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:
Andrey Epifancev 2026-10-05 14:37:21 +04:00
parent da7215b314
commit e705d75520
19 changed files with 1393 additions and 70 deletions

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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",

View File

@ -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
View 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
View 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),
)

View File

@ -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
View 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)

View File

@ -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(

View 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

View 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

View 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]

View File

@ -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

View File

@ -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:

View File

@ -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)

View 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)

View File

@ -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 как парсер по умолчанию, лимиты`.
---

View File

@ -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), испорченная