Andrey Epifancev c780dfd858 fix(statements): округление итогов не портит статус (OQ-138), слабые продолжения только сразу после формы и той же формы, подстроки по порядку строк
- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 %
  от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не
  меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done.
- N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа;
  присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser.
- M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230).
- N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name.
- T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке.
make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden).

Refs #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:14:21 +04:00

152 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Оркестратор книги (design D-1): лимит размера → чтение → дедлайн → вид листа → конвейеры ОСВ и форм → статус.
Карта исходов та же, что была у `OsvParser.parse` (D-6 Среза 2a): нет документов ОСВ и форм → `INVALID_DOCUMENT`;
лист ОСВ без заголовка/колонок → `INVALID_FORMAT`; книга не открывается → `UNREADABLE`; сигнатура/лимиты →
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Нарушение контрольных сумм ОСВ или
соотношений формы хотя бы в одном документе → `needs_check`. Документы: ОСВ в порядке листов, затем формы.
"""
import time
from collections.abc import Callable, Sequence
from typing import Protocol
from loguru import logger
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument
from src.parser import ParseOutcome
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, classify_sheet
from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError
from src.parser.grid import Sheet
from src.parser.osv import OsvParser, data_rows, sheet_line, unique
from src.parser.readers import read_book
from src.parser.readers._common import one_line_reason
from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
from src.parser.rules import Rules
from src.parser.statements.detect import is_weak_continuation
__all__ = ["BookParser", "StatementSheetParser"]
_MS_IN_SECOND = 1000
STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATION})
class StatementSheetParser(Protocol):
def parse_sheets(
self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline
) -> tuple[list[StatementDocument], list[str], bool]: ...
class BookParser:
def __init__( # noqa: PLR0913 - все зависимости оркестратора именованные
self,
rules: Rules,
*,
osv: OsvParser,
statements: StatementSheetParser,
max_bytes: int,
timeout_seconds: float,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._rules = rules
self._osv = osv
self._statements = statements
self._max_bytes = max_bytes
self._timeout_seconds = timeout_seconds
self._clock = clock
@property
def max_bytes(self) -> int:
return self._max_bytes
@property
def timeout_seconds(self) -> float:
return self._timeout_seconds
def parse(self, data: bytes, file_type: FileType) -> ParseOutcome:
started = self._clock()
warnings: list[str] = []
try:
documents, ok = self._parse_book(data, file_type, warnings)
except ParserError as exc:
if exc.code is ErrorCode.UNREADABLE:
logger.warning("Книга не читается: {reason}", reason=exc.message)
return self._error(exc.code, exc.message, warnings, started)
except Exception as exc:
reason = one_line_reason(exc)
logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason)
return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started)
rows = sum(
data_rows(document) if isinstance(document, OsvDocument) else len(document.lines) for document in documents
)
metrics = Metrics(duration_ms=self._elapsed_ms(started), rows=rows, warnings=warnings)
return ParseOutcome(
status=ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK, documents=tuple(documents), metrics=metrics
)
def _parse_book(
self, data: bytes, file_type: FileType, warnings: list[str]
) -> tuple[list[OsvDocument | StatementDocument], bool]:
if len(data) > self._max_bytes:
raise UnsupportedFormatError(
f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: "
"книга не открывается"
)
deadline = Deadline(self._timeout_seconds, self._clock)
sheets, read_warnings = read_book(
data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
)
warnings.extend(unique(read_warnings))
osv_documents: list[OsvDocument] = []
statement_sheets: list[tuple[Sheet, SheetKind]] = []
ok = True
previous_is_statement = False # N-2: слабое продолжение — только сразу после листа формы
for sheet in sheets:
deadline.check(f"лист «{sheet.name}»")
kind = classify_sheet(sheet, self._rules)
if kind in STATEMENT_KINDS:
statement_sheets.append((sheet, kind))
previous_is_statement = True
continue
if kind is SheetKind.OTHER and previous_is_statement and is_weak_continuation(sheet, self._rules):
kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами сразу после листа формы
statement_sheets.append((sheet, kind))
continue
previous_is_statement = False
if kind is SheetKind.OTHER:
warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы"))
continue
try:
document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline)
except ParserError:
# T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными
warnings.extend(
sheet_line(form_sheet, f"форма не разобрана: книга завершилась ошибкой листа ОСВ «{sheet.name}»")
for form_sheet, _ in statement_sheets
)
raise
osv_documents.append(document)
warnings.extend(sheet_warnings)
ok = ok and sheet_ok
statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(
statement_sheets, deadline
)
warnings.extend(statement_warnings)
documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents]
if not documents:
raise InvalidDocumentError(f"в книге нет листов ОСВ или форм: листов просмотрено {len(sheets)}")
return documents, ok and statements_ok
def _elapsed_ms(self, started: float) -> int:
return max(0, round((self._clock() - started) * _MS_IN_SECOND))
def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome:
fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)}
if warnings:
fields["warnings"] = warnings
return ParseOutcome(
status=ResultStatus.ERROR,
error=MessageError(code=code, message=" ".join(message.split())),
metrics=Metrics.model_validate(fields),
)