Приложение к ТЗ: события, ошибки, сообщения, логирование
Дополняет основное ТЗ (§7 Статусы, §8 События, §11 Ошибки). Здесь — исчерпывающий каталог: что может пойти не так, как это классифицируется, что видит пользователь и как это отлаживается.
1. Нормализация и опечатки в артикулах
Артикул — «грязный» ввод (копипаст из Excel/1С/писем). Поиск должен прощать типовые искажения. Пайплайн нормализации применяется к каждой строке столбца A перед запросом к API; исходное и нормализованное значения сохраняются оба.
1.1. Пайплайн нормализации (normalize_code)
| Шаг | Что делает | Пример |
|---|---|---|
| trim | убрать пробелы по краям и внутри | " 0451 103 316 " → 0451103316 |
| unify-case | привести к верхнему регистру для сравнения/дедупа | bp1234a → BP1234A |
| collapse-spaces | схлопнуть множественные пробелы, затем убрать | ABC 123 → ABC123 |
⚠️ Кириллица сохраняется в основном коде (изменено 2026-07-10). Многие РФ-каталоги (ГАЗ/КамАЗ/ЗМЗ) хранят артикулы в кириллице (напр.
130У.09.012), и принудительная латинизация промахивалась бы по базе. Латинизированный вариант (С→C,К→K, …) пробуется как кандидат, а не подменяет основной код.
Помимо основного кода строятся кандидаты (для «прощающего» поиска, §1.2):
| Кандидат | Назначение |
|---|---|
латинизированный (С→C, А→A, О→O, К→K, У→Y, …) | импортный код, ошибочно введённый кириллицей |
без разделителей (-, ., /, \) | GDB-1330 → GDB1330 |
| O↔0, I↔1 | визуально похожие цифры/буквы |
1.2. Стратегия «прощающего» поиска
1. Определение бренда перебирает варианты кода по приоритету
(оригинал-с-кириллицей → латинизированный → без разделителей → O/0 → I/1),
но не более settings.FUZZY_MAX_TRIES+1 запросов. Первый вариант, давший бренд,
и есть рабочий код детали.
2. По найденному коду тянутся entities/relations/cars.
3. Если данных нет и включён fuzzy-режим — добираем оставшихся кандидатов
(не более settings.FUZZY_MAX_TRIES). В note фиксируется исправленный вариант.
4. Все варианты исчерпаны → not_found.1.3. Предупреждения о вводе (не блокируют, показываются оператору)
| Проверка | Сообщение оператору |
|---|---|
| пустая строка среди списка | «Строка N пустая — пропущена» |
| длина < 3 или > 250 (лимит API) | «Строка N: артикул слишком короткий/длинный — пропущен» |
| дубликат в списке | «Строка N — дубль строки M, запрошено один раз» |
| очень много строк (> MAX_ROWS) | «Список превышает лимit {MAX_ROWS}; лишние строки не приняты» |
| смешанные разделители/формат | «Строка N: необычный формат артикула, проверьте результат» |
Предупреждения возвращаются при создании задания (POST /jobs → warnings[]) и дублируются в UI до старта обработки.
2. Полный каталог результатов строки
Расширяет §7.2 основного ТЗ. Для КАЖДОЙ строки фиксируется исход и человекочитаемое note. UI и Excel показывают статус + note.
| Статус | Условие | note (пример) | Цвет в Excel/UI |
|---|---|---|---|
ok | найдены все целевые поля | — | без заливки / зелёный бейдж |
partial | есть данные, но нет части размеров/веса | «Нет высоты и веса в базе» | жёлтый |
partial | найдено по исправленному коду | «Найдено по 0451103316 (исправлен формат)» | жёлтый |
not_found | нет ни entities/relations/cars | «Артикул не найден в базе PartsIndex» | серый |
not_found | бренд не определён | «Не удалось определить бренд по артикулу» | серый |
ambiguous | несколько брендов, политика operator без бренда | «Найдено {n} брендов — уточните бренд» | оранжевый |
error | сетевая/серверная ошибка по строке (после ретраев) | «Временная ошибка сервиса, повторите» | красный |
skipped | не прошла валидацию ввода (§1.3) | «Пропущена: пустая строка» | серый (курсив) |
Итоговая сводка задания: {total, ok, partial, not_found, ambiguous, error, skipped}.
3. Каталог технических ошибок (внешний API и инфраструктура)
Единый маппинг «сырой ошибки» → внутренний класс → реакция → сообщение. Внутренние детали (коды, тела) идут в логи; пользователю — только дружелюбный текст.
| Источник | Внутренний код | Класс | Реакция | Сообщение пользователю |
|---|---|---|---|---|
API 200, пустой список | PI_EMPTY | data | строка not_found | «Артикул не найден» |
API 400 | PI_BAD_REQUEST | client | строка error, лог WARN | «Некорректный запрос по строке» |
API 401 no auth | PI_NO_AUTH | config | Job blocked (no_key), алерт | «Сервис данных недоступен (авторизация)» |
API 403/1003 ip | PI_IP_DENY | config | Job blocked (ip), алерт | «Сервис данных временно недоступен» |
API 403/1003 quota | PI_QUOTA | quota | Job blocked (quota), алерт | «Достигнут дневной лимит запросов, задание приостановлено» |
API 403/1003 domain/resource | PI_ACCESS_DENY | config | Job blocked, алерт | «Сервис данных временно недоступен» |
API 404 | PI_NOT_FOUND | data | строка not_found | «Артикул не найден» |
API 429 (если появится) | PI_RATE | retriable | backoff + ретрай | (прозрачно) |
API 5xx | PI_SERVER | retriable | ретрай до N, затем строка error | «Временная ошибка сервиса» |
| таймаут/сеть | PI_TIMEOUT | retriable | ретрай до N, затем строка error | «Превышено время ожидания» |
| невалидный JSON ответа | PI_PARSE | bug | строка error, лог ERROR + raw | «Ошибка обработки ответа» |
| БД недоступна | DB_DOWN | infra | 503, задание остаётся running/восстановление | «Сервис временно недоступен» |
| Redis недоступен | CACHE_DOWN | infra-soft | деградация: без кэша, WARN | (прозрачно, но лог) |
| диск/запись xlsx | FILE_IO | infra | Excel error, ретрай генерации | «Не удалось сформировать файл, повторите» |
| ключ не задан в .env | NO_KEY_CONFIG | config | старт задания запрещён | «Сервис не настроен, обратитесь к администратору» |
| необработанное исключение в воркере | INTERNAL_ERROR | bug | Job failed, block_reason сохраняется | «Внутренняя ошибка обработки задания. Нажмите «Возобновить» или обратитесь к администратору, если повторится.» |
Классы и общая политика:
retriable— экспоненциальный backoff (напр. 0.5→1→2 с, джиттер), доattempts_max.quota/config— приостановка задания (blocked), не потеря строк; возобновление после сброса лимита/исправления ключа/IP (POST /jobs/{id}/retry).data— нормальный «нет данных», не ошибка; строкаnot_found.bug/infra— логируются с полным контекстом (см. §5), пользователю — обобщённо.
INTERNAL_ERROR — единственный код класса bug, который переводит всё задание в failed (а не только строку в error): его ставит app/workers/runner.py при необработанном исключении в process_job, чтобы задание не зависало в running до перезапуска воркера. И для blocked (config/quota), и для failed (internal_error) причина пишется в один и тот же Job.block_reason; человекочитаемый текст для обоих случаев отдаёт app/core/errors.error_user_message() — на его основе JobSummary.block_reason_message показывается и в списке заданий (/history), и на странице конкретного задания, без необходимости открывать задание, чтобы понять причину.
Отдельно от этого каталога (который управляет статусом заданий) есть более подробная, специально для UI API-ключей классификация в app/services/api_keys.py: каждый результат живой проверки ключа несёт who (self/supplier/developer/wait/none) — явную подсказку, кому решать проблему — и различает настоящий таймаут (error_kind="timeout") и обрыв сети/DNS/TLS (error_kind="network"), которые раньше обе схлопывались в «не ответил вовремя». Эта классификация показывается в admin → «API-ключи» и через GET /status — операторам (см. §6.4 ниже).
4. Сообщения и статусы для пользователя (UX)
4.1. Принципы
- Пользователю — человеческий русский текст, без кодов и стектрейсов.
- Технический код (
PI_QUOTAи т.п.) иcorrelation_id— в свёрнутой «деталке» для поддержки/админа и всегда в логах. - Статусы — цветными бейджами + иконкой + подсказкой (tooltip = note).
4.2. Уровни сообщений
| Уровень | Где | Пример |
|---|---|---|
| toast success | после действия | «Задание #123 создано, обрабатываем…» |
| toast warning | предупреждения ввода | «3 строки пропущены (пустые/дубли)» |
| inline banner | состояние задания | «Задание приостановлено: дневной лимит. Возобновить?» |
| row note | по строке | «Найдено по исправленному коду» |
| error toast | сбой действия | «Не удалось скачать файл. Попробуйте ещё раз» |
4.3. Отображение прогресса и итога
- Прогресс-бар
done/total+ счётчики по статусам вживую (SSEprogress). - Живая таблица: строки заполняются по мере
item.done, с фильтрами (Все / Найдено / Частично / Не найдено / Ошибки). - Итоговая плашка: «Готово: {ok} найдено, {partial} частично, {not_found} не найдено, {error} с ошибками» + кнопка «Скачать Excel».
blocked→ баннер с причиной и кнопкой «Возобновить»; данные уже полученных строк доступны.
4.4. Экспорт «проблемных» строк
Отдельная выгрузка только not_found/error/ambiguous (для повторного прогона после уточнения бренда/исправления) — кнопка «Скачать ненайденные».
5. Логирование
5.1. Формат и транспорт
- structlog, JSON-логи в stdout (собираются Docker/агрегатором). Не
print(). - Обязательные поля каждого события:
ts,level,logger,event,correlation_id,user_id?,job_id?,row_no?,endpoint?,duration_ms?. correlation_id(request id) генерируется на входящий HTTP-запрос (middleware), прокидывается в воркер и во все вызовы PartsIndex; возвращается клиенту в заголовкеX-Request-Idи показывается в «деталке» ошибки.
5.2. Уровни
| Уровень | Что пишем |
|---|---|
| DEBUG | тела запросов/ответов PartsIndex (усечённо), кандидаты нормализации, cache hit/miss — только при DEBUG=true |
| INFO | старт/финиш задания, сводка, cache-hit-rate, расход квоты |
| WARNING | ретраи, деградации (Redis down), пропуски строк, PI_BAD_REQUEST |
| ERROR | PI_PARSE, FILE_IO, необработанные исключения, blocked-переходы |
| CRITICAL | сервис не может работать (нет ключа, БД недоступна на старте) |
5.3. Что НЕ логируем
- Полный API-ключ (только префикс, напр.
OEM-API-F1B2…→ маскирование). - Пароли/хеши, JWT целиком.
- Персональные данные, если появятся.
5.4. Метрики (для /admin)
- Расход квоты по дням/эндпоинтам (
api_usage), cache-hit-rate. - Кол-во заданий по статусам, среднее время обработки строки, доля
not_found. - Топ ненайденных артикулов (для анализа качества базы/ввода).
6. Отладка и дебаг
6.1. Режим разработки без боевого ключа
- Mock-режим PartsIndex-клиента (
PI_MODE=mock): фикстуры ответов (entities/relations/cars/brands) изtests/fixtures/— позволяет разрабатывать всю логику, пока нет ключа/whitelisted-IP. - Record/replay: при наличии ключа — записать реальные ответы в фикстуры (
PI_MODE=record) и переигрывать (PI_MODE=replay) без расхода квоты.
6.2. Инструменты диагностики
| Инструмент | Назначение |
|---|---|
X-Request-Id в ответе + логах | сквозная трассировка одного запроса |
raw_json в job_items | посмотреть точный ответ API по проблемной строке |
GET /jobs/{id}/events (история из job_events) | воспроизвести ход выполнения |
GET /admin/audit | кто что запускал/отменял |
GET /health (без авторизации) | живы ли БД/Redis, задан ли ключ PartsIndex в .env |
GET /status (любой авторизованный) | живой статус ключей источников + расход за сегодня |
GET /admin/api-keys + POST /admin/api-keys/{source}/check (только admin) | маскированный ключ, живая проверка, правка |
| «деталка» ошибки в UI (код + correlation_id) | связать жалобу пользователя с логом |
6.3. Health-check GET /health
Без авторизации, только базовая доступность (не бьёт по внешнему API). Реальный формат (app/routers/health.py):
{
"status": "ok|degraded|down",
"db": "ok|down", "redis": "ok|down",
"partsindex_key": "configured|missing",
"partsindex_mode": "live|mock|record|replay",
"version": "0.1.0"
}partsindex_key смотрит только .env (settings.pi_api_key) — если ключ задан через админку и хранится в БД (app_settings), а .env пуст, /health всё равно покажет missing. Это ожидаемо: /health — про доступность инфраструктуры для оркестратора/мониторинга, а не про то, работает ли текущий ключ у поставщика.
6.4. Живой статус ключей — GET /status
Для «работает ли ключ прямо сейчас» есть отдельный, более информативный эндпоинт (app/routers/status.py), доступный любому авторизованному пользователю, не только admin:
{
"sources": [
{"source": "pi", "title": "Parts.Index", "ok": true, "status": "ok",
"who": null, "who_label": null, "message": "Ключ работает: сервис ответил успешно."},
{"source": "pc", "title": "Parts-Catalogs (Tradesoft)", "ok": false, "status": "access_deny",
"who": "supplier", "who_label": "Обратитесь к поставщику данных…", "message": "…"}
],
"quota": [ {"source": "pi-info", "title": "…", "used": 16, "quota": null, "percent": null, "level": "ok"}, … ]
}Ключ (даже маскированный) сюда не попадает — только статус и текст. Результат идёт из api_keys.cached_probe() (кэш ~180 c, чтобы каждый заход оператора не слал лишний запрос поставщику); ручная проверка в admin → «API-ключи» обновляет тот же кэш принудительно. На фронте SourceStatusBanner.vue дёргает /status на каждой странице и показывает баннер только когда ok: false у источника или уровень квоты не ok — молчит, если всё в порядке.
6.5. Воспроизводимость инцидента
По жалобе «строка не нашлась»: оператор сообщает № задания и артикул → админ по raw_json/job_events/логам с correlation_id видит: нормализованный код, какие кандидаты пробовались, ответ API, итоговый статус и причину.
7. Сводка полей для трассировки одной строки
row_no · code (исходный) · нормализованный код · кандидаты · выбранный brand · статус каждого из 3 запросов · attempts · итоговый status · note · correlation_id · raw_json. Этого набора достаточно, чтобы объяснить ЛЮБОЙ исход строки без повторного обращения к API.