diff --git a/api/.env.example b/api/.env.example index 8614ffe..2f5738d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/.env.test b/api/.env.test index 099d508..16a4d00 100644 --- a/api/.env.test +++ b/api/.env.test @@ -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 diff --git a/api/src/app.py b/api/src/app.py index 2956145..434a69c 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -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, diff --git a/api/src/core/config.py b/api/src/core/config.py index f81a927..25fdb11 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -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", diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py index eff0a80..68d8c05 100644 --- a/api/src/parser/__init__.py +++ b/api/src/parser/__init__.py @@ -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) diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py new file mode 100644 index 0000000..2090570 --- /dev/null +++ b/api/src/parser/checks.py @@ -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 diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py new file mode 100644 index 0000000..ecc9e53 --- /dev/null +++ b/api/src/parser/osv.py @@ -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), + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index efa66ce..bff8da3 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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 diff --git a/api/tests/fixtures/test_osv_property.py b/api/tests/fixtures/test_osv_property.py new file mode 100644 index 0000000..235bd92 --- /dev/null +++ b/api/tests/fixtures/test_osv_property.py @@ -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) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index b85bc4a..339ef76 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -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 = :` и собирает конверты из `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 = :` _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( diff --git a/api/tests/result_contract.py b/api/tests/result_contract.py new file mode 100644 index 0000000..ba71f83 --- /dev/null +++ b/api/tests/result_contract.py @@ -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 diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py new file mode 100644 index 0000000..a5a71d8 --- /dev/null +++ b/api/tests/unit/parser/test_checks.py @@ -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 diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_osv_parser.py new file mode 100644 index 0000000..f17ab44 --- /dev/null +++ b/api/tests/unit/parser/test_osv_parser.py @@ -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] diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index 77e214e..8ee3b01 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -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 diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py index cbb420c..0abd8b5 100644 --- a/api/tests/unit/test_config.py +++ b/api/tests/unit/test_config.py @@ -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: diff --git a/api/tests/unit/test_null_parser.py b/api/tests/unit/test_null_parser.py deleted file mode 100644 index 22aec10..0000000 --- a/api/tests/unit/test_null_parser.py +++ /dev/null @@ -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) diff --git a/api/tests/unit/test_parser_factory.py b/api/tests/unit/test_parser_factory.py new file mode 100644 index 0000000..93c4d77 --- /dev/null +++ b/api/tests/unit/test_parser_factory.py @@ -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) diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 3b578f4..0d36bd5 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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= 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= hierarchy=`. `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 как парсер по умолчанию, лимиты`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index f5f389f..5e76989 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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), испорченная