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

Merged
aep merged 4 commits from feat/42-xls-number-format into main 2026-10-15 17:37:31 +03:00
15 changed files with 292 additions and 32 deletions
Showing only changes of commit 388276c261 - Show all commits

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ 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
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
@ -33,7 +34,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]: ...
@ -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=OUTLINE_UNAVAILABLE_WARNING in read_warnings
) )
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

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

View File

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

View File

@ -54,12 +54,21 @@ _UNPARSED = FormatResult("unparsed")
_NO_VALUE = FormatResult("value", None) _NO_VALUE = FormatResult("value", None)
_EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр
def _excel_precision(value: Decimal) -> Decimal:
"""Число до 15 значащих цифр: -1486533582.3500001 (артефакт float в xls) равно условию `[=-1486533582.35]`."""
return Decimal(f"{value:.{_EXCEL_DIGITS}g}")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Condition: class _Condition:
operator: str operator: str
operand: Decimal operand: Decimal
def holds(self, value: Decimal) -> bool: def holds(self, raw: Decimal) -> bool:
value = _excel_precision(raw)
match self.operator: match self.operator:
case "=": case "=":
return value == self.operand return value == self.operand

View File

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

View File

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

View File

@ -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).
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: []} # значения по формату в тыс. руб.; итоги округлены до тысяч: мягкие нарушения «в пределах округления» (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":

View File

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

View File

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

View File

@ -4,7 +4,7 @@ from decimal import Decimal
import pytest import pytest
from src.parser.statements.number_format import display_amount from src.parser.statements.number_format import FormatResult, display_amount
def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]: def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]:
@ -132,3 +132,12 @@ def test_max_numeric_18_2_range() -> 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("9999999999999999000", "0,") == ("value", Decimal("9999999999999999.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"))

View File

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

View File

@ -33,6 +33,7 @@ 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
@ -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,21 @@ 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, 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")
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))