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 не читается: значения как в файле`. Разбор ОСВ
формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше
`fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check`
задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — округление итогов, OQ-138).
задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138).
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
одиноким продолжением и даёт `needs_check`.
- Лист с 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/Б.Б. Лист 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-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив |
| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки |
| `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/ББ. Лист 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 без шапки |
| `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_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
| `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.grid import Sheet
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._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING
from src.parser.rules import Rules
from src.parser.statements.detect import is_weak_continuation
@ -94,10 +93,11 @@ class BookParser:
"книга не открывается"
)
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
)
warnings.extend(unique(read_warnings))
sheets = read.sheets
warnings.extend(unique(read.warnings))
osv_documents: list[OsvDocument] = []
statement_sheets: list[tuple[Sheet, SheetKind]] = []
ok = True
@ -130,7 +130,7 @@ class BookParser:
warnings.extend(sheet_warnings)
ok = ok and sheet_ok
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)
documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents]

View File

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

View File

@ -22,7 +22,7 @@
import re
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 typing import Literal, NamedTuple
@ -32,6 +32,7 @@ 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
@ -57,9 +58,15 @@ _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 значащих цифр: -1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`."""
return Decimal(f"{value:.{_EXCEL_DIGITS}g}")
"""Число до 15 значащих цифр, половина от нуля, как в Excel.
-1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`; 1500499.9999999998 — это 1500500.
"""
return _EXCEL_CONTEXT.plus(value)
@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 condition is not 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] и прочее оформление игнорируются
continue
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:
"""Секции формата либо None, если формат не разобран."""
parts = _split_sections(fmt)
if parts is None:
return None
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)
@ -233,7 +242,7 @@ def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | No
def _scaled(raw: Decimal, section: _Section) -> FormatResult:
digits = min(section.decimals, _MAX_DECIMALS)
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)
except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2)
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(
core: Core, uploads: Uploads, fixtures_dir: Path
) -> None:
"""«Реальные формы через воркер»: книга НПЗ → ack → `done`; расхождения итогов — округление (OQ-138, мягкие)."""
"""«Реальные формы через воркер»: книга НПЗ → ack → `done`; жёстких расхождений нет, мягкие — по golden."""
request = _new_request(key_name="npz_2025_q3.xlsx")
request["file_type"] = "XLSX"
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"]
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
assert balance["checks"]["relations_failed"] == [] and income["checks"]["relations_failed"] == []
# расхождения в пределах округления (OQ-138) видны аналитику как мягкие
assert "1600 = 1700" in {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"]}
# мягкие расхождения (OQ-138) видны аналитику; набор — как в golden НПЗ
assert {item["relation"] for item in balance["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)
assert "2400" in {line["line_code"] for line in income["lines"]}
await core.assert_quiet(request)

View File

@ -3,7 +3,7 @@
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора.
# Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту».
# Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах;
# мягкие нарушения «в пределах округления» — следствие округления итогов до тысяч (OQ-138).
# мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden.
files:
"unagrande-co/2024-FY/Б.Б Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"'
@ -15,7 +15,7 @@ files:
organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697"
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":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}]
"unagrande-group/2024-FY/ББ. Лист 1 .xls":
@ -36,7 +36,7 @@ files:
organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141"
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":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}]
"npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx":
@ -44,8 +44,8 @@ files:
inn: "2315995868"
inn_optional_for: [INCOME]
documents:
- {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # 1600 = 1700 на 1.94/9.02/2.48 — округление (OQ-138)
- {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # округление итогов (OQ-138); лист с единственной строкой 2400 присоединён (O-9)
- {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: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9)
"mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
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"),
("-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:
@ -59,6 +64,8 @@ def test_value(raw: str, fmt: str, expected: str) -> None:
("0", '[=0]"-";General'),
("-0.79", '[=-0.79]"-"'),
("0", '#,##0,;(#,##0,);"-"'),
("0", ";;;"),
("7", ";;;"),
],
)
def test_no_value(raw: str, fmt: str) -> None:
@ -78,6 +85,10 @@ def test_no_value(raw: str, fmt: str) -> None:
("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:
@ -125,13 +136,15 @@ def test_huge_value_out_of_range_is_unparsed() -> 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)
def test_scaled_huge_value_fits() -> 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:
@ -141,3 +154,19 @@ def test_condition_compares_with_excel_15_digit_precision() -> None:
result = display_amount(Decimal("-1486533582.3500001"), fmt)
assert result == FormatResult("value", Decimal("-1486534"))
def test_value_is_rounded_to_15_digits_half_up_before_scaling() -> None:
"""Артефакт float 1500499.9999999998 в Excel — 1500500, после деления на 1000 половина округляется вверх."""
assert shown("1500499.9999999998", "#,##0,") == ("value", Decimal("1501"))
def test_condition_operand_is_rounded_to_15_digits() -> None:
"""Литерал «-» — «нет значения»; «x» дал бы unparsed по R-5 независимо от условия, поэтому берём прочерк."""
result = display_amount(Decimal("0.1234567890123456"), '[=0.1234567890123456]"-"')
assert result == FormatResult("value", None)
def test_condition_that_does_not_match_the_pattern_is_unparsed() -> None:
assert shown("0", '[=1E-5]"x"') == ("unparsed", None)

View File

@ -33,9 +33,9 @@ from src.parser.errors import ParseTimeoutError
from src.parser.grid import Sheet
from src.parser.hierarchy import BodyRow
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.statements.statement import StatementParser
from tests.synthetic.xlsb_writer import write_xlsb
OPENING = "Сальдо на начало периода"
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)
def test_xlsb_book_marks_formats_unreadable_for_statements_only(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None:
"""#42, R-7: признак xlsb из read_book доходит до конвейера форм; ОСВ-документ предупреждения не получает."""
sheets = (sheet_from_rows(BALANCE_ROWS, "ББ"), sheet_from_rows(mini_osv(), "ОСВ 60"))
monkeypatch.setattr("src.parser.book.read_book", lambda *_args, **_kwargs: (sheets, [OUTLINE_UNAVAILABLE_WARNING]))
outcome = book(rules).parse(b"ignored", "XLSB")
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 не читается: значения как в файле"]