feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты
checks.py: тождество сальдо по каждой строке, суммы родителя = Σ детей того же data_type (CURRENCY/QUANTITY исключены; обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя), «Итого» = корневой счёт (R-13: нет корня — «Итого» пусто/0; R-20: нет «Итого» — false и предупреждение). osv.py: OsvParser — лимит размера, чтение по сигнатуре, листы по содержимому (формы ББ/ОФР и прочие — пропуск с предупреждением), карта исходов D-6, кооперативный дедлайн, metrics (rows без TOTAL, warnings с «sheet=<имя> hierarchy=<стратегия>» первой записью по листу). get_parser(settings, rules_dir=…) → OsvParser (словари грузятся при создании: битый словарь — падение старта); PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS в Settings и .env.*; app.py передаёт настройки. test_null_parser.py → test_parser_factory.py. Тесты: юнит-карта статусов, «Испорченная сумма», «Пустая ОСВ», два листа ОСВ, новый синоним без релиза кода (R-8); property-тест на 66 реальных файлах (все done, < 5 с, схема v1.1). test_app и интеграционный round-trip ждут ошибку реального парсера вместо NOT_IMPLEMENTED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
da7215b314
commit
e705d75520
@ -36,3 +36,7 @@ WORKER_CONCURRENCY=1
|
||||
PUBLISH_RETRIES=3
|
||||
# Размер process-local LRU обработанных job_id (защита от дублей доставки)
|
||||
DONE_CACHE_SIZE=1000
|
||||
# Лимиты разбора: размер файла в байтах (больше — UNSUPPORTED_FORMAT, книга не открывается; 52428800 = 50 МиБ)
|
||||
# и время разбора одного файла в секундах (больше — TIMEOUT); оба ниже reply_deadline ядра (5 мин)
|
||||
PARSE_MAX_BYTES=52428800
|
||||
PARSE_TIMEOUT_SECONDS=120
|
||||
|
||||
@ -24,3 +24,6 @@ NATS_ENABLED=false
|
||||
WORKER_CONCURRENCY=1
|
||||
PUBLISH_RETRIES=3
|
||||
DONE_CACHE_SIZE=1000
|
||||
# Лимиты разбора (design D-8): значения по умолчанию
|
||||
PARSE_MAX_BYTES=52428800
|
||||
PARSE_TIMEOUT_SECONDS=120
|
||||
|
||||
@ -80,7 +80,7 @@ def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker
|
||||
worker = RecognitionWorker(
|
||||
storage=storage,
|
||||
publisher=nats,
|
||||
parser=get_parser(),
|
||||
parser=get_parser(settings),
|
||||
service_name=settings.SERVICE_NAME,
|
||||
service_version=VERSION,
|
||||
concurrency=settings.WORKER_CONCURRENCY,
|
||||
|
||||
@ -48,6 +48,10 @@ class Settings(BaseSettings):
|
||||
WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно
|
||||
PUBLISH_RETRIES: NonNegativeInt = 3 # повторы публикации ack/результата после первой попытки (0 — без повторов)
|
||||
DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id
|
||||
# Лимиты разбора (design D-8): файл больше лимита — UNSUPPORTED_FORMAT без чтения; превышение времени — TIMEOUT
|
||||
# (кооперативно: между листами, стратегиями и блоками строк). Оба ниже reply_deadline ядра (5 мин)
|
||||
PARSE_MAX_BYTES: PositiveInt = 50 * 1024 * 1024
|
||||
PARSE_TIMEOUT_SECONDS: PositiveInt = 120
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка».
|
||||
|
||||
Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат.
|
||||
Срез 0 — только заглушка `NullParser`; реализация Среза 2 подменяет фабрику `get_parser`, воркер не меняется.
|
||||
Реализация по умолчанию — `OsvParser` (`osv.py`, Срез 2a); `NullParser` (заглушка Среза 0) остаётся для тестов воркера.
|
||||
|
||||
`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его
|
||||
через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument
|
||||
from src.core.config import Settings
|
||||
from src.parser.rules import DEFAULT_RULES_DIR, load_rules
|
||||
|
||||
__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"]
|
||||
|
||||
@ -57,6 +60,17 @@ class NullParser:
|
||||
)
|
||||
|
||||
|
||||
def get_parser() -> Parser:
|
||||
"""Фабрика парсера для воркера. Срез 2 заменяет возвращаемую реализацию."""
|
||||
return NullParser()
|
||||
def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser:
|
||||
"""Фабрика парсера для воркера: `OsvParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` /
|
||||
`PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`).
|
||||
|
||||
Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом
|
||||
и ключом, сервис не стартует, а не падает на первом файле.
|
||||
"""
|
||||
# `osv.py` импортирует `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим
|
||||
from src.parser.osv import OsvParser # noqa: PLC0415 - цикл импорта osv → src.parser
|
||||
|
||||
fields = Settings.model_fields
|
||||
max_bytes = fields["PARSE_MAX_BYTES"].default if settings is None else settings.PARSE_MAX_BYTES
|
||||
timeout_seconds = fields["PARSE_TIMEOUT_SECONDS"].default if settings is None else settings.PARSE_TIMEOUT_SECONDS
|
||||
return OsvParser(load_rules(rules_dir), max_bytes=max_bytes, timeout_seconds=timeout_seconds)
|
||||
|
||||
175
api/src/parser/checks.py
Normal file
175
api/src/parser/checks.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Контрольные суммы документа ОСВ (`05-domain-rules.md` §1.4; spec «Контрольные суммы определяют статус»).
|
||||
|
||||
Три проверки над классифицированными строками одного листа (суммы — `Decimal`, `None` = 0, допуск ±0.01):
|
||||
|
||||
1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit =
|
||||
closing_debit − closing_credit`;
|
||||
2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.»)
|
||||
в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) —
|
||||
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
|
||||
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
|
||||
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
|
||||
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого»
|
||||
должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`.
|
||||
|
||||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||||
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Sequence
|
||||
from decimal import Decimal
|
||||
|
||||
from src.contract.result import OsvChecks
|
||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
||||
|
||||
__all__ = [
|
||||
"TOLERANCE",
|
||||
"balance_identity",
|
||||
"parent_sums",
|
||||
"run_checks",
|
||||
"total_matches_root",
|
||||
]
|
||||
|
||||
TOLERANCE = Decimal("0.01")
|
||||
# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
|
||||
WARNING_ROWS_SHOWN = 20
|
||||
_ZERO = Decimal(0)
|
||||
# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят
|
||||
_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"})
|
||||
_ROOT_LEVEL = 0
|
||||
# Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом
|
||||
_OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6)
|
||||
|
||||
TOTAL_NOT_FOUND_WARNING = "строка «Итого» не найдена"
|
||||
|
||||
|
||||
def _values(item: ClassifiedRow) -> tuple[Decimal, ...]:
|
||||
return tuple(_ZERO if value is None else value for value in item.body.sums)
|
||||
|
||||
|
||||
def _net(pair: Sequence[Decimal]) -> Decimal:
|
||||
debit, credit = pair
|
||||
return debit - credit
|
||||
|
||||
|
||||
def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
|
||||
return any(abs(a - b) > TOLERANCE for a, b in zip(left, right, strict=True))
|
||||
|
||||
|
||||
def _is_indicator(item: ClassifiedRow) -> bool:
|
||||
return item.data_type in _INDICATOR_DATA_TYPES
|
||||
|
||||
|
||||
def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int]:
|
||||
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого»."""
|
||||
violations: list[int] = []
|
||||
for item in rows:
|
||||
sums = _values(item)
|
||||
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
|
||||
if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
|
||||
violations.append(item.body.row)
|
||||
return violations
|
||||
|
||||
|
||||
def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
|
||||
"""Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто."""
|
||||
if not _differs(parent, children):
|
||||
return True
|
||||
is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
|
||||
return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
|
||||
|
||||
|
||||
def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
|
||||
return (
|
||||
_balance_matches(parent[_OPENING], children[_OPENING])
|
||||
and not _differs(parent[_TURNOVER], children[_TURNOVER])
|
||||
and _balance_matches(parent[_CLOSING], children[_CLOSING])
|
||||
)
|
||||
|
||||
|
||||
def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
|
||||
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
||||
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
||||
|
||||
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
|
||||
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
|
||||
проверяется.
|
||||
"""
|
||||
by_row = {item.body.row: item for item in rows}
|
||||
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
||||
for item in rows:
|
||||
if item.parent is None or _is_indicator(item):
|
||||
continue
|
||||
parent = by_row.get(item.parent)
|
||||
if parent is None or _is_indicator(parent) or parent.data_type != item.data_type:
|
||||
continue
|
||||
accumulated = totals[item.parent]
|
||||
for index, value in enumerate(_values(item)):
|
||||
accumulated[index] += value
|
||||
return [row for row in by_row if row in totals and not _sums_match(_values(by_row[row]), totals[row])]
|
||||
|
||||
|
||||
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
||||
return next((item for item in reversed(rows) if item.group_type == "TOTAL"), None)
|
||||
|
||||
|
||||
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
||||
return next(
|
||||
(item for item in rows if item.group_type == "ACCOUNT" and item.level == _ROOT_LEVEL and item.parent is None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
|
||||
"""«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01).
|
||||
|
||||
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13).
|
||||
"""
|
||||
total = _total(rows)
|
||||
if total is None:
|
||||
return False
|
||||
root = _root(rows)
|
||||
if root is None:
|
||||
return all(abs(value) <= TOLERANCE for value in _values(total))
|
||||
return not _differs(_values(total), _values(root))
|
||||
|
||||
|
||||
def _rows_text(rows: Iterable[int]) -> str:
|
||||
numbers = list(rows)
|
||||
shown = ", ".join(str(number) for number in numbers[:WARNING_ROWS_SHOWN])
|
||||
if len(numbers) > WARNING_ROWS_SHOWN:
|
||||
return f"{shown} … (всего {len(numbers)})"
|
||||
return shown
|
||||
|
||||
|
||||
def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
|
||||
total = _total(rows)
|
||||
root = _root(rows)
|
||||
if total is None:
|
||||
return TOTAL_NOT_FOUND_WARNING
|
||||
if root is None:
|
||||
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
|
||||
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
|
||||
|
||||
|
||||
def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:
|
||||
"""Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось)."""
|
||||
identity = balance_identity(rows)
|
||||
parents = parent_sums(rows)
|
||||
matches = total_matches_root(rows)
|
||||
warnings: list[str] = []
|
||||
if identity:
|
||||
warnings.append(
|
||||
f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
|
||||
)
|
||||
if parents:
|
||||
warnings.append(f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}")
|
||||
if not matches:
|
||||
warnings.append(_total_warning(rows))
|
||||
checks = OsvChecks(
|
||||
total_row_matches_root=matches,
|
||||
balance_identity_violations=identity,
|
||||
parent_sum_violations=parents,
|
||||
)
|
||||
return checks, warnings
|
||||
248
api/src/parser/osv.py
Normal file
248
api/src/parser/osv.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""Оркестратор разбора ОСВ: байты книги → документы, метрики, статус (design D-6, D-8; spec «Листы ОСВ находятся по
|
||||
содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время»).
|
||||
|
||||
Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее)
|
||||
→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные
|
||||
суммы §1.4. Листы форм и прочие пропускаются с предупреждением; имя листа только подставляется в `locator` и
|
||||
сообщения, поиск по нему не ведётся.
|
||||
|
||||
Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT`
|
||||
(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB или размер больше лимита →
|
||||
`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
|
||||
`Тип: сообщение`. Нарушение §1.4 хотя бы на одном листе → `needs_check` всего результата.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
|
||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||
from src.parser import ParseOutcome
|
||||
from src.parser.checks import run_checks
|
||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
|
||||
from src.parser.grid import Sheet, column_letter
|
||||
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.hierarchy import resolve_levels
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.readers._common import one_line_reason
|
||||
from src.parser.rules import Rules
|
||||
|
||||
__all__ = ["OsvParser"]
|
||||
|
||||
LOCATOR_PREFIX = "sheet:"
|
||||
INN_COLUMN_KEY = "inn"
|
||||
NO_DATA_ROWS_WARNING = "нет строк данных"
|
||||
_MS_IN_SECOND = 1000
|
||||
|
||||
|
||||
def _sheet_line(sheet: Sheet, text: str) -> str:
|
||||
return f"sheet={sheet.name} {text}"
|
||||
|
||||
|
||||
def _unique(items: Iterable[str]) -> list[str]:
|
||||
"""Без повторов, в порядке первого появления."""
|
||||
return list(dict.fromkeys(items))
|
||||
|
||||
|
||||
def _columns(header: TableHeader) -> dict[str, str]:
|
||||
"""Диагностика `columns`: шесть колонок сумм и, если найдена, колонка ИНН (буквы — только здесь)."""
|
||||
columns = header.columns.as_diagnostics()
|
||||
if header.inn_col is not None:
|
||||
columns[INN_COLUMN_KEY] = column_letter(header.inn_col)
|
||||
return columns
|
||||
|
||||
|
||||
def _header(doc: DocHeader) -> OsvHeader:
|
||||
return OsvHeader(
|
||||
organization_name=doc.organization_name,
|
||||
organization_inn=None,
|
||||
account_code_raw=doc.account_code_raw,
|
||||
period_text=doc.period_text,
|
||||
period_year=doc.period_year,
|
||||
period_quarter=doc.period_quarter,
|
||||
is_cumulative=doc.is_cumulative,
|
||||
data_type=doc.data_type,
|
||||
currency=doc.currency,
|
||||
header_variant=doc.header_variant,
|
||||
)
|
||||
|
||||
|
||||
def _row(item: ClassifiedRow) -> OsvRow:
|
||||
sums = dict(zip(SUM_FIELDS, item.body.sums, strict=True))
|
||||
return OsvRow(
|
||||
row_number=item.body.row,
|
||||
level=item.level,
|
||||
parent_row_number=item.parent,
|
||||
row_value=item.body.text,
|
||||
group_type=item.group_type,
|
||||
group_name=item.group_name,
|
||||
account_code_raw=item.account_code_raw,
|
||||
counterparty_name=item.counterparty_name,
|
||||
counterparty_inn=item.counterparty_inn,
|
||||
data_type=item.data_type,
|
||||
opening_debit=sums["opening_debit"],
|
||||
opening_credit=sums["opening_credit"],
|
||||
turnover_debit=sums["turnover_debit"],
|
||||
turnover_credit=sums["turnover_credit"],
|
||||
closing_debit=sums["closing_debit"],
|
||||
closing_credit=sums["closing_credit"],
|
||||
extra=item.extra,
|
||||
)
|
||||
|
||||
|
||||
def _is_ok(checks: OsvChecks) -> bool:
|
||||
return (
|
||||
bool(checks.total_row_matches_root)
|
||||
and not checks.balance_identity_violations
|
||||
and not (checks.parent_sum_violations)
|
||||
)
|
||||
|
||||
|
||||
def _data_rows(document: OsvDocument) -> int:
|
||||
return sum(1 for row in document.rows if row.group_type != "TOTAL")
|
||||
|
||||
|
||||
def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError:
|
||||
"""Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется."""
|
||||
reason = exc.message.removeprefix(f"лист «{sheet.name}»: ")
|
||||
return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}")
|
||||
|
||||
|
||||
class OsvParser:
|
||||
"""Парсер ОСВ по умолчанию: словари `rules`, лимит размера `max_bytes`, таймаут `timeout_seconds` (D-8).
|
||||
|
||||
`parse` не бросает исключений: любой сбой — `ParseOutcome(status=error)` с кодом контракта.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rules: Rules,
|
||||
*,
|
||||
max_bytes: int,
|
||||
timeout_seconds: float,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._rules = rules
|
||||
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:
|
||||
return self._error(exc.code, exc.message, warnings, started)
|
||||
except Exception as exc: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер
|
||||
return self._error(ErrorCode.RECOGNITION_FAILED, one_line_reason(exc), warnings, started)
|
||||
metrics = Metrics(
|
||||
duration_ms=self._elapsed_ms(started),
|
||||
rows=sum(_data_rows(document) for document in documents),
|
||||
warnings=warnings,
|
||||
)
|
||||
status = ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK
|
||||
return ParseOutcome(status=status, documents=tuple(documents), metrics=metrics)
|
||||
|
||||
def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]:
|
||||
"""Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак
|
||||
«контрольные суммы сошлись»; нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом.
|
||||
"""
|
||||
rules = self._rules
|
||||
step = "заголовок таблицы"
|
||||
try:
|
||||
header = find_table_header(sheet, rules)
|
||||
step = "шапка документа"
|
||||
doc = parse_doc_header(sheet, header, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
deadline.check(f"лист «{sheet.name}»: строки таблицы")
|
||||
step = "строки таблицы"
|
||||
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
step = "иерархия строк"
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
step = "классификация строк"
|
||||
classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||
except InvalidFormatError as exc:
|
||||
raise _invalid_format(sheet, step, exc) from exc
|
||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||
checks, check_warnings = run_checks(classified)
|
||||
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
|
||||
warnings = [_sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
|
||||
if not any(item.group_type != "TOTAL" for item in classified):
|
||||
warnings.append(NO_DATA_ROWS_WARNING)
|
||||
warnings.extend(check_warnings)
|
||||
return document, _unique(warnings), _is_ok(checks)
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
def _parse_book(self, data: bytes, file_type: FileType, warnings: list[str]) -> tuple[list[OsvDocument], 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)
|
||||
warnings.extend(_unique(read_warnings))
|
||||
documents: list[OsvDocument] = []
|
||||
ok = True
|
||||
for sheet in sheets:
|
||||
deadline.check(f"лист «{sheet.name}»")
|
||||
kind = classify_sheet(sheet, self._rules)
|
||||
if kind is SheetKind.STATEMENT:
|
||||
warnings.append(_sheet_line(sheet, "skipped: statement (slice 2b)"))
|
||||
continue
|
||||
if kind is SheetKind.OTHER:
|
||||
warnings.append(_sheet_line(sheet, "skipped: нет признаков ОСВ"))
|
||||
continue
|
||||
document, sheet_warnings, sheet_ok = self.parse_sheet(sheet, deadline)
|
||||
documents.append(document)
|
||||
warnings.extend(sheet_warnings)
|
||||
ok = ok and sheet_ok
|
||||
if not documents:
|
||||
raise InvalidDocumentError(f"в книге нет листов ОСВ: листов просмотрено {len(sheets)}")
|
||||
return documents, ok
|
||||
|
||||
@staticmethod
|
||||
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
|
||||
*,
|
||||
sheet: Sheet,
|
||||
header: TableHeader,
|
||||
doc: DocHeader,
|
||||
schema: list[OsvGroup],
|
||||
rows: list[ClassifiedRow],
|
||||
checks: OsvChecks,
|
||||
) -> OsvDocument:
|
||||
return OsvDocument(
|
||||
document_type="OSV",
|
||||
locator=f"{LOCATOR_PREFIX}{sheet.name}",
|
||||
header=_header(doc),
|
||||
group_schema=schema,
|
||||
columns=_columns(header),
|
||||
rows=[_row(item) for item in rows],
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
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:
|
||||
metrics_fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)}
|
||||
if warnings:
|
||||
metrics_fields["warnings"] = warnings
|
||||
return ParseOutcome(
|
||||
status=ResultStatus.ERROR,
|
||||
error=MessageError(code=code, message=" ".join(message.split())),
|
||||
metrics=Metrics.model_validate(metrics_fields),
|
||||
)
|
||||
@ -21,6 +21,8 @@ _SETTINGS_ENV_PREFIXES = (
|
||||
"WORKER_CONCURRENCY",
|
||||
"PUBLISH_RETRIES",
|
||||
"DONE_CACHE_SIZE",
|
||||
"PARSE_MAX_BYTES",
|
||||
"PARSE_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
# Минимальное окружение spec «Старт с минимальным окружением»: MinIO, адрес NATS и DEBUG; без seed/jwt, БД и SMB
|
||||
|
||||
53
api/tests/fixtures/test_osv_property.py
vendored
Normal file
53
api/tests/fixtures/test_osv_property.py
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
"""Property-тест парсера на всех реальных ОСВ `../fixtures/osv/**` (design D-10; spec «Золотые снимки и
|
||||
property-тесты», «Контрольные суммы определяют статус», «Пустая ОСВ», «Форматы, лимиты, время»).
|
||||
|
||||
Инварианты проверяются независимо от снимков: статус `done`, один документ, контрольные суммы §1.4 сошлись, корень =
|
||||
счёт шапки, период распознан, разбор быстрее 5 с, результат валиден по контракту v1.1. Пустая ОСВ (R-13) определяется
|
||||
свойством — в `rows` только TOTAL, — а не списком файлов.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from result_contract import assert_valid_result
|
||||
|
||||
from src.contract import FileType, OsvDocument, ResultStatus
|
||||
from src.parser import get_parser
|
||||
|
||||
MAX_PARSE_SECONDS = 5
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
def test_real_osv_parses_done_with_invariants(osv_file: Path) -> None:
|
||||
parser = get_parser()
|
||||
data = osv_file.read_bytes()
|
||||
|
||||
started = time.monotonic()
|
||||
outcome = parser.parse(data, cast(FileType, osv_file.suffix.lstrip(".").upper()))
|
||||
seconds = time.monotonic() - started
|
||||
|
||||
assert outcome.error is None, outcome.error
|
||||
assert outcome.status is ResultStatus.DONE, outcome.metrics
|
||||
(document,) = outcome.documents
|
||||
assert isinstance(document, OsvDocument)
|
||||
checks = document.checks
|
||||
assert checks is not None
|
||||
assert checks.total_row_matches_root is True
|
||||
assert checks.balance_identity_violations == []
|
||||
assert checks.parent_sum_violations == []
|
||||
assert document.header.period_quarter is not None
|
||||
data_rows = [row for row in document.rows if row.group_type != "TOTAL"]
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == len(data_rows)
|
||||
if data_rows:
|
||||
assert outcome.metrics.rows > 0
|
||||
assert document.header.account_code_raw == document.rows[0].account_code_raw
|
||||
else:
|
||||
# Пустая ОСВ (R-13): только «Итого»
|
||||
assert [row.group_type for row in document.rows] == ["TOTAL"]
|
||||
assert outcome.metrics.warnings is not None
|
||||
assert "нет строк данных" in outcome.metrics.warnings
|
||||
assert seconds < MAX_PARSE_SECONDS, f"{osv_file.name}: {seconds:.2f} с"
|
||||
assert_valid_result(outcome)
|
||||
@ -1,13 +1,14 @@
|
||||
"""Интеграционный round-trip сервиса против compose-NATS (без JetStream) и compose-MinIO (`.env.test`).
|
||||
|
||||
Приложение поднимается в процессе теста через lifespan (`src.app`) с `NATS_ENABLED=True` — тот же код, что в проде:
|
||||
`_connect_s3`, `NatsConnection` с подпиской в queue group `tabular-parser`, `RecognitionWorker` с `NullParser`.
|
||||
`_connect_s3`, `NatsConnection` с подпиской в queue group `tabular-parser`, `RecognitionWorker` с парсером по умолчанию
|
||||
(`get_parser(settings)` → `OsvParser`); исходник — битый zip, поэтому результат — ошибка `UNREADABLE`.
|
||||
Тест играет роль ядра: кладёт исходник в MinIO `uploads/…`, публикует запрос в `recognition.tabular.request` с
|
||||
`Nats-Msg-Id = <job_id>:<attempt>` и собирает конверты из `recognition.result`.
|
||||
|
||||
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
|
||||
обработки: заглушка отвечает мгновенно; повтор во время обработки покрыт unit-тестами воркера), «Запрос с заглушкой
|
||||
парсера», «Нет объекта в S3», «Несовместимая версия».
|
||||
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
||||
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия».
|
||||
|
||||
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
||||
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
||||
@ -46,10 +47,10 @@ MESSAGE_VALIDATOR = Draft202012Validator(
|
||||
SERVICE_NAME = "tabular-parser"
|
||||
READY_TIMEOUT = 5.0 # подключение к NATS и подписка приложения
|
||||
MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с запасом на повторы публикации)
|
||||
# Окно тишины для отрицательных проверок («второго результата нет», «ack нет»): заглушка отвечает за миллисекунды,
|
||||
# Окно тишины для отрицательных проверок («второго результата нет», «ack нет»): битый файл отвергается за миллисекунды,
|
||||
# поэтому лишнее сообщение, если бы оно было, пришло бы гораздо раньше конца окна
|
||||
QUIET_WINDOW = 1.5
|
||||
SOURCE_BYTES = b"PK\x03\x04 round-trip: content is not read by NullParser"
|
||||
SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
|
||||
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
|
||||
@ -209,14 +210,14 @@ async def core(service: None) -> AsyncIterator[Core]:
|
||||
|
||||
|
||||
# --- сценарии ---------------------------------------------------------------------------------------------------------
|
||||
async def test_request_gets_ack_then_not_implemented_and_duplicate_gets_only_ack(core: Core, uploads: Uploads) -> None:
|
||||
"""«Ack раньше результата», «Запрос с заглушкой парсера», повтор запроса — только ещё один ack."""
|
||||
async def test_request_gets_ack_then_parser_error_and_duplicate_gets_only_ack(core: Core, uploads: Uploads) -> None:
|
||||
"""«Ack раньше результата», ошибка разбора (`UNREADABLE`) конвертом, повтор запроса — только ещё один ack."""
|
||||
request = _new_request()
|
||||
await uploads.put(request)
|
||||
|
||||
await core.publish(request)
|
||||
_assert_ack(await core.next_message(request), request)
|
||||
_assert_error(await core.next_message(request), request, "NOT_IMPLEMENTED")
|
||||
_assert_error(await core.next_message(request), request, "UNREADABLE")
|
||||
|
||||
await core.publish(request) # та же доставка ещё раз: тот же `Nats-Msg-Id = <job_id>:<attempt>`
|
||||
_assert_ack(await core.next_message(request), request)
|
||||
@ -238,7 +239,7 @@ async def test_missing_source_object_gives_file_read_error_and_service_keeps_wor
|
||||
await uploads.put(following)
|
||||
await core.publish(following)
|
||||
_assert_ack(await core.next_message(following), following)
|
||||
_assert_error(await core.next_message(following), following, "NOT_IMPLEMENTED")
|
||||
_assert_error(await core.next_message(following), following, "UNREADABLE")
|
||||
|
||||
|
||||
async def test_unsupported_schema_version_gives_single_schema_mismatch_without_ack(
|
||||
|
||||
68
api/tests/result_contract.py
Normal file
68
api/tests/result_contract.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Исход разбора → файл результата контракта v1.1 (как его собирает воркер) и проверка JSON-схемой `api/contracts/`.
|
||||
|
||||
Общий помощник юнит-тестов парсера и property-теста на реальных файлах: `RecognitionResult.model_validate` и
|
||||
`recognition-result.schema.json` (Draft 2020-12, `$ref` — локальный реестр без сети) — оба вердикта обязательны.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
from referencing import Registry, Resource
|
||||
|
||||
from src.contract import CONTRACT_VERSION, RecognitionResult
|
||||
from src.parser import ParseOutcome
|
||||
|
||||
CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
|
||||
SCHEMA_FILES = (
|
||||
"recognition-request.schema.json",
|
||||
"recognition-result-message.schema.json",
|
||||
"recognition-result.schema.json",
|
||||
)
|
||||
RESULT_SCHEMA = "recognition-result.schema.json"
|
||||
|
||||
|
||||
def _validator() -> Draft202012Validator:
|
||||
schemas = {name: json.loads((CONTRACTS_DIR / name).read_text(encoding="utf-8")) for name in SCHEMA_FILES}
|
||||
registry: Registry = Registry()
|
||||
for schema in schemas.values():
|
||||
registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
|
||||
return Draft202012Validator(schemas[RESULT_SCHEMA], registry=registry, format_checker=FormatChecker())
|
||||
|
||||
|
||||
RESULT_VALIDATOR = _validator()
|
||||
|
||||
|
||||
def result_payload(outcome: ParseOutcome) -> dict[str, Any]:
|
||||
"""Файл результата из успешного исхода (`done | needs_check`): модель контракта → JSON-словарь."""
|
||||
result = RecognitionResult.model_validate(
|
||||
{
|
||||
"schema_version": CONTRACT_VERSION,
|
||||
"job_id": uuid.uuid4(),
|
||||
"file_id": uuid.uuid4(),
|
||||
"service": {"name": "tabular-parser", "version": "test"},
|
||||
"status": outcome.status,
|
||||
"metrics": outcome.metrics,
|
||||
"documents": list(outcome.documents),
|
||||
}
|
||||
)
|
||||
payload = result.to_wire()
|
||||
# Круг через текст JSON: в схему идёт то, что реально ляжет в S3
|
||||
loaded: dict[str, Any] = json.loads(json.dumps(payload, ensure_ascii=False))
|
||||
return loaded
|
||||
|
||||
|
||||
def schema_errors(payload: dict[str, Any]) -> list[str]:
|
||||
"""Нарушения JSON-схемы v1.1 (пустой список — файл валиден)."""
|
||||
return [f"{list(error.absolute_path)}: {error.message}" for error in RESULT_VALIDATOR.iter_errors(payload)]
|
||||
|
||||
|
||||
def assert_valid_result(outcome: ParseOutcome) -> dict[str, Any]:
|
||||
"""Файл результата проходит и pydantic-модель (с round-trip), и JSON-схему; возвращает JSON-словарь."""
|
||||
payload = result_payload(outcome)
|
||||
errors = schema_errors(payload)
|
||||
assert errors == [], "\n".join(errors[:20])
|
||||
assert RecognitionResult.model_validate(payload).to_wire() == payload
|
||||
return payload
|
||||
255
api/tests/unit/parser/test_checks.py
Normal file
255
api/tests/unit/parser/test_checks.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""Контрольные суммы §1.4 (spec «Контрольные суммы определяют статус», `05-domain-rules.md` §1.4, R-13, R-20).
|
||||
|
||||
Строки собираются напрямую (`ClassifiedRow` над `BodyRow`): проверки — чистые функции над деревом строк, им не нужна
|
||||
книга. Номера строк произвольные; суммы — `Decimal` или `None` (пусто = 0).
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from src.contract.common import DataType
|
||||
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
||||
from src.parser.classify import ClassifiedRow, RowGroupType
|
||||
from src.parser.hierarchy import BodyRow
|
||||
|
||||
Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
|
||||
EMPTY: Sums = (None, None, None, None, None, None)
|
||||
|
||||
|
||||
def row( # noqa: PLR0913 - строка теста описывается полностью одним вызовом
|
||||
number: int,
|
||||
sums: Sums,
|
||||
*,
|
||||
level: int = 0,
|
||||
parent: int | None = None,
|
||||
group_type: RowGroupType = "COUNTERPARTY",
|
||||
data_type: DataType | None = "BU",
|
||||
text: str = "строка",
|
||||
) -> ClassifiedRow:
|
||||
body = BodyRow(
|
||||
row=number,
|
||||
text=text,
|
||||
is_account=group_type == "ACCOUNT",
|
||||
account_rank=1 if group_type == "ACCOUNT" else None,
|
||||
bold=False,
|
||||
indent=0,
|
||||
outline=level,
|
||||
sums=tuple(None if value is None else Decimal(value) for value in sums),
|
||||
)
|
||||
return ClassifiedRow(
|
||||
body=body,
|
||||
level=level,
|
||||
parent=parent,
|
||||
group_type=group_type,
|
||||
group_name=None,
|
||||
account_code_raw="60",
|
||||
counterparty_name=None,
|
||||
counterparty_inn=None,
|
||||
data_type=data_type,
|
||||
extra=None,
|
||||
)
|
||||
|
||||
|
||||
def account(number: int, sums: Sums) -> ClassifiedRow:
|
||||
return row(number, sums, group_type="ACCOUNT", text="60")
|
||||
|
||||
|
||||
def total(number: int, sums: Sums) -> ClassifiedRow:
|
||||
return row(number, sums, group_type="TOTAL", text="Итого")
|
||||
|
||||
|
||||
# Корень 60 с двумя контрагентами, «Итого» равно корню; тождество выполняется во всех строках
|
||||
ROOT: Sums = ("100", None, "50", "30", "120", None)
|
||||
CHILD_A: Sums = ("60", None, "20", "10", "70", None)
|
||||
CHILD_B: Sums = ("40", None, "30", "20", "50", None)
|
||||
|
||||
|
||||
def tree() -> list[ClassifiedRow]:
|
||||
return [
|
||||
account(7, ROOT),
|
||||
row(8, CHILD_A, level=1, parent=7),
|
||||
row(9, CHILD_B, level=1, parent=7),
|
||||
total(10, ROOT),
|
||||
]
|
||||
|
||||
|
||||
def test_tolerance_is_one_kopeck() -> None:
|
||||
assert Decimal("0.01") == TOLERANCE
|
||||
|
||||
|
||||
def test_consistent_tree_has_no_violations() -> None:
|
||||
checks, warnings = run_checks(tree())
|
||||
|
||||
assert checks.total_row_matches_root is True
|
||||
assert checks.balance_identity_violations == []
|
||||
assert checks.parent_sum_violations == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_balance_identity_treats_null_as_zero() -> None:
|
||||
rows = [
|
||||
row(1, (None, None, None, None, None, None)),
|
||||
row(2, (None, "5", None, None, None, "5")), # только кредит: −5 = −5
|
||||
row(3, ("10", None, None, "10", None, None)), # 10 − 10 = 0 при пустом конце
|
||||
]
|
||||
|
||||
assert balance_identity(rows) == []
|
||||
|
||||
|
||||
def test_balance_identity_reports_row_numbers_beyond_tolerance() -> None:
|
||||
rows = [
|
||||
row(1, ("100", None, "50", "30", "120.01", None)), # в пределах ±0.01
|
||||
row(2, ("100", None, "50", "30", "121", None)), # конец увеличен на 1
|
||||
row(3, (None, None, None, None, None, "0.02")),
|
||||
]
|
||||
|
||||
assert balance_identity(rows) == [2, 3]
|
||||
|
||||
|
||||
def test_parent_differs_from_sum_of_children() -> None:
|
||||
rows = tree()
|
||||
rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
|
||||
|
||||
assert parent_sums(rows) == [7]
|
||||
|
||||
|
||||
def test_parent_sum_is_checked_per_column_with_tolerance() -> None:
|
||||
rows = [
|
||||
account(7, ("100", None, "50", "30", "120", None)),
|
||||
row(8, ("99.995", None, "50", "30", "119.995", None), level=1, parent=7),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_collapsed_parent_balance_is_compared_by_net() -> None:
|
||||
"""Счёт со свёрнутым сальдо (одна сторона), субсчета — с развёрнутым: сверка сальдо по нетто (как в 1С на всех
|
||||
реальных файлах с субсчетами разных сторон); обороты — по колонкам.
|
||||
"""
|
||||
rows = [
|
||||
account(12, ("16", None, "134", "111", "39", None)),
|
||||
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_collapsed_parent_with_wrong_net_is_violation() -> None:
|
||||
rows = [
|
||||
account(12, ("17", None, "134", "111", "39", None)),
|
||||
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == [12]
|
||||
|
||||
|
||||
def test_expanded_parent_balance_must_match_by_columns() -> None:
|
||||
"""Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
|
||||
rows = [
|
||||
account(12, ("41", "25", "134", "111", "60", "21")),
|
||||
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == [12]
|
||||
|
||||
|
||||
def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None:
|
||||
rows = [
|
||||
account(12, ("16", None, "135", "112", "39", None)),
|
||||
row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
|
||||
row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == [12]
|
||||
|
||||
|
||||
def test_currency_and_quantity_children_are_excluded() -> None:
|
||||
rows = [
|
||||
account(7, ROOT),
|
||||
row(8, ROOT, level=1, parent=7),
|
||||
row(9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY"),
|
||||
row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"),
|
||||
total(11, ROOT),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_children_of_other_data_type_are_not_summed() -> None:
|
||||
"""Σ детей «того же data_type»: ребёнок НУ под родителем БУ в сумму не входит."""
|
||||
rows = [
|
||||
account(7, ("10", None, None, None, "10", None)),
|
||||
row(8, ("10", None, None, None, "10", None), level=1, parent=7),
|
||||
row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"),
|
||||
]
|
||||
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_rows_without_children_are_not_parents() -> None:
|
||||
rows = [account(7, ROOT), account(8, ("1", None, None, None, "1", None))]
|
||||
|
||||
assert parent_sums(rows) == []
|
||||
|
||||
|
||||
def test_total_matches_first_root_account() -> None:
|
||||
rows = tree()
|
||||
|
||||
assert total_matches_root(rows) is True
|
||||
rows[-1] = total(10, ("100", None, "50", "30", "121", None))
|
||||
assert total_matches_root(rows) is False
|
||||
|
||||
|
||||
def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
|
||||
assert total_matches_root([total(8, EMPTY)]) is True
|
||||
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True
|
||||
assert total_matches_root([total(8, ("1", None, None, None, "1", None))]) is False
|
||||
|
||||
|
||||
def test_missing_total_fails_root_check_with_warning() -> None:
|
||||
"""R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение."""
|
||||
rows = tree()[:-1]
|
||||
|
||||
checks, warnings = run_checks(rows)
|
||||
|
||||
assert total_matches_root(rows) is False
|
||||
assert checks.total_row_matches_root is False
|
||||
assert "строка «Итого» не найдена" in warnings
|
||||
|
||||
|
||||
def test_run_checks_warnings_name_row_numbers() -> None:
|
||||
rows = tree()
|
||||
rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
|
||||
|
||||
checks, warnings = run_checks(rows)
|
||||
|
||||
assert checks.balance_identity_violations == [9]
|
||||
assert checks.parent_sum_violations == [7]
|
||||
assert checks.total_row_matches_root is True
|
||||
assert any("строки 9" in warning for warning in warnings), warnings
|
||||
assert any("строки 7" in warning for warning in warnings), warnings
|
||||
|
||||
|
||||
def test_run_checks_reports_total_mismatch_with_row_numbers() -> None:
|
||||
rows = tree()
|
||||
rows[-1] = total(10, ("100", None, "50", "30", "121", None))
|
||||
|
||||
checks, warnings = run_checks(rows)
|
||||
|
||||
assert checks.total_row_matches_root is False
|
||||
assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings
|
||||
|
||||
|
||||
def test_run_checks_warning_lists_are_capped() -> None:
|
||||
"""Список нарушений в `checks` полный, текст предупреждения — первые номера и общее число."""
|
||||
broken: Sums = ("1", None, None, None, "2", None)
|
||||
rows = [row(number, broken) for number in range(1, 101)]
|
||||
|
||||
checks, warnings = run_checks(rows)
|
||||
|
||||
assert checks.balance_identity_violations == list(range(1, 101))
|
||||
(identity,) = [warning for warning in warnings if "сальдо" in warning]
|
||||
assert "всего 100" in identity
|
||||
assert "100," not in identity
|
||||
451
api/tests/unit/parser/test_osv_parser.py
Normal file
451
api/tests/unit/parser/test_osv_parser.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
|
||||
статус», «Форматы, лимиты, время», «Словари — данные, не код»).
|
||||
|
||||
Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
|
||||
ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту.
|
||||
"""
|
||||
|
||||
import io
|
||||
import itertools
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from gridbuilder import L, M
|
||||
from result_contract import assert_valid_result
|
||||
|
||||
from src.contract import ErrorCode, OsvDocument, ResultStatus
|
||||
from src.parser import ParseOutcome, hierarchy
|
||||
from src.parser.classify import SUM_FIELDS
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.hierarchy import BodyRow
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
|
||||
|
||||
OPENING = "Сальдо на начало периода"
|
||||
TURNOVER = "Обороты за период"
|
||||
CLOSING = "Сальдо на конец периода"
|
||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||
MAX_BYTES = 50 * 1024 * 1024
|
||||
TIMEOUT_SECONDS = 120
|
||||
# Строки мини-ОСВ: шапка 6 строк, блок заголовка 2 строки → данные с 9-й
|
||||
ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW = 9, 10, 11, 12
|
||||
DATA_ROWS = 3
|
||||
# Часы-заглушка: каждый вызов +100 с; лимит 150 с истекает на второй проверке дедлайна
|
||||
CLOCK_STEP = 100
|
||||
SHORT_TIMEOUT = 150
|
||||
|
||||
Cells = list[object]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
|
||||
"""Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
|
||||
book = openpyxl.Workbook()
|
||||
default = book.active
|
||||
assert default is not None
|
||||
book.remove(default)
|
||||
for name, rows in sheets.items():
|
||||
sheet = book.create_sheet(name)
|
||||
for row_index, cells in enumerate(rows, start=1):
|
||||
col = 1
|
||||
for value in cells:
|
||||
if isinstance(value, L):
|
||||
sheet.row_dimensions[row_index].outline_level = value.level
|
||||
continue
|
||||
if isinstance(value, M):
|
||||
sheet.cell(row_index, col, value.value)
|
||||
sheet.merge_cells(
|
||||
start_row=row_index,
|
||||
start_column=col,
|
||||
end_row=row_index + value.height - 1,
|
||||
end_column=col + value.width - 1,
|
||||
)
|
||||
col += value.width
|
||||
continue
|
||||
if value is not None:
|
||||
sheet.cell(row_index, col, value)
|
||||
col += 1
|
||||
buffer = io.BytesIO()
|
||||
book.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def head(opening: str = OPENING, title: str = TITLE) -> list[Cells]:
|
||||
"""Шапка варианта 1 и блок заголовка «Счет | начало | обороты | конец», группировки Счет / Контрагенты (с ИНН)."""
|
||||
return [
|
||||
['АО "Ромашка"'], # 1
|
||||
[title], # 2
|
||||
[], # 3
|
||||
["Выводимые данные: БУ (данные бухгалтерского учета)"], # 4
|
||||
["Параметры:", None, "Валюта БУ, НУ: RUB"], # 5
|
||||
[], # 6
|
||||
["Счет", None, M(opening, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 7
|
||||
["Контрагенты", "ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 8
|
||||
]
|
||||
|
||||
|
||||
def data(level: int, text: str, sums: Sequence[object], inn: object = None) -> Cells:
|
||||
return [L(level), text, inn, *sums]
|
||||
|
||||
|
||||
ROOT = (100, None, 50, 30, 120, None)
|
||||
SUPPLIER = (60, None, 20, 10, 70, None)
|
||||
CONTRACTOR = (40, None, 30, 20, 50, None)
|
||||
|
||||
|
||||
def mini_osv(supplier: Sequence[object] = SUPPLIER, *, total: bool = True, opening: str = OPENING) -> list[Cells]:
|
||||
rows = [
|
||||
*head(opening),
|
||||
data(0, "60", ROOT), # 9
|
||||
data(1, "ООО Поставщик", supplier, inn="7701234567"), # 10
|
||||
data(1, "ООО Подрядчик", CONTRACTOR, inn="7707654321"), # 11
|
||||
]
|
||||
if total:
|
||||
rows.append(data(0, "Итого", ROOT)) # 12
|
||||
return rows
|
||||
|
||||
|
||||
def statement() -> list[Cells]:
|
||||
return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
|
||||
|
||||
|
||||
def parser(
|
||||
rules: Rules,
|
||||
*,
|
||||
max_bytes: int = MAX_BYTES,
|
||||
timeout_seconds: float = TIMEOUT_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> OsvParser:
|
||||
return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock)
|
||||
|
||||
|
||||
def only_document(outcome: ParseOutcome) -> OsvDocument:
|
||||
(document,) = outcome.documents
|
||||
assert isinstance(document, OsvDocument)
|
||||
return document
|
||||
|
||||
|
||||
def warnings_of(outcome: ParseOutcome) -> list[str]:
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.warnings is not None
|
||||
return outcome.metrics.warnings
|
||||
|
||||
|
||||
# --- Штатный разбор ---
|
||||
|
||||
|
||||
def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None:
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
assert outcome.error is None
|
||||
document = only_document(outcome)
|
||||
assert document.document_type == "OSV"
|
||||
assert document.locator == "sheet:ОСВ 60"
|
||||
header = document.header
|
||||
assert (header.organization_name, header.account_code_raw, header.period_text) == (
|
||||
'АО "Ромашка"',
|
||||
"60",
|
||||
"1 квартал 2025 г.",
|
||||
)
|
||||
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q1", True)
|
||||
assert (header.data_type, header.currency, header.header_variant) == ("BU", "RUB", 1)
|
||||
assert [(group.type, group.name, group.has_inn) for group in document.group_schema] == [
|
||||
("ACCOUNT", "Счет", None),
|
||||
("COUNTERPARTY", "Контрагенты", True),
|
||||
]
|
||||
assert document.columns == {
|
||||
"opening_debit": "C",
|
||||
"opening_credit": "D",
|
||||
"turnover_debit": "E",
|
||||
"turnover_credit": "F",
|
||||
"closing_debit": "G",
|
||||
"closing_credit": "H",
|
||||
"inn": "B",
|
||||
}
|
||||
rows = document.rows
|
||||
assert [(row.row_number, row.level, row.parent_row_number, row.group_type) for row in rows] == [
|
||||
(ACCOUNT_ROW, 0, None, "ACCOUNT"),
|
||||
(SUPPLIER_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
|
||||
(CONTRACTOR_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
|
||||
(TOTAL_ROW, 0, None, "TOTAL"),
|
||||
]
|
||||
supplier = rows[1]
|
||||
assert (supplier.row_value, supplier.counterparty_name, supplier.counterparty_inn) == (
|
||||
"ООО Поставщик",
|
||||
"ООО Поставщик",
|
||||
"7701234567",
|
||||
)
|
||||
assert (supplier.group_name, supplier.account_code_raw, supplier.data_type) == ("Контрагенты", "60", "BU")
|
||||
sums = [getattr(supplier, name) for name in SUM_FIELDS]
|
||||
assert sums == [Decimal(60), None, Decimal(20), Decimal(10), Decimal(70), None]
|
||||
assert document.checks is not None
|
||||
assert document.checks.total_row_matches_root is True
|
||||
assert document.checks.balance_identity_violations == []
|
||||
assert document.checks.parent_sum_violations == []
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == DATA_ROWS
|
||||
assert outcome.metrics.duration_ms is not None
|
||||
assert outcome.metrics.duration_ms >= 0
|
||||
assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline"]
|
||||
payload = assert_valid_result(outcome)
|
||||
assert payload["schema_version"] == "1.1"
|
||||
|
||||
|
||||
def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
|
||||
"""Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя."""
|
||||
spoiled = (60, None, 20, 10, 71, None)
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
checks = only_document(outcome).checks
|
||||
assert checks is not None
|
||||
assert checks.balance_identity_violations == [SUPPLIER_ROW]
|
||||
assert checks.parent_sum_violations == [ACCOUNT_ROW]
|
||||
assert checks.total_row_matches_root is True
|
||||
warnings = warnings_of(outcome)
|
||||
assert warnings[0] == "sheet=ОСВ 60 hierarchy=outline"
|
||||
assert any(f"строки {SUPPLIER_ROW}" in warning for warning in warnings), warnings
|
||||
assert any(f"строки {ACCOUNT_ROW}" in warning for warning in warnings), warnings
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
def test_missing_total_needs_check(rules: Rules) -> None:
|
||||
"""R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
checks = only_document(outcome).checks
|
||||
assert checks is not None
|
||||
assert checks.total_row_matches_root is False
|
||||
assert "строка «Итого» не найдена" in warnings_of(outcome)
|
||||
|
||||
|
||||
def test_empty_osv_is_done_with_total_only(rules: Rules) -> None:
|
||||
"""Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм."""
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
document = only_document(outcome)
|
||||
assert [(row.group_type, row.level, row.parent_row_number) for row in document.rows] == [("TOTAL", 0, None)]
|
||||
assert document.checks is not None
|
||||
assert document.checks.total_row_matches_root is True
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == 0
|
||||
assert warnings_of(outcome) == ["sheet=ОСВ 66 hierarchy=trivial", "нет строк данных"]
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
|
||||
rows = mini_osv()
|
||||
rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321")
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
|
||||
|
||||
|
||||
# --- Несколько листов ---
|
||||
|
||||
|
||||
def test_statement_sheet_is_skipped_and_osv_parsed(rules: Rules) -> None:
|
||||
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
assert only_document(outcome).locator == "sheet:ОСВ 60"
|
||||
assert warnings_of(outcome) == ["sheet=Баланс skipped: statement (slice 2b)", "sheet=ОСВ 60 hierarchy=outline"]
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
|
||||
"""Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
|
||||
other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]]
|
||||
book = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()})
|
||||
|
||||
outcome = parser(rules).parse(book, "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == 2 * DATA_ROWS
|
||||
warnings = warnings_of(outcome)
|
||||
assert "sheet=ОСВ 60 hierarchy=outline" in warnings
|
||||
assert "sheet=ОСВ 62 hierarchy=outline" in warnings
|
||||
assert any(warning.startswith("sheet=Справка skipped") for warning in warnings), warnings
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
|
||||
spoiled = (60, None, 20, 10, 71, None)
|
||||
book = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)})
|
||||
|
||||
outcome = parser(rules).parse(book, "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ
|
||||
|
||||
|
||||
# --- Ошибки (карта D-6) ---
|
||||
|
||||
|
||||
def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
|
||||
"""Spec «Нет ОСВ»: сообщение называет число просмотренных листов."""
|
||||
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.INVALID_DOCUMENT
|
||||
assert "листов просмотрено 2" in outcome.error.message
|
||||
assert outcome.documents == ()
|
||||
|
||||
|
||||
def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None:
|
||||
rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]]
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.INVALID_FORMAT
|
||||
assert "«ОСВ 60»" in outcome.error.message
|
||||
assert "заголовок таблицы" in outcome.error.message
|
||||
assert outcome.error.message.count("ОСВ 60") == 1
|
||||
|
||||
|
||||
def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step(
|
||||
rules: Rules, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def nothing(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("nothing", nothing),))
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.INVALID_FORMAT
|
||||
assert "«ОСВ 60»" in outcome.error.message
|
||||
assert "иерархия" in outcome.error.message
|
||||
|
||||
|
||||
def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None:
|
||||
"""Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
|
||||
book = xlsx({"ОСВ 60": mini_osv()})
|
||||
|
||||
over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX")
|
||||
exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX")
|
||||
|
||||
assert over.status is ResultStatus.ERROR
|
||||
assert over.error is not None
|
||||
assert over.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
assert "лимит" in over.error.message
|
||||
assert exact.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_tiny_limit_rejects_any_file(rules: Rules) -> None:
|
||||
outcome = parser(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
|
||||
|
||||
def test_unknown_signature_is_unsupported_format(rules: Rules) -> None:
|
||||
outcome = parser(rules).parse(b"%PDF-1.7 not a workbook", "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||||
|
||||
|
||||
def test_broken_zip_is_unreadable(rules: Rules) -> None:
|
||||
outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
|
||||
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.UNREADABLE
|
||||
|
||||
|
||||
def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
|
||||
clock = itertools.count(step=CLOCK_STEP)
|
||||
|
||||
outcome = parser(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(
|
||||
xlsx({"ОСВ 60": mini_osv()}), "XLSX"
|
||||
)
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.TIMEOUT
|
||||
assert "превышен лимит" in outcome.error.message
|
||||
|
||||
|
||||
def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
||||
raise ValueError("сбой стратегии\nвторая строка")
|
||||
|
||||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
|
||||
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error is not None
|
||||
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
|
||||
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
|
||||
|
||||
|
||||
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
|
||||
|
||||
NEW_OPENING = "Нач. остаток"
|
||||
NEW_SYNONYM_LINE = " - 'нач\\.\\s*остаток'\n"
|
||||
|
||||
|
||||
def rules_with_new_opening_synonym(tmp_path: Path) -> Path:
|
||||
"""Копия штатного каталога словарей, в `osv_headers.yaml` синоним «Нач. остаток» добавлен к группе opening."""
|
||||
copy = tmp_path / "rules"
|
||||
shutil.copytree(DEFAULT_RULES_DIR, copy)
|
||||
headers = copy / HEADERS_FILE
|
||||
text = headers.read_text(encoding="utf-8")
|
||||
anchor = " opening:\n"
|
||||
assert text.count(anchor) == 1
|
||||
headers.write_text(text.replace(anchor, anchor + NEW_SYNONYM_LINE), encoding="utf-8")
|
||||
return copy
|
||||
|
||||
|
||||
def test_new_synonym_parses_without_code_changes(tmp_path: Path, rules: Rules) -> None:
|
||||
book = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)})
|
||||
|
||||
outcome = parser(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(book, "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
document = only_document(outcome)
|
||||
assert document.columns is not None
|
||||
assert (document.columns["opening_debit"], document.columns["opening_credit"]) == ("C", "D")
|
||||
assert document.rows[0].opening_debit == Decimal(100)
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
|
||||
"""До правки словаря «Нач. остаток» не распознаётся: заголовок находится по «оборотам» и «концу» (начало
|
||||
необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в
|
||||
строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом).
|
||||
"""
|
||||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX")
|
||||
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
document = only_document(outcome)
|
||||
assert document.columns is not None
|
||||
assert "opening_debit" not in document.columns
|
||||
assert all(row.opening_debit is None and row.opening_credit is None for row in document.rows)
|
||||
assert document.rows[0].extra is not None
|
||||
assert "100" in document.rows[0].extra.values()
|
||||
checks = document.checks
|
||||
assert checks is not None
|
||||
assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW]
|
||||
@ -50,7 +50,7 @@ class FakeStorage:
|
||||
return self.objects[(bucket, key)]
|
||||
|
||||
async def put_json(self, bucket: str, key: str, obj: Any) -> None:
|
||||
raise AssertionError("NullParser не пишет файл результата")
|
||||
raise AssertionError("байты «xlsx» — не книга: парсер отдаёт ошибку без файла результата")
|
||||
|
||||
|
||||
class FakeNatsConnection:
|
||||
@ -170,7 +170,8 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok(
|
||||
worker = patched_app.app.state.worker
|
||||
assert isinstance(worker, RecognitionWorker)
|
||||
|
||||
# Запрос, доставленный подписке, проходит через воркер: ack и результат NOT_IMPLEMENTED в recognition.result
|
||||
# Запрос, доставленный подписке, проходит через воркер с парсером по умолчанию (`get_parser(settings)` →
|
||||
# OsvParser): байты «xlsx» — не книга, ack и результат UNSUPPORTED_FORMAT в recognition.result
|
||||
_subject, handler = connection.handlers[0]
|
||||
message = SimpleNamespace(
|
||||
data=json.dumps(REQUEST).encode(), headers={NATS_MSG_ID_HEADER: f"{JOB_ID}:1"}, subject=_subject
|
||||
@ -182,7 +183,7 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok(
|
||||
(RESULT_SUBJECT, f"ack:{JOB_ID}:1"),
|
||||
(RESULT_SUBJECT, f"result:{JOB_ID}:1"),
|
||||
]
|
||||
assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED"
|
||||
assert connection.published[1][1]["error"]["code"] == "UNSUPPORTED_FORMAT"
|
||||
assert connection.published[1][1]["service"]["name"] == "tabular-parser"
|
||||
# Старт: воркер готов до подключения (on_connected сразу подписывает worker.on_request).
|
||||
# Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3
|
||||
|
||||
@ -23,6 +23,15 @@ def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) -
|
||||
assert settings.WORKER_CONCURRENCY == 1
|
||||
assert settings.PUBLISH_RETRIES == 3 # noqa: PLR2004 - значение по умолчанию из spec
|
||||
assert settings.DONE_CACHE_SIZE == 1000 # noqa: PLR2004 - значение по умолчанию из spec
|
||||
assert settings.PARSE_MAX_BYTES == 50 * 1024 * 1024 # design D-8: 50 МиБ
|
||||
assert settings.PARSE_TIMEOUT_SECONDS == 120 # noqa: PLR2004 - значение по умолчанию из design D-8
|
||||
|
||||
|
||||
def test_parse_limits_must_be_positive(minimal_settings: SettingsFactory) -> None:
|
||||
with pytest.raises(ValidationError, match="PARSE_MAX_BYTES"):
|
||||
minimal_settings("PARSE_MAX_BYTES=0\n")
|
||||
with pytest.raises(ValidationError, match="PARSE_TIMEOUT_SECONDS"):
|
||||
minimal_settings("PARSE_TIMEOUT_SECONDS=0\n")
|
||||
|
||||
|
||||
def test_publish_retries_zero_is_allowed(minimal_settings: SettingsFactory) -> None:
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
"""Парсер-заглушка Среза 0 (design D-3): `NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются."""
|
||||
|
||||
from typing import Any, get_args
|
||||
|
||||
import pytest
|
||||
|
||||
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
|
||||
from src.parser import NullParser, ParseOutcome, Parser, get_parser
|
||||
|
||||
|
||||
class UntouchableBytes(bytes):
|
||||
"""Байты, любое обращение к содержимому которых — ошибка теста."""
|
||||
|
||||
def _fail(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
raise AssertionError("NullParser прикоснулся к входным байтам")
|
||||
|
||||
__iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_type", get_args(FileType))
|
||||
def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None:
|
||||
data = UntouchableBytes(b"content")
|
||||
|
||||
outcome = NullParser().parse(data, file_type)
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error == MessageError(
|
||||
code=ErrorCode.NOT_IMPLEMENTED,
|
||||
message="разбор XLS/XLSX/XLSB реализуется в Срезе 2",
|
||||
)
|
||||
assert outcome.documents == ()
|
||||
assert outcome.metrics is None
|
||||
|
||||
|
||||
def test_get_parser_returns_null_parser() -> None:
|
||||
parser = get_parser()
|
||||
|
||||
assert isinstance(parser, NullParser)
|
||||
assert isinstance(parser, Parser)
|
||||
|
||||
|
||||
def test_outcome_error_requires_error_payload() -> None:
|
||||
with pytest.raises(ValueError, match="error"):
|
||||
ParseOutcome(status=ResultStatus.ERROR)
|
||||
|
||||
|
||||
def test_outcome_success_requires_documents() -> None:
|
||||
with pytest.raises(ValueError, match="documents"):
|
||||
ParseOutcome(status=ResultStatus.DONE)
|
||||
84
api/tests/unit/test_parser_factory.py
Normal file
84
api/tests/unit/test_parser_factory.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
|
||||
|
||||
`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
|
||||
падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
|
||||
`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, get_args
|
||||
|
||||
import pytest
|
||||
|
||||
from src.contract import ErrorCode, FileType, MessageError, ResultStatus
|
||||
from src.core.config import Settings
|
||||
from src.parser import NullParser, ParseOutcome, Parser, get_parser
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE
|
||||
|
||||
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
class UntouchableBytes(bytes):
|
||||
"""Байты, любое обращение к содержимому которых — ошибка теста."""
|
||||
|
||||
def _fail(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
raise AssertionError("NullParser прикоснулся к входным байтам")
|
||||
|
||||
__iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_type", get_args(FileType))
|
||||
def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None:
|
||||
data = UntouchableBytes(b"content")
|
||||
|
||||
outcome = NullParser().parse(data, file_type)
|
||||
|
||||
assert outcome.status is ResultStatus.ERROR
|
||||
assert outcome.error == MessageError(
|
||||
code=ErrorCode.NOT_IMPLEMENTED,
|
||||
message="разбор XLS/XLSX/XLSB реализуется в Срезе 2",
|
||||
)
|
||||
assert outcome.documents == ()
|
||||
assert outcome.metrics is None
|
||||
|
||||
|
||||
def test_get_parser_returns_osv_parser_with_default_limits() -> None:
|
||||
parser = get_parser()
|
||||
|
||||
assert isinstance(parser, OsvParser)
|
||||
assert isinstance(parser, Parser)
|
||||
assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def test_get_parser_takes_limits_from_settings(minimal_settings: Callable[..., Settings]) -> None:
|
||||
settings = minimal_settings("PARSE_MAX_BYTES=1024\nPARSE_TIMEOUT_SECONDS=7\n")
|
||||
|
||||
parser = get_parser(settings)
|
||||
|
||||
assert isinstance(parser, OsvParser)
|
||||
assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста
|
||||
|
||||
|
||||
def test_get_parser_fails_on_broken_dictionary(tmp_path: Path) -> None:
|
||||
"""Spec «Словари — данные»: ошибка словаря — падение старта с указанием файла и записи."""
|
||||
broken = tmp_path / "rules"
|
||||
shutil.copytree(DEFAULT_RULES_DIR, broken)
|
||||
headers = broken / HEADERS_FILE
|
||||
headers.write_text(headers.read_text(encoding="utf-8").replace("total_row:", "total_rows:"), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
|
||||
get_parser(rules_dir=broken)
|
||||
|
||||
|
||||
def test_outcome_error_requires_error_payload() -> None:
|
||||
with pytest.raises(ValueError, match="error"):
|
||||
ParseOutcome(status=ResultStatus.ERROR)
|
||||
|
||||
|
||||
def test_outcome_success_requires_documents() -> None:
|
||||
with pytest.raises(ValueError, match="documents"):
|
||||
ParseOutcome(status=ResultStatus.DONE)
|
||||
@ -356,9 +356,9 @@ def get_parser(settings: Settings | None = None) -> Parser
|
||||
```
|
||||
`parse`: размер > max_bytes → `UNSUPPORTED_FORMAT`; `read_book` (ошибки → коды); листы: `classify_sheet` → OSV → `parse_sheet`, STATEMENT → warning `sheet=<name> skipped: statement (slice 2b)`, OTHER → warning; ни одного OSV → `INVALID_DOCUMENT` «листов просмотрено N»; `InvalidFormatError` на листе → `error INVALID_FORMAT` всего файла с именем листа и шага; `ParseTimeoutError` → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` (одна строка `type: message`). Статус: все листы ok → `done`, иначе `needs_check`. `metrics = Metrics(rows=Σ строк данных без TOTAL, warnings=[...])`, первой записью по каждому листу — `sheet=<name> hierarchy=<strategy>`. `columns` документа = `ColumnMap.as_diagnostics()` + `{"inn": letter}` при наличии.
|
||||
|
||||
- [ ] **Step 1: Тесты.** `test_checks.py`: тождество с null; родитель ≠ Σ детей; CURRENCY-дети исключены; TOTAL vs корень; пустая ОСВ без корня → True. `test_osv_parser.py` (юнит, книги собираются openpyxl в памяти): мини-ОСВ → `done` с полными полями; испорченная сумма → `needs_check`, `checks` и warnings с номерами; лист без заголовка → `INVALID_FORMAT`; книга без ОСВ → `INVALID_DOCUMENT`; `data` на 1 байт больше лимита (через `max_bytes=10`) → `UNSUPPORTED_FORMAT`; дедлайн (clock-заглушка) → `TIMEOUT`; исключение в стратегии (monkeypatch) → `RECOGNITION_FAILED`; ББ-лист + ОСВ-лист → один документ + warning; результат проходит `RecognitionResult.model_validate` и JSON-схему v1.1. `test_parser_factory.py`: `get_parser()` — `OsvParser`, `NullParser` по-прежнему `NOT_IMPLEMENTED`. `test_osv_property.py` (`fixtures`, параметр `osv_file`): `status is DONE`; один документ; `checks.total_row_matches_root`; списки нарушений пусты; `header.account_code_raw == rows[0].account_code_raw` (корень); `period_quarter` задан; `metrics.rows > 0` кроме файла `…66 за 2 квартал 2025 г..xlsx` (0); разбор < 5 с; результат валиден по схеме v1.1.
|
||||
- [ ] **Step 2–4:** падает → реализация → `pytest tests/unit/parser tests/fixtures -v`, `make check`.
|
||||
- [ ] **Step 5: Commit** `feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты`.
|
||||
- [x] **Step 1: Тесты.** `test_checks.py`: тождество с null; родитель ≠ Σ детей; CURRENCY-дети исключены; TOTAL vs корень; пустая ОСВ без корня → True. `test_osv_parser.py` (юнит, книги собираются openpyxl в памяти): мини-ОСВ → `done` с полными полями; испорченная сумма → `needs_check`, `checks` и warnings с номерами; лист без заголовка → `INVALID_FORMAT`; книга без ОСВ → `INVALID_DOCUMENT`; `data` на 1 байт больше лимита (через `max_bytes=10`) → `UNSUPPORTED_FORMAT`; дедлайн (clock-заглушка) → `TIMEOUT`; исключение в стратегии (monkeypatch) → `RECOGNITION_FAILED`; ББ-лист + ОСВ-лист → один документ + warning; результат проходит `RecognitionResult.model_validate` и JSON-схему v1.1. `test_parser_factory.py`: `get_parser()` — `OsvParser`, `NullParser` по-прежнему `NOT_IMPLEMENTED`. `test_osv_property.py` (`fixtures`, параметр `osv_file`): `status is DONE`; один документ; `checks.total_row_matches_root`; списки нарушений пусты; `header.account_code_raw == rows[0].account_code_raw` (корень); `period_quarter` задан; `metrics.rows > 0` кроме файла `…66 за 2 квартал 2025 г..xlsx` (0); разбор < 5 с; результат валиден по схеме v1.1.
|
||||
- [x] **Step 2–4:** падает → реализация → `pytest tests/unit/parser tests/fixtures -v`, `make check`.
|
||||
- [x] **Step 5: Commit** `feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
|
||||
## 3. Сборка и статусы
|
||||
|
||||
- [ ] 3.1 `checks.py` + `osv.py`: инварианты §1.4 (±0.01, `null` = 0, CURRENCY/QUANTITY исключены), `checks`, статусы D-6,
|
||||
- [x] 3.1 `checks.py` + `osv.py`: инварианты §1.4 (±0.01, `null` = 0, CURRENCY/QUANTITY исключены), `checks`, статусы D-6,
|
||||
`Deadline` и `PARSE_MAX_BYTES`/`PARSE_TIMEOUT_SECONDS` в `Settings` (+ `.env.example`), `OsvParser.parse(data, file_type)
|
||||
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
|
||||
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user