ББ/ОФР из Excel: значение как показывает числовой формат (#42, AURORA-1528) #43
@ -117,5 +117,6 @@ property, покрытие словарей); без каталога `../fixtur
|
||||
просмотром diff и отдельным коммитом в `fixtures`.
|
||||
|
||||
**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`:
|
||||
`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Интеграционный тест книги НПЗ ожидает
|
||||
`needs_check` (рубли при шапке «тыс. руб.», OQ-138), а не `done`.
|
||||
`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Значения ББ/ОФР из Excel — как их показывает
|
||||
числовой формат ячейки (#42, `statements/number_format.py`; xlsb — как в файле + предупреждение); ОСВ формат не применяет.
|
||||
Книга НПЗ в манифесте — `done` (итоги расходятся в пределах округления, OQ-138).
|
||||
|
||||
19
README.md
19
README.md
@ -297,9 +297,16 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|
||||
|
||||
### Ограничения
|
||||
|
||||
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и доле значений с дробной
|
||||
частью не меньше `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет —
|
||||
`needs_check` задают нарушенные соотношения (при рублях с копейками итоги расходятся, OQ-138).
|
||||
- Единицы не нормализуются: `units` — текст шапки. Значение `amount` — **как его показывает документ** (#42): в xlsx/xls
|
||||
применяется числовой формат ячейки (`statements/number_format.py`): `#,##0,` и `0,` делят на 1000 (рубли с копейками
|
||||
из 1С становятся тысячами), `#,##0` округляет до целого (половина от нуля, как в Excel), условные секции
|
||||
(`[=0]"-"`, `[=-646532]"(647)"`) дают прочерк или текст-литерал («(647)» -> -647). Формат `General` — сырое значение.
|
||||
Формат, который не удалось разобрать (`%`, даты, дроби, экспонента), — сырое значение и одно предупреждение на
|
||||
документ: `формат чисел не разобран, значения как в ячейках: «…»`. В xlsb числовые форматы не читаются (pyxlsb): значения
|
||||
как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ
|
||||
формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше
|
||||
`fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check`
|
||||
задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — округление итогов, OQ-138).
|
||||
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
|
||||
одиноким продолжением и даёт `needs_check`.
|
||||
- Лист с 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/Б.Б. Лист 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-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 `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 без шапки |
|
||||
| `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_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
|
||||
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7).
|
||||
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
|
||||
# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление.
|
||||
# Значения форм берутся как их показывает числовой формат ячейки (#42, 15.10.2026): рубли с копейками в тысячах.
|
||||
# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа
|
||||
# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус:
|
||||
# уходит в soft_relations_failed с предупреждением «в пределах округления».
|
||||
|
||||
@ -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._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
|
||||
|
||||
@ -33,7 +34,7 @@ STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATI
|
||||
|
||||
class StatementSheetParser(Protocol):
|
||||
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]: ...
|
||||
|
||||
|
||||
@ -129,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
|
||||
statement_sheets, deadline, formats_unreadable=OUTLINE_UNAVAILABLE_WARNING in read_warnings
|
||||
)
|
||||
warnings.extend(statement_warnings)
|
||||
documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents]
|
||||
|
||||
@ -1,21 +1,23 @@
|
||||
"""Строки формы с кодом и их значения (design D-6).
|
||||
|
||||
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
|
||||
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
|
||||
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
|
||||
нормализуются; счётчики дробных значений нужны предупреждению документа (`statement.py`: одно на документ).
|
||||
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа —
|
||||
`statement_money`: показ по числовому формату ячейки (#42), иначе `numbers.to_money` (Decimal, скобки → минус,
|
||||
прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не нормализуются; счётчики
|
||||
дробных значений нужны предупреждению документа (`statement.py`: одно на документ).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
from src.contract.result import StatementLine, StatementValue
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.numbers import to_money
|
||||
from src.parser.grid import Cell, Sheet
|
||||
from src.parser.numbers import MoneyParse, to_money
|
||||
from src.parser.rules import Rules
|
||||
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
|
||||
|
||||
@ -27,13 +29,28 @@ class LinesResult:
|
||||
skipped_with_sums: int = 0
|
||||
fractional_values: int = 0
|
||||
numeric_values: int = 0
|
||||
unparsed_formats: set[str] = field(default_factory=set) # числовые форматы, которые не удалось разобрать (#42)
|
||||
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))
|
||||
parsed = to_money(cell)
|
||||
parsed = statement_money(cell, unparsed_formats)
|
||||
if parsed.value is not None:
|
||||
has_fractional = parsed.value % 1 != 0
|
||||
value = StatementValue(
|
||||
@ -100,7 +117,10 @@ def collect_lines( # noqa: PLR0913
|
||||
if code is None:
|
||||
if is_numbering_row(sheet, row, rules): # «1 2 3 4 5», повторённая на разрыве страницы
|
||||
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
|
||||
continue
|
||||
if code in seen:
|
||||
@ -109,7 +129,7 @@ def collect_lines( # noqa: PLR0913
|
||||
seen.add(code)
|
||||
values: list[StatementValue] = []
|
||||
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)
|
||||
if value.amount is not None:
|
||||
result.numeric_values += 1
|
||||
|
||||
@ -21,6 +21,7 @@ class Draft:
|
||||
lonely_continuation: bool = False
|
||||
fractional_values: int = 0
|
||||
numeric_values: int = 0
|
||||
unparsed_formats: set[str] = field(default_factory=set)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
|
||||
@ -54,12 +54,21 @@ _UNPARSED = FormatResult("unparsed")
|
||||
_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)
|
||||
class _Condition:
|
||||
operator: str
|
||||
operand: Decimal
|
||||
|
||||
def holds(self, value: Decimal) -> bool:
|
||||
def holds(self, raw: Decimal) -> bool:
|
||||
value = _excel_precision(raw)
|
||||
match self.operator:
|
||||
case "=":
|
||||
return value == self.operand
|
||||
|
||||
@ -18,12 +18,22 @@ from src.parser.statements.header import parse_header
|
||||
from src.parser.statements.lines import collect_lines
|
||||
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_SEPARATOR = ","
|
||||
CONTINUATION_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:
|
||||
@ -35,8 +45,10 @@ class StatementParser:
|
||||
self._rules = rules
|
||||
|
||||
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]:
|
||||
"""`formats_unreadable` — книга xlsb: числовые форматы недоступны, каждый документ с колонкой кодов получает
|
||||
предупреждение «значения как в файле» (R-7)."""
|
||||
drafts: list[Draft] = []
|
||||
warnings: list[str] = []
|
||||
dropped = False
|
||||
@ -49,6 +61,10 @@ class StatementParser:
|
||||
drafts.append(self._block_draft(sheet, block, warnings))
|
||||
else:
|
||||
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]
|
||||
for draft in drafts:
|
||||
warnings.extend(draft.warnings)
|
||||
@ -105,6 +121,7 @@ class StatementParser:
|
||||
join(open_draft, sheet.name, result.lines, result.duplicates)
|
||||
open_draft.fractional_values += result.fractional_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)
|
||||
if weak: # N-1: присоединение по 1–2 строкам видно аналитику
|
||||
warnings.append(
|
||||
@ -155,6 +172,7 @@ class StatementParser:
|
||||
draft.lines = result.lines
|
||||
draft.duplicates = result.duplicates
|
||||
draft.fractional_values, draft.numeric_values = result.fractional_values, result.numeric_values
|
||||
draft.unparsed_formats = result.unparsed_formats
|
||||
texts = [*header_warnings, *result.warnings]
|
||||
if 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 [],
|
||||
lines=[],
|
||||
)
|
||||
draft.warnings.extend(self._unparsed_formats_warning(draft))
|
||||
draft.warnings.extend(self._fractional_warning(draft))
|
||||
checks, check_warnings = run_statement_checks(
|
||||
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
|
||||
)
|
||||
|
||||
@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]:
|
||||
"""Одно предупреждение на документ (все его листы): при «тыс.» в единицах доля значений с дробной частью ≥
|
||||
порога словаря — вероятно, значения в рублях (OQ-137)."""
|
||||
|
||||
43
api/tests/fixtures/test_statements_number_format.py
vendored
Normal file
43
api/tests/fixtures/test_statements_number_format.py
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
"""Значение ББ/ОФР из Excel — как его показывает числовой формат ячейки (#42): конкретные ячейки реальных выгрузок."""
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.contract import FileType, StatementDocument
|
||||
from src.parser import get_parser
|
||||
|
||||
pytestmark = pytest.mark.fixtures
|
||||
STATEMENTS = "statements-xlsx"
|
||||
NPZ = "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx"
|
||||
UNAGRANDE_INCOME = "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls"
|
||||
MEZHENINOVSKAYA_BALANCE = "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx"
|
||||
|
||||
|
||||
def _amount(fixtures_dir: Path, relative: str, document_type: str, code: str, column: int = 0) -> Decimal | None:
|
||||
path = fixtures_dir / STATEMENTS / relative
|
||||
outcome = get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||||
assert outcome.error is None, outcome.error
|
||||
for document in outcome.documents:
|
||||
if isinstance(document, StatementDocument) and document.document_type == document_type:
|
||||
for line in document.lines:
|
||||
if line.line_code == code:
|
||||
return line.values[column].amount
|
||||
raise AssertionError(f"строка {code} не найдена")
|
||||
|
||||
|
||||
def test_npz_scaling_comma_gives_thousands(fixtures_dir: Path) -> None:
|
||||
"""P43: 10741981390 с форматом `#,##0,` показывается как 10 741 981 (тыс. руб.)."""
|
||||
assert _amount(fixtures_dir, NPZ, "BALANCE", "1150") == Decimal("10741981")
|
||||
|
||||
|
||||
def test_unagrande_conditional_literal_gives_negative_thousands(fixtures_dir: Path) -> None:
|
||||
"""Значение -646532 с форматом `[=-646532]"(647)"` показывается текстом «(647)» -> -647."""
|
||||
assert _amount(fixtures_dir, UNAGRANDE_INCOME, "INCOME", "2411") == Decimal("-647")
|
||||
|
||||
|
||||
def test_mezheninovskaya_rounds_kopecks_to_displayed_integer(fixtures_dir: Path) -> None:
|
||||
"""M31: 898852.21795 с форматом `#,##0` показывается как 898 852."""
|
||||
assert _amount(fixtures_dir, MEZHENINOVSKAYA_BALANCE, "BALANCE", "1150") == Decimal("898852")
|
||||
@ -1,7 +1,9 @@
|
||||
# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx.
|
||||
# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится).
|
||||
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм, рубли при шапке «тыс. руб.»), не дефект разбора.
|
||||
# Значения сверены прогоном парсера на файлах (R-13, OQ-138): расхождения с первоначальным планом помечены «по факту».
|
||||
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора.
|
||||
# Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту».
|
||||
# Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах;
|
||||
# мягкие нарушения «в пределах округления» — следствие округления итогов до тысяч (OQ-138).
|
||||
files:
|
||||
"unagrande-co/2024-FY/Б.Б Лист 1.xls":
|
||||
organization: 'Акционерное общество "Унагранде Компани"'
|
||||
@ -13,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: []} # значения по формату в тыс. руб.; итоги округлены до тысяч: мягкие нарушения «в пределах округления» (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":
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
`n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
from collections.abc import Sequence
|
||||
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_cols=n_cols,
|
||||
)
|
||||
|
||||
|
||||
def with_formats(sheet: Sheet, formats: dict[tuple[int, int], str]) -> Sheet:
|
||||
"""Копия листа, где числовым ячейкам (row, col) проставлен числовой формат (#42)."""
|
||||
cells = dict(sheet.cells)
|
||||
for position, number_format in formats.items():
|
||||
cells[position] = dataclasses.replace(cells[position], number_format=number_format)
|
||||
return dataclasses.replace(sheet, cells=cells)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from decimal import Decimal
|
||||
|
||||
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.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}
|
||||
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
|
||||
|
||||
@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
|
||||
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]:
|
||||
@ -132,3 +132,12 @@ def test_max_numeric_18_2_range() -> None:
|
||||
def test_scaled_huge_value_fits() -> None:
|
||||
assert shown("99999999999999999000", "0,") == ("unparsed", None)
|
||||
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"))
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
from decimal import Decimal
|
||||
|
||||
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.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")
|
||||
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)
|
||||
|
||||
@ -33,6 +33,7 @@ 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
|
||||
|
||||
@ -642,10 +643,12 @@ class _FakeStatements:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[tuple[str, SheetKind]] = []
|
||||
self.formats_unreadable = False
|
||||
|
||||
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]:
|
||||
self.formats_unreadable = formats_unreadable
|
||||
self.received = [(sheet.name, kind) for sheet, kind in sheets]
|
||||
documents = [
|
||||
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:
|
||||
class _NotOk(_FakeStatements):
|
||||
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]:
|
||||
documents, warnings, _ = super().parse_sheets(sheets, deadline)
|
||||
documents, warnings, _ = super().parse_sheets(sheets, deadline, formats_unreadable=formats_unreadable)
|
||||
return documents, warnings, False
|
||||
|
||||
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 "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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user