number_format: 15 значащих цифр с округлением вверх, неразобранные условия и >4 секций; признак xlsb по содержимому (#42)

Фикс-волна по финальному ревью: I-1, M-1, M-4, M-5, M-6 (T4.1, T4.2); тесты на ;;;, 5 секций, [$-419], \(…\), [=5]0;[=5]0,.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 17:46:11 +04:00
parent 388276c261
commit 904392ddcb
8 changed files with 96 additions and 36 deletions

View File

@ -306,7 +306,7 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ
формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше
`fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check` `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check`
задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — округление итогов, OQ-138). задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138).
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся - Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
одиноким продолжением и даёт `needs_check`. одиноким продолжением и даёт `needs_check`.
- Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся - Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся
@ -321,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 `done` | значения по числовому формату (тыс. руб.); итоги округлены до тысяч: мягкие нарушения «в пределах округления» (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 `done` | значения по числовому формату (тыс. руб.); 2200 — малая разность больших слагаемых, округление (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 `done`, INCOME `done` | значения по числовому формату `#,##0,` (тыс. руб.); расхождения итогов — округление (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

@ -19,10 +19,9 @@ 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.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING
from src.parser.rules import Rules from src.parser.rules import Rules
from src.parser.statements.detect import is_weak_continuation from src.parser.statements.detect import is_weak_continuation
@ -94,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
@ -130,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, formats_unreadable=OUTLINE_UNAVAILABLE_WARNING in read_warnings 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

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

@ -22,7 +22,7 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from decimal import ROUND_HALF_UP, Context, Decimal, InvalidOperation
from functools import lru_cache from functools import lru_cache
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
@ -32,6 +32,7 @@ from src.parser.numbers import quantize_money, to_money
__all__ = ["FormatResult", "display_amount"] __all__ = ["FormatResult", "display_amount"]
_PLACEHOLDERS = "0#?" _PLACEHOLDERS = "0#?"
_MAX_PARTS = 4
_MAX_SECTIONS = 3 # четвёртая секция — текст, для чисел не используется _MAX_SECTIONS = 3 # четвёртая секция — текст, для чисел не используется
_MAX_DECIMALS = 2 # Money = numeric(18,2) _MAX_DECIMALS = 2 # Money = numeric(18,2)
_SCALE_STEP = 3 # одна масштабирующая запятая = деление на 1000 _SCALE_STEP = 3 # одна масштабирующая запятая = деление на 1000
@ -57,9 +58,15 @@ _NO_VALUE = FormatResult("value", None)
_EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр _EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр
_EXCEL_CONTEXT = Context(prec=_EXCEL_DIGITS, rounding=ROUND_HALF_UP)
def _excel_precision(value: Decimal) -> Decimal: def _excel_precision(value: Decimal) -> Decimal:
"""Число до 15 значащих цифр: -1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`.""" """Число до 15 значащих цифр, половина от нуля, как в Excel.
return Decimal(f"{value:.{_EXCEL_DIGITS}g}")
-1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`; 1500499.9999999998 — это 1500500.
"""
return _EXCEL_CONTEXT.plus(value)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -142,7 +149,9 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли
if found is not None: if found is not None:
if condition is not None: if condition is not None:
return None return None
condition = _Condition(found.group(1), Decimal(found.group(2))) 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] и прочее оформление игнорируются index = end + 1 # цвет, [$-419] и прочее оформление игнорируются
continue continue
if char == "\\": if char == "\\":
@ -197,8 +206,8 @@ def _build_section(condition: _Condition | None, literal: list[str], kinds: list
def _parse_format(fmt: str) -> tuple[_Section, ...] | None: def _parse_format(fmt: str) -> tuple[_Section, ...] | None:
"""Секции формата либо None, если формат не разобран.""" """Секции формата либо None, если формат не разобран."""
parts = _split_sections(fmt) parts = _split_sections(fmt)
if parts is None: if parts is None or len(parts) > _MAX_PARTS:
return None return None # Excel не принимает формат больше чем из четырёх секций
sections: list[_Section] = [] sections: list[_Section] = []
for part in parts[:_MAX_SECTIONS]: for part in parts[:_MAX_SECTIONS]:
section = _parse_section(part) section = _parse_section(part)
@ -233,7 +242,7 @@ def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | No
def _scaled(raw: Decimal, section: _Section) -> FormatResult: def _scaled(raw: Decimal, section: _Section) -> FormatResult:
digits = min(section.decimals, _MAX_DECIMALS) digits = min(section.decimals, _MAX_DECIMALS)
try: try:
magnitude = abs(raw).scaleb(-_SCALE_STEP * section.scale) magnitude = abs(_excel_precision(raw)).scaleb(-_SCALE_STEP * section.scale)
rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP) rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP)
except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2) except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2)
return _UNPARSED return _UNPARSED

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

@ -3,7 +3,7 @@
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. # Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора.
# Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту». # Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту».
# Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах; # Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах;
# мягкие нарушения «в пределах округления» — следствие округления итогов до тысяч (OQ-138). # мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden.
files: files:
"unagrande-co/2024-FY/Б.Б Лист 1.xls": "unagrande-co/2024-FY/Б.Б Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"' organization: 'Акционерное общество "Унагранде Компани"'
@ -15,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":
@ -36,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":
@ -44,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

@ -42,6 +42,11 @@ def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]:
("-872206881", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"), ("-872206881", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"),
("-646532", '[=-646532]"(647)"', "-647.00"), ("-646532", '[=-646532]"(647)"', "-647.00"),
("12345678", '[=-872206880.07]"(872 207)";#,##0,', "12346.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: def test_value(raw: str, fmt: str, expected: str) -> None:
@ -59,6 +64,8 @@ def test_value(raw: str, fmt: str, expected: str) -> None:
("0", '[=0]"-";General'), ("0", '[=0]"-";General'),
("-0.79", '[=-0.79]"-"'), ("-0.79", '[=-0.79]"-"'),
("0", '#,##0,;(#,##0,);"-"'), ("0", '#,##0,;(#,##0,);"-"'),
("0", ";;;"),
("7", ";;;"),
], ],
) )
def test_no_value(raw: str, fmt: str) -> None: def test_no_value(raw: str, fmt: str) -> None:
@ -78,6 +85,10 @@ def test_no_value(raw: str, fmt: str) -> None:
("0", '[=0]"н/д"'), ("0", '[=0]"н/д"'),
("5", '"н/д"'), ("5", '"н/д"'),
("5", "0 руб"), ("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: def test_unparsed(raw: str, fmt: str) -> None:
@ -125,13 +136,15 @@ def test_huge_value_out_of_range_is_unparsed() -> None:
def test_max_numeric_18_2_range() -> None: def test_max_numeric_18_2_range() -> None:
assert shown("9999999999999999", "0") == ("value", Decimal("9999999999999999.00")) """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) assert shown("99999999999999999", "0") == ("unparsed", None)
def test_scaled_huge_value_fits() -> None: def test_scaled_huge_value_fits() -> None:
assert shown("99999999999999999000", "0,") == ("unparsed", None) assert shown("99999999999999999000", "0,") == ("unparsed", None)
assert shown("9999999999999999000", "0,") == ("value", Decimal("9999999999999999.00")) assert shown("999999999999999000", "0,") == ("value", Decimal("999999999999999.00"))
def test_condition_compares_with_excel_15_digit_precision() -> None: def test_condition_compares_with_excel_15_digit_precision() -> None:
@ -141,3 +154,19 @@ def test_condition_compares_with_excel_15_digit_precision() -> None:
result = display_amount(Decimal("-1486533582.3500001"), fmt) result = display_amount(Decimal("-1486533582.3500001"), fmt)
assert result == FormatResult("value", Decimal("-1486534")) 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

@ -33,9 +33,9 @@ from src.parser.errors import ParseTimeoutError
from src.parser.grid import Sheet from src.parser.grid import Sheet
from src.parser.hierarchy import BodyRow from src.parser.hierarchy import BodyRow
from src.parser.osv import OsvParser from src.parser.osv import OsvParser
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING
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 = "Обороты за период"
@ -769,12 +769,13 @@ def test_statement_sheets_are_reported_when_osv_sheet_fails(rules: Rules) -> Non
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, monkeypatch: pytest.MonkeyPatch) -> None: def test_xlsb_book_marks_formats_unreadable_for_statements_only(rules: Rules) -> None:
"""#42, R-7: признак xlsb из read_book доходит до конвейера форм; ОСВ-документ предупреждения не получает.""" """#42, R-7: xlsb определяется по содержимому книги (без подмены предупреждений); ОСВ предупреждения не получает."""
sheets = (sheet_from_rows(BALANCE_ROWS, "ББ"), sheet_from_rows(mini_osv(), "ОСВ 60")) cells = {
monkeypatch.setattr("src.parser.book.read_book", lambda *_args, **_kwargs: (sheets, [OUTLINE_UNAVAILABLE_WARNING])) 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(b"ignored", "XLSB") }
outcome = book(rules).parse(write_xlsb(cells), "XLSB")
xlsb_warnings = [w for w in warnings_of(outcome) if "формат чисел xlsb" in w] xlsb_warnings = [w for w in warnings_of(outcome) if "формат чисел xlsb" in w]
assert xlsb_warnings == ["sheet=ББ формат чисел xlsb не читается: значения как в файле"] assert xlsb_warnings == ["sheet=ББ формат чисел xlsb не читается: значения как в файле"]