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:
parent
388276c261
commit
904392ddcb
@ -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 |
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 не читается: значения как в файле"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user