feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий

CLI `python -m src.parser.cli <файл> [--file-type]` печатает JSON результата (коды 0/1/2 по статусу, 3 — файл/использование);
сборка RecognitionResult выделена из воркера в чистую `build_result` без изменения логики; тесты воркера на реальном
OsvParser, интеграционный сценарий «Реальная ОСВ через воркер», README «Запуск на файле».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 14:51:01 +04:00
parent cf532a325b
commit 621c0b2dc4
8 changed files with 498 additions and 20 deletions

View File

@ -116,6 +116,69 @@ curl -s localhost:8000/healthcheck
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS
восстанавливается автоматически (бесконечное автопереподключение).
## Запуск на файле
Разбор одного файла без NATS и S3 — из каталога `api/`:
```bash
cd api
ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XLS|XLSX|XLSB]
```
Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
переопределяет, а для неизвестного расширения обязателен. `ENV_FILE=.env.test` нужен, потому что импорт пакета `src`
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,
а код и сообщение ошибки дублируются в stderr.
| Код возврата | Значение |
|---|---|
| `0` | `done` |
| `1` | `needs_check` — нарушены контрольные суммы (§1.4) |
| `2` | `error` — разбор вернул ошибку (`error.code`, `error.message`) |
| `3` | файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`, недопустимое значение `--file-type`); у `argparse` код 2 по умолчанию, здесь он занят статусом `error` |
Пример (начало вывода на `ОСВ_66 за 2024.xls` из `fixtures`):
```json
{
"schema_version": "1.1",
"job_id": "00000000-0000-0000-0000-000000000000",
"file_id": "00000000-0000-0000-0000-000000000000",
"service": {
"name": "tabular-parser",
"version": "1.0.0"
},
"status": "done",
"metrics": {
"duration_ms": 2,
"rows": 9,
"warnings": [
"sheet=Лист_1 hierarchy=outline"
]
},
"documents": [
{
"document_type": "OSV",
"locator": "sheet:Лист_1",
"header": {
"organization_name": "АО \"Русский бисквит\"",
"organization_inn": null,
"account_code_raw": "66",
"period_text": "2024 г.",
"period_year": 2024,
"period_quarter": "Q4",
"is_cumulative": true,
"data_type": "BU",
"currency": "RUB",
"header_variant": 1
},
```
## Переменные окружения
Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание.

View File

@ -83,6 +83,23 @@ def result_key(file_id: object, job_id: object) -> str:
return f"results/{file_id}/{job_id}/result.json"
def build_result(
outcome: ParseOutcome, *, job_id: object, file_id: object, service: ServiceInfo, metrics: Metrics | None
) -> RecognitionResult:
"""Файл результата контракта из успешного исхода (`done | needs_check`); общий для воркера и CLI разбора файла."""
return RecognitionResult.model_validate(
{
"schema_version": CONTRACT_VERSION,
"job_id": job_id,
"file_id": file_id,
"service": service,
"status": ResultStatus(outcome.status),
"metrics": metrics,
"documents": list(outcome.documents),
}
)
class InvalidRequestError(Exception):
"""Запрос не прошёл контракт: `SCHEMA_MISMATCH`."""
@ -378,16 +395,8 @@ class RecognitionWorker:
key = result_key(request.file_id, request.job_id)
try:
metrics = self._metrics(outcome.metrics, started)
result = RecognitionResult.model_validate(
{
"schema_version": CONTRACT_VERSION,
"job_id": request.job_id,
"file_id": request.file_id,
"service": self._service,
"status": ResultStatus(outcome.status),
"metrics": metrics,
"documents": list(outcome.documents),
}
result = build_result(
outcome, job_id=request.job_id, file_id=request.file_id, service=self._service, metrics=metrics
)
await self._storage.put_json(request.s3.bucket, key, result.to_wire())
except Exception as exc:

133
api/src/parser/cli.py Normal file
View File

@ -0,0 +1,133 @@
"""Разбор одного файла из командной строки: `python -m src.parser.cli <файл> [--file-type XLSX|XLS|XLSB]` (из `api/`).
В stdout — JSON файла результата контракта v1.1 (тот же, что воркер кладёт в S3 под `results/…/result.json`; сборка
— `core.worker.build_result`) для `done | needs_check`, либо `{"status": "error", "error": {...}}` для ошибки разбора.
JSON детерминирован по форме: `ensure_ascii=False`, `indent=2`, порядок полей — порядок модели (`sort_keys` нет);
суммы — строки. `job_id`/`file_id` — фиксированные нулевые заглушки, `service` — `tabular-parser` с версией по
умолчанию. Метрика `duration_ms` — фактическая, поэтому от запуска к запуску меняется.
Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
переопределяет. Код возврата:
- 0 — `done`;
- 1 — `needs_check` (нарушены контрольные суммы §1.4);
- 2 — `error` (результат разбора — ошибка; код и сообщение — в stdout и в stderr);
- 3 — файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`,
недопустимое значение `--file-type`): у argparse код 2 по умолчанию, но он занят статусом `error`, поэтому
ошибки использования переопределены на 3.
"""
import argparse
import json
import sys
import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any, NoReturn
from src.contract import FileType, ResultStatus, ServiceInfo
from src.core.config import Settings
from src.core.worker import build_result
from src.parser import ParseOutcome, get_parser
__all__ = ["main"]
EXIT_DONE = 0
EXIT_NEEDS_CHECK = 1
EXIT_ERROR = 2
EXIT_USAGE = 3
STUB_ID = uuid.UUID(int=0)
FILE_TYPES: tuple[FileType, ...] = ("XLS", "XLSX", "XLSB")
JSON_INDENT = 2
class _Parser(argparse.ArgumentParser):
"""Ошибки использования — код 3 (код 2 занят статусом `error`)."""
def error(self, message: str) -> NoReturn:
self.print_usage(sys.stderr)
self.exit(EXIT_USAGE, f"{self.prog}: ошибка: {message}\n")
def _build_argument_parser() -> argparse.ArgumentParser:
parser = _Parser(
prog="python -m src.parser.cli",
description="Разбор XLS/XLSX/XLSB (ОСВ): JSON файла результата — в stdout.",
epilog="Коды возврата: 0 — done, 1 — needs_check, 2 — error, 3 — файл не найден / ошибка использования.",
)
parser.add_argument("path", type=Path, metavar="файл", help="путь к файлу XLS/XLSX/XLSB")
parser.add_argument(
"--file-type",
choices=FILE_TYPES,
help="тип файла; по умолчанию — по расширению файла",
)
return parser
def _file_type(path: Path, explicit: FileType | None) -> FileType | None:
if explicit is not None:
return explicit
extension = path.suffix.lstrip(".").upper()
return next((file_type for file_type in FILE_TYPES if file_type == extension), None)
def _error_payload(outcome: ParseOutcome) -> dict[str, Any]:
payload: dict[str, Any] = {"status": ResultStatus.ERROR.value}
if outcome.error is not None:
payload["error"] = outcome.error.model_dump(mode="json", exclude_unset=True)
if outcome.metrics is not None:
payload["metrics"] = outcome.metrics.model_dump(mode="json", exclude_unset=True)
return payload
def _dump(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, indent=JSON_INDENT) + "\n"
def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace | int:
try:
return _build_argument_parser().parse_args(argv)
except SystemExit as exit_request: # --help (0) и ошибки использования (3)
return exit_request.code if isinstance(exit_request.code, int) else EXIT_USAGE
def main(argv: Sequence[str] | None = None) -> int:
"""Разбирает файл и печатает JSON; возвращает код возврата процесса."""
arguments = _parse_arguments(argv)
if isinstance(arguments, int):
return arguments
path: Path = arguments.path
file_type = _file_type(path, arguments.file_type)
if file_type is None:
print(
f"неизвестное расширение файла {path.suffix!r}: укажите тип явно, --file-type {{{','.join(FILE_TYPES)}}}",
file=sys.stderr,
)
return EXIT_USAGE
try:
data = path.read_bytes()
except OSError as exc:
print(f"файл не прочитан: {path}: {exc.strerror or type(exc).__name__}", file=sys.stderr)
return EXIT_USAGE
outcome = get_parser().parse(data, file_type)
if outcome.status is ResultStatus.ERROR:
error = outcome.error
print(f"{error.code.value}: {error.message}" if error else "ошибка разбора", file=sys.stderr)
sys.stdout.write(_dump(_error_payload(outcome)))
return EXIT_ERROR
service = ServiceInfo.model_validate(
{"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default}
)
result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics)
sys.stdout.write(_dump(result.to_wire()))
return EXIT_DONE if outcome.status is ResultStatus.DONE else EXIT_NEEDS_CHECK
if __name__ == "__main__":
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8") # вывод не зависит от локали терминала
raise SystemExit(main())

View File

@ -8,7 +8,8 @@
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия».
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
@ -31,12 +32,21 @@ from jsonschema import Draft202012Validator, FormatChecker
from miniopy_async import Minio
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from result_contract import schema_errors
from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT, request_msg_id
from src.contract import (
NATS_MSG_ID_HEADER,
RESULT_SUBJECT,
TABULAR_REQUEST_SUBJECT,
RecognitionResult,
request_msg_id,
)
from src.core.config import get_settings
pytestmark = pytest.mark.integration
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
MESSAGE_VALIDATOR = Draft202012Validator(
@ -52,6 +62,7 @@ MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с зап
QUIET_WINDOW = 1.5
SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
XLS_CONTENT_TYPE = "application/vnd.ms-excel"
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
@ -147,13 +158,30 @@ class Uploads:
def __init__(self, client: Minio) -> None:
self.client = client
self.keys: list[tuple[str, str]] = []
self.buckets: set[str] = set()
async def put(self, request: dict[str, Any]) -> None:
async def put(
self, request: dict[str, Any], data: bytes = SOURCE_BYTES, content_type: str = XLSX_CONTENT_TYPE
) -> None:
bucket, key = request["s3"]["bucket"], request["s3"]["key"]
data = io.BytesIO(SOURCE_BYTES)
await self.client.put_object(bucket, key, data, len(SOURCE_BYTES), content_type=XLSX_CONTENT_TYPE)
await self._ensure_bucket(bucket)
await self.client.put_object(bucket, key, io.BytesIO(data), len(data), content_type=content_type)
self.keys.append((bucket, key))
async def _ensure_bucket(self, bucket: str) -> None:
"""`NoSuchBucket` (OQ-80): на чистом MinIO бакета нет — тест создаёт его сам, compose не трогаем."""
if bucket not in self.buckets:
if not await self.client.bucket_exists(bucket):
await self.client.make_bucket(bucket)
self.buckets.add(bucket)
async def read(self, bucket: str, key: str) -> bytes:
response = await self.client.get_object(bucket, key)
try:
return await response.read()
finally:
response.release()
@pytest.fixture
async def uploads() -> AsyncIterator[Uploads]:
@ -254,3 +282,34 @@ async def test_unsupported_schema_version_gives_single_schema_mismatch_without_a
body = _assert_error(await core.next_message(request), request, "SCHEMA_MISMATCH")
assert "2.0" in body["error"]["message"]
await core.assert_quiet(request)
@pytest.mark.fixtures
async def test_real_osv_through_worker_gives_done_and_valid_result_file(
core: Core, uploads: Uploads, fixtures_dir: Path
) -> None:
"""«Реальная ОСВ через воркер»: реальный XLS в MinIO → ack → `done`, `result_schema_version 1.1`, файл по схеме."""
request = _new_request(key_name="osv_66_2024.xls")
request["file_type"] = "XLS"
await uploads.put(request, (fixtures_dir / REAL_OSV_66).read_bytes(), XLS_CONTENT_TYPE)
await core.publish(request)
_assert_ack(await core.next_message(request), request)
body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}")
assert body["status"] == "done", body
assert body["result_schema_version"] == "1.1"
assert body["metrics"]["duration_ms"] >= 0
result_ref = body["result_s3"]
assert result_ref["bucket"] == request["s3"]["bucket"]
assert result_ref["key"] == f"results/{request['file_id']}/{request['job_id']}/result.json"
uploads.keys.append((result_ref["bucket"], result_ref["key"])) # результат тоже убирается после теста
stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"]))
assert schema_errors(stored) == []
assert RecognitionResult.model_validate(stored).to_wire() == stored
assert stored["status"] == "done"
assert stored["job_id"] == request["job_id"]
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
await core.assert_quiet(request)

View File

@ -0,0 +1,185 @@
"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
Синтетические книги собираются openpyxl в памяти (`test_osv_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
запуск как модуля через subprocess (маркер `fixtures`).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from result_contract import schema_errors
from test_osv_parser import mini_osv, xlsx
from src.contract import RecognitionResult
from src.parser import cli
API_DIR = Path(__file__).resolve().parents[3]
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
SPOILED = (60, None, 20, 10, 71, None)
EXIT_DONE, EXIT_NEEDS_CHECK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2, 3
def _write(tmp_path: Path, name: str, data: bytes) -> Path:
path = tmp_path / name
path.write_bytes(data)
return path
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, str, str]:
code = cli.main(list(argv))
captured = capsys.readouterr()
return code, captured.out, captured.err
def test_done_book_returns_0_and_prints_result_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
code, out, _err = _run(capsys, str(path))
assert code == EXIT_DONE
payload: dict[str, Any] = json.loads(out)
assert payload["status"] == "done"
assert payload["schema_version"] == "1.1"
assert schema_errors(payload) == []
assert RecognitionResult.model_validate(payload).to_wire() == payload
assert payload["documents"][0]["header"]["account_code_raw"] == "60"
assert "ОСВ 60" in out # не ASCII-экранирование
assert out.endswith("\n")
def test_output_is_indented_and_keeps_model_field_order(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
_code, out, _err = _run(capsys, str(path))
assert out.startswith('{\n "schema_version": "1.1",\n')
keys = list(json.loads(out))
assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden
def test_money_is_printed_as_strings(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
_code, out, _err = _run(capsys, str(path))
row = json.loads(out)["documents"][0]["rows"][1]
assert row["opening_debit"] == "60.00"
def test_needs_check_book_returns_1(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "spoiled.xlsx", xlsx({"ОСВ 60": mini_osv(SPOILED)}))
code, out, _err = _run(capsys, str(path))
assert code == EXIT_NEEDS_CHECK
payload = json.loads(out)
assert payload["status"] == "needs_check"
assert schema_errors(payload) == []
def test_error_outcome_returns_2_with_error_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "broken.xlsx", b"PK\x03\x04 broken zip")
code, out, err = _run(capsys, str(path))
assert code == EXIT_ERROR
payload = json.loads(out)
assert payload["status"] == "error"
assert payload["error"]["code"] == "UNREADABLE"
assert payload["error"]["message"]
assert "UNREADABLE" in err
def test_missing_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
code, out, err = _run(capsys, str(tmp_path / "nope.xlsx"))
assert code == EXIT_USAGE
assert out == ""
assert "nope.xlsx" in err
def test_directory_instead_of_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
code, out, _err = _run(capsys, str(tmp_path))
assert code == EXIT_USAGE
assert out == ""
def test_unknown_extension_without_file_type_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
code, out, err = _run(capsys, str(path))
assert code == EXIT_USAGE
assert out == ""
assert "--file-type" in err
def test_explicit_file_type_overrides_extension(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
code, out, _err = _run(capsys, str(path), "--file-type", "XLSX")
assert code == EXIT_DONE
assert json.loads(out)["status"] == "done"
def test_extension_is_case_insensitive(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "OSV.XLSX", xlsx({"ОСВ 60": mini_osv()}))
code, _out, _err = _run(capsys, str(path))
assert code == EXIT_DONE
def test_invalid_file_type_value_returns_3_not_argparse_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
code, out, err = _run(capsys, str(path), "--file-type", "PDF")
assert code == EXIT_USAGE
assert out == ""
assert "PDF" in err
def test_no_arguments_returns_3(capsys: pytest.CaptureFixture[str]) -> None:
code, out, _err = _run(capsys)
assert code == EXIT_USAGE
assert out == ""
def test_output_is_deterministic_except_duration(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
first = json.loads(_run(capsys, str(path))[1])
second = json.loads(_run(capsys, str(path))[1])
for payload in (first, second):
payload["metrics"].pop("duration_ms")
assert first == second
@pytest.mark.fixtures
def test_module_run_on_real_file_prints_valid_json(fixtures_dir: Path) -> None:
"""`python -m src.parser.cli <файл>` из `api/`: stdout — JSON результата по схеме, код возврата 0."""
completed = subprocess.run(
[sys.executable, "-m", "src.parser.cli", str(fixtures_dir / REAL_OSV_66)],
cwd=API_DIR,
env={**os.environ, "ENV_FILE": ".env.test"}, # импорт пакета `src` читает настройки сервиса
capture_output=True,
check=False,
timeout=120,
)
assert completed.returncode == EXIT_DONE, completed.stderr.decode("utf-8", "replace")
payload = json.loads(completed.stdout.decode("utf-8"))
assert payload["status"] == "done"
assert schema_errors(payload) == []
assert payload["documents"][0]["header"]["account_code_raw"] == "66"

View File

@ -33,7 +33,7 @@ from src.contract import (
from src.core.config import NATSSettings
from src.core.nats import NatsConnection, NatsPublishError
from src.core.worker import RecognitionWorker, result_key
from src.parser import NullParser, ParseOutcome
from src.parser import NullParser, ParseOutcome, get_parser
API_DIR = Path(__file__).resolve().parents[2]
CONTRACTS_DIR = API_DIR / "contracts"
@ -825,3 +825,32 @@ async def test_malformed_request_for_done_job_is_dropped(env: Env) -> None:
await env.worker.join()
assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
# --- реальный парсер в воркере ------------------------------------------------------------------------------------
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
@pytest.mark.fixtures
async def test_worker_with_real_osv_parser_gives_done_and_valid_result_file(fixtures_dir: Path) -> None:
"""Spec «Реальная ОСВ через воркер»: настоящий `OsvParser` (`get_parser`) + байты реальной ОСВ 66 без NATS/S3."""
environment = Env(get_parser())
environment.storage.objects[(BUCKET, SOURCE_KEY)] = (fixtures_dir / REAL_OSV_66).read_bytes()
environment.worker.start()
try:
await environment.worker.on_request(_msg(_request(file_type="XLS")))
await environment.worker.join()
finally:
await environment.worker.stop(timeout=1)
assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
envelope = environment.bodies()[1]
_assert_message_valid(envelope)
assert envelope["status"] == "done"
assert envelope["result_schema_version"] == "1.1"
assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY}
stored = environment.storage.json_objects[(BUCKET, RESULT_KEY)]
assert [error.message for error in VALIDATORS["result"].iter_errors(stored)] == []
assert RecognitionResult.model_validate(stored).to_wire() == stored
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
assert stored["documents"][0]["header"]["account_code_raw"] == "66"

View File

@ -368,9 +368,9 @@ def get_parser(settings: Settings | None = None) -> Parser
- Create: `api/src/parser/cli.py` (`python -m src.parser.cli <файл> [--file-type XLSX]` → JSON результата в stdout, код возврата 0/1/2 по статусу)
- Modify: `api/tests/unit/test_worker.py` (новый тест с реальным `OsvParser` и байтами `rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`), `api/tests/integration/test_roundtrip.py` (сценарий «Реальная ОСВ через воркер»: файл в MinIO, запрос `XLS`, конверт `done`, `result_schema_version "1.1"`, файл результата по схеме, `account_code_raw "66"`), `README.md` (раздел «Запуск на файле»)
- [ ] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
- [ ] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
- [ ] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
- [x] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
- [x] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
- [x] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
---

View File

@ -48,7 +48,7 @@
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
сумма → `needs_check` с номерами строк. `fixtures`-тест (property): все 66 — `done`, инварианты, корень = счёт шапки.
- [ ] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
- [x] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
интеграционный roundtrip (NATS + MinIO): конверт `done`, `result_schema_version = "1.1"`, файл результата проходит схему.
README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).