ТЗ: Многопользовательская админка сопоставления артикулов
- Дата: 2026-07-08
- Статус: дизайн (черновик, готов к ревью)
- Стек: FastAPI (Python 3.11+), PostgreSQL, Redis, Vue 3 (SPA), openpyxl
- Внешний источник данных: PartsIndex API (
https://api.parts-index.com/v1). См. также обзор.
1. Назначение
Веб-инструмент для операторов, который по списку артикулов запчастей автоматически подтягивает из PartsIndex API карточку данных (габариты, вес, применимость по авто, кросс-номера аналогов) и отдаёт результат таблицей с выгрузкой в Excel (.xlsx). Оператор может повторять операции сопоставления над новыми списками.
Роли
- Оператор — вставляет артикулы (по одному или списком), запускает обогащение, просматривает таблицу, скачивает Excel, видит историю своих заданий.
- Администратор — управляет пользователями, видит квоты/расход лимитов API, задаёт справочник маппинга параметров (какие
titleсчитать шириной/весом и т.д.).
2. Ключевое ограничение источника данных
Требуемые поля (габариты, вес, кроссы, применимость по артикулу) есть только в PartsIndex API, а не в parts-catalogs (OEM-каталоги). Это подтверждено разбором обеих спецификаций. Следствия для ТЗ:
- Нужен ключ PartsIndex со scope
access, info, relations, old-apply. Тестовый OEM-ключOEM-API-F1B2A560-…для этого не подходит (403 access deny). - Ключ привязан к IP-whitelist — боевой сервер должен выходить в интернет с whitelisted-IP (иначе
403 errorCode 1003 "IP not allowed"). Учесть при деплое. - Квоты по запросам ограничены — обязательны кэш и дедупликация артикулов.
3. Пользовательский сценарий
1. Оператор логинится.
2. Вставляет артикулы: textarea (по одному в строке) ИЛИ загрузка .xlsx/.csv (столбец A).
3. (опц.) указывает бренд для строк без бренда, либо система определяет его через
/brands/by-part-code.
4. Жмёт «Сопоставить» → создаётся задание (Job), артикулы дедуплицируются.
5. Система пачками опрашивает PartsIndex (entities + relations + cars) с учётом
кэша и лимита параллелизма; прогресс отображается вживую (SSE/polling).
6. Результат — таблица на экране (с подсветкой пустых/ошибочных строк).
7. Оператор скачивает .xlsx. Задание сохраняется в истории.
8. Повторяет с новым списком.4. Формат таблицы (Excel)
Оператор вставляет артикулы в столбец A. Система заполняет остальные:
| Столбец | Поле | Источник (PartsIndex) |
|---|---|---|
| A | Артикул | ввод оператора (code) |
| B | Ширина | entities → param Width → values[0].value |
| C | Длина | entities → param Length |
| D | Высота | entities → param Height |
| E | Вес | entities → param Weight |
| F | Марки и модели применимости | cars → нумерованный список, по строке на модель: N. brand model modif (годы) |
| G | Кросс-номера (аналоги) | relations → code (+ brand.name), через ; |
Дополнительно (служебные, опц. столбцы или отдельный лист): H — бренд, I — название детали, J — штрихкод(и), K — статус строки (ok / not_found / partial / error), L — примечание.
Названия параметров размеров/веса в PartsIndex приходят в
params[].title(напр.Length [mm],Weight [kg]) и зависят от группы детали. Маппинг ведётся через справочник синонимов (таблицаparam_aliases), редактируемый админом: набор регэкспов/строк → каноническое поле (width/length/height/weight).
4.1. Правила заполнения ячеек
- Размеры (B–E) — число (
float), единица измерения выносится в комментарий ячейки или в отдельную строку заголовка (Ширина, мм). - Применимость (F) — все записи (конфиг
MAX_APPLICABILITY, по умолчанию0— без ограничения по количеству), нумерованный список, каждая модель с новой строки (перенос по словам в Excel):N. Brand Model Modif (2015–2019). Годы — изdateFrom/dateTo(unix → год). Еслиmodifуже содержит марку+модель — дубль убирается. Единственное усечение — по физическому лимиту ячейки Excel (32767 символов): лишние строки не влезают, тогда добавляется суффикс… (+M), а полный список остаётся вraw_json. УстановкаMAX_APPLICABILITY=N (>0)возвращает старое поведение с жёстким лимитом в N моделей. - Кроссы (G) — уникальные
codeаналогов, форматcode (Brand), разделитель;. Дубли поcodeсхлопываются. - Незаполненные свойства — если поле данных отсутствует в базе, в ячейку (таблица и Excel) выводится «Свойство не заполнено» вместо пустого значения. Исключение — «Артикул» и «Статус» (всегда заполнены).
- Статус строки (K) подсвечивается цветом:
ok— без заливки,partial— жёлтая,not_found— серая,error— красная. - Первая строка листа — «шапка» (жирный, автофильтр, закрепление области
freeze A2).
5. Алгоритм обогащения одного артикула
Полный каталог событий, ошибок, сообщений пользователю и логирования вынесен в приложение.
Перед запросом каждый артикул проходит нормализацию (normalize_code: trim, верхний регистр, чистка пробелов; кириллица сохраняется — латинизированный вариант идёт лишь кандидатом). Исходный и нормализованный коды сохраняются оба. Определение бренда перебирает варианты кода (оригинал → латинизированный → без разделителей → O/0 → I/1); при not_found и включённом fuzzy-режиме добираются остальные кандидаты (не более FUZZY_MAX_TRIES). Детали — приложение §1.
вход: code (артикул), brand? (опционально)
0. нормализация: code_norm + кандидаты (приложение §1)
1. если brand не задан:
brands = GET /brands/by-part-code?code={code}
если brands пусто → status=not_found, стоп
brand = выбрать по политике (см. §7): переданный / первый / все
2. параллельно (asyncio.gather, каждый через кэш):
E = GET /entities?code={code}&brand={brand}
R = GET /relations?code={code}&brand={brand}
C = GET /cars?code={code}&brand={brand}
3. из E извлечь через matcher (param_aliases):
width, length, height, weight, part_name, barcode
4. из C собрать applicability (список CarApply → строки)
5. из R собрать crosses (список EntityRelation → code+brand)
6. вычислить status строки:
not_found — E пусто и R пусто и C пусто
error — любой запрос вернул 4xx/5xx (кроме 404 → трактуется как «нет данных»)
partial — есть данные, но нет хотя бы одного из размеров/веса
ok — заполнены все целевые поля
7. сохранить JobItem (поля + raw_json полных ответов), инкремент счётчиков Job,
эмит события item.doneКаждый шаг с сетевым вызовом идёт через Redis-кэш и учитывается в api_usage. Ошибки классифицируются (см. §8): retriable (5xx, timeout) → повтор с backoff; fatal для строки (404) → not_found; fatal для задания (403/1003 quota|ip) → blocked.
6. Архитектура
app/
main.py — FastAPI app, роутеры, CORS, middleware
core/
config.py — pydantic-settings (.env): API-ключ, БД, Redis, лимиты
database.py — async engine (asyncpg), SessionLocal
security.py — JWT, хеш паролей (passlib/argon2)
models/ — ORM: User, Job, JobItem, ParamAlias, ApiUsage
schemas/ — Pydantic: вход/выход, JobCreate/JobStatus/RowResult
routers/
auth.py — login, refresh, me
jobs.py — создать задание, статус, результат, скачать xlsx
admin.py — пользователи, квоты, param_aliases
services/
partsindex_client.py — async httpx-клиент PartsIndex (entities/relations/cars/brands)
enrichment.py — оркестрация обогащения одного артикула (3 запроса → RowResult)
matcher.py — извлечение размеров/веса из parameters по param_aliases
excel.py — сборка .xlsx (openpyxl) по столбцам A–L
cache.py — Redis-кэш ответов API (ключ = endpoint+code+brand)
quota.py — учёт расхода лимитов, троттлинг
workers/
tasks.py — фоновая обработка задания (asyncio-очередь или Celery)
tests/Компоненты
- PartsIndex client — тонкий async-клиент (httpx), заголовок
Authorization, ретраи с backoff, обработка403/1003(IP/quota) как явных ошибок. - Enrichment service — на артикул:
entities(+brands/by-part-codeесли нет бренда) →relations→cars; собираетRowResult. Все три запроса — параллельно (asyncio.gather), с общим таймаутом. - Cache (Redis) — кэш ответов по (endpoint, code, brand) с TTL по типу данных (entities/relations — сутки, cars — сутки). Снижает расход квоты на повторах.
- Quota/throttle — семафор на N одновременных запросов к API + учёт суточного расхода в
ApiUsage; при исчерпании — задание встаёт в очередь/помечается. - Excel service — openpyxl: шапка, автоширина, подсветка статусов, стрim-лист.
- Jobs (БД) — задание и его строки персистятся: можно вернуться к результату, перекачать Excel, посмотреть историю. Файлы .xlsx — на диск/volume, ссылка в Job.
7. Статусы (машина состояний)
7.1. Статус задания (Job.status)
| Статус | Значение | Из каких → входит | Куда → выходит |
|---|---|---|---|
pending | создано, в очереди на обработку | — (создание) | running, canceled |
running | воркер обрабатывает строки | pending, blocked (retry) | completed, failed, blocked, canceled |
blocked | приостановлено: квота/IP/нет ключа | running | running (retry), canceled |
completed | все строки обработаны (в т.ч. с not_found) | running | expired (по retention) |
failed | фатальная ошибка (не по строкам) | running | running (retry), expired |
canceled | отменено оператором/админом | pending, running, blocked | expired |
expired | результат/файл удалён по retention | completed, failed, canceled | — |
Правила переходов:
completedнаступает, когдаdone == totalи нет строк в статусеqueued/processing. Наличиеnot_found/error-строк не делает заданиеfailed— это нормальный итог.failed— только для ошибок вне логики строк (напр. необработанное исключение вprocess_job, битый входной файл). С этой версииfailedтоже пишет причину вblock_reason(кодINTERNAL_ERROR), а не оставляет её только в разовом SSE-событии — иначе причина терялась, если оператор не был на странице в момент сбоя. Тот же столбец обслуживает иblocked, иfailed.blocked— при403 quota|ipили отсутствии валидного ключа PartsIndex. Задание можно возобновить (running) — обработка продолжится с необработанных строк (идемпотентно).- Переход в
expired— фоновой чисткой (§9.4), сами данные строк могут сохраняться дольше файла (конфигурируемо).
7.2. Статус строки (JobItem.status)
| Статус | Значение |
|---|---|
queued | ещё не обрабатывалась |
processing | обрабатывается воркером (для восстановления после сбоя) |
ok | заполнены все целевые поля (размеры, вес, применимость, кроссы по наличию) |
partial | есть данные, но не хватает части размеров/веса |
not_found | артикул/бренд не найден в базе (или 404) |
error | ошибка запроса по этой строке (после исчерпания ретраев) |
processing при рестарте воркера сбрасывается в queued (см. §9.3, восстановление). error-строки можно точечно перезапустить (POST /jobs/{id}/retry?items=…).
8. События и прогресс
8.1. Модель доставки
Прогресс — через SSE GET /jobs/{id}/events (поток) с деградацией на polling GET /jobs/{id} (если SSE недоступен). События также складываются в таблицу job_events (аудит + возможность «доиграть» пропущенные после reconnect по Last-Event-ID).
8.2. Типы событий (SSE event: + JSON data:)
| Событие | Когда | Payload (data) |
|---|---|---|
job.created | задание создано | {jobId, total, status} |
job.started | воркер начал | {jobId, startedAt} |
item.started | строка взята в работу | {jobId, rowNo, code} |
item.done | строка завершена | {jobId, rowNo, code, status, fields} |
progress | периодически (throttle, напр. раз в 500 мс) | {jobId, done, total, ok, partial, notFound, error} |
job.blocked | квота/IP/нет ключа | {jobId, reason, retryAfter?} |
job.completed | все строки готовы | {jobId, done, total, summary, xlsxReady:bool} |
job.failed | фатальная ошибка | {jobId, error} |
job.canceled | отменено | {jobId, at} |
xlsx.ready | Excel сгенерирован и доступен | {jobId, url, size} |
- Каждое SSE-сообщение имеет
id:= монотонныйjob_events.seq(дляLast-Event-ID). progressтроттлится, чтобы не спамить при больших списках;item.doneотправляется по каждой строке (можно батчить по 10 при очень больших заданиях).- Keepalive-комментарий (
: ping) раз в 15 с, чтобы прокси не рвал поток.
8.3. Аудит-события
Отдельно от прогресса пишется журнал действий пользователей (для админа): login, job_create, job_cancel, job_retry, xlsx_download, param_alias_change, user_create/disable. Хранятся в audit_log (§9.1).
9. Хранение
9.1. Схема PostgreSQL
-- пользователи
users(
id bigserial PK,
email citext UNIQUE NOT NULL,
password_hash text NOT NULL, -- argon2
role text NOT NULL DEFAULT 'operator', -- operator|admin
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)
-- задания
jobs(
id bigserial PK,
user_id bigint NOT NULL REFERENCES users(id),
status text NOT NULL DEFAULT 'pending', -- §7.1
brand_policy text NOT NULL DEFAULT 'first', -- first|operator|all
total int NOT NULL DEFAULT 0,
done int NOT NULL DEFAULT 0,
ok int NOT NULL DEFAULT 0,
partial int NOT NULL DEFAULT 0,
not_found int NOT NULL DEFAULT 0,
failed int NOT NULL DEFAULT 0,
block_reason text, -- код из ERROR_CATALOG: quota|ip|no_key|...
-- (при status=blocked) или INTERNAL_ERROR
-- (при status=failed, необработанное исключение).
-- Человекочитаемый текст для UI не хранится —
-- вычисляется на лету error_user_message(code)
-- (app/core/errors.py) в JobSummary.block_reason_message,
-- показывается и в /history, и на странице задания.
xlsx_path text, -- путь к файлу, NULL пока не готов
xlsx_size bigint,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
expires_at timestamptz -- для retention
)
CREATE INDEX ON jobs(user_id, created_at DESC);
CREATE INDEX ON jobs(status);
-- строки задания
job_items(
id bigserial PK,
job_id bigint NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
row_no int NOT NULL, -- порядок как ввёл оператор (столбец A)
code text NOT NULL,
brand text, -- заданный/выбранный
status text NOT NULL DEFAULT 'queued', -- §7.2
width numeric,
length numeric,
height numeric,
weight numeric,
part_name text,
barcode text,
applicability text, -- готовая строка для столбца F
crosses text, -- готовая строка для столбца G
note text, -- пояснение при not_found/error/partial
raw_json jsonb, -- полные ответы entities/relations/cars
attempts smallint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(job_id, row_no)
)
CREATE INDEX ON job_items(job_id, status);
-- дедуп внутри задания по (code, brand) — на уровне логики; повторы ссылаются на кэш
-- журнал событий прогресса (для SSE replay и истории)
job_events(
id bigserial PK,
job_id bigint NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
seq bigint NOT NULL, -- монотонно в рамках job, = SSE id
type text NOT NULL, -- §8.2
data jsonb NOT NULL,
at timestamptz NOT NULL DEFAULT now(),
UNIQUE(job_id, seq)
)
CREATE INDEX ON job_events(job_id, seq);
-- справочник синонимов параметров размеров/веса
param_aliases(
id bigserial PK,
canonical text NOT NULL, -- width|length|height|weight
pattern text NOT NULL, -- строка или regex для params[].title
is_regex boolean NOT NULL DEFAULT false,
priority int NOT NULL DEFAULT 100, -- меньше = выше приоритет
enabled boolean NOT NULL DEFAULT true
)
-- учёт расхода лимитов API
api_usage(
id bigserial PK,
day date NOT NULL,
endpoint text NOT NULL, -- entities|relations|cars|brands
count int NOT NULL DEFAULT 0,
UNIQUE(day, endpoint)
)
-- аудит действий пользователей
audit_log(
id bigserial PK,
user_id bigint REFERENCES users(id),
action text NOT NULL, -- §8.3
target text, -- напр. job:123
meta jsonb,
at timestamptz NOT NULL DEFAULT now()
)
CREATE INDEX ON audit_log(user_id, at DESC);9.2. Хранение файлов Excel
- Файлы
.xlsx— на диск в volumestorage/jobs/{job_id}/result.xlsx(не в БД). Путь и размер — вjobs.xlsx_path/xlsx_size. - Генерация — по завершении задания (или по требованию
GET /jobs/{id}/xlsxс ленивой генерацией и кэшированием файла). Событиеxlsx.readyпри готовности. - Скачивание — через
StreamingResponse/FileResponse, имяmatching_{job_id}_{YYYYMMDD}.xlsx.
9.3. Кэш и восстановление (Redis)
- Кэш ответов API: ключ
pi:{endpoint}:{code}:{brand}:{lang}, значение — JSON ответа, TTL:entities/relations/cars/brands— 24 ч (конфиг). Кэш снижает расход квоты на повторных артикулах между заданиями. - Дедупликация внутри задания: одинаковые
(code, brand)обрабатываются один раз, результат копируется во все строки-дубли. - Учёт квоты: счётчик
usage:{day}:{endpoint}(INCR) + зеркалирование вapi_usage. Семафор параллелизма —settings.API_CONCURRENCY(напр. 5). - Восстановление после сбоя воркера: при старте воркер сбрасывает
job_itemsвprocessing→queuedдля незавершённыхrunning-заданий и продолжает (идемпотентно, т.к. результат по строке перезаписывается).
9.4. Retention
jobs/job_items/job_eventsи файлы.xlsxживутsettings.JOB_TTL_DAYS(по умолчанию 30). Фоновая задача переводит истёкшие вexpiredи удаляет файл.audit_log/api_usage— дольше (напр. 365 дней) для отчётности.
10. API нашего сервиса (FastAPI)
| Метод | Путь | Назначение |
|---|---|---|
| POST | /auth/login | JWT-логин |
| GET | /auth/me | профиль |
| POST | /jobs | создать задание (список артикулов + опц. бренды + brand_policy) |
| GET | /jobs | история заданий пользователя (пагинация, фильтр по статусу) |
| GET | /jobs/{id} | статус + прогресс (polling) |
| GET | /jobs/{id}/events | SSE-поток событий (§8) |
| GET | /jobs/{id}/rows | результат (таблица, пагинация, фильтр по статусу строки) |
| POST | /jobs/{id}/cancel | отменить задание |
| POST | /jobs/{id}/retry | возобновить blocked/failed или перезапустить error-строки |
| GET | /jobs/{id}/xlsx | скачать Excel |
| GET | /status | живой статус ключей источников + расход за сегодня (любой авторизованный) |
| GET | /admin/users · POST · PATCH | управление пользователями (admin) |
| GET | /admin/usage | расход лимитов API по дням/эндпоинтам (admin) |
| GET | /admin/audit | журнал действий (admin) |
| GET/PUT | /admin/param-aliases | справочник маппинга параметров (admin) |
| GET/PUT | /admin/api-keys[/{source}] | статус/маскированный ключ, правка (admin) |
| POST | /admin/api-keys/{source}/check | живая проверка ключа у поставщика (admin) |
11. Обработка ошибок и краевые случаи
| Ситуация | Реакция | Итоговый статус |
|---|---|---|
| Артикул/бренд не найден (пусто или 404) | поля пустые, note с пояснением | строка not_found |
| Найден без части размеров/веса | заполняем что есть | строка partial |
| Таймаут / 5xx по строке | ретрай с backoff (до attempts_max, напр. 3) | error после исчерпания |
| Несколько брендов на артикул | по brand_policy: operator/first/all | по данным |
403 quota / 403/1003 ip / нет ключа | задание blocked, block_reason, алерт админу; строки не теряются | Job blocked |
| Битый входной файл/формат | задание не стартует | Job failed |
Необработанное исключение в воркере (process_job) | runner.py ловит на верхнем уровне, ставит block_reason=INTERNAL_ERROR, чтобы не зависало в running | Job failed |
| Отмена оператором | остановка, частичный результат доступен | Job canceled |
- Идемпотентность: повтор задания/строки перезаписывает результат; дедуп по
(code, brand)и Redis-кэш не дают бить квоту дважды. - Лимиты входа: макс. строк на задание (
settings.MAX_ROWS, напр. 5000), валидация формата артикула (длина 3–250 по спецификации PartsIndex), нормализация (trim, upper-case для сравнения/дедупа).
12. Нефункциональные требования
- Производительность: обработка ограничена квотой и
API_CONCURRENCY; при холодном кэше ~N строк × 3 запроса. Прогресс виден вживую. - Отказоустойчивость: воркер переживает рестарт (§9.3);
blocked-задания возобновляемы; данные строк персистятся до retention. - Наблюдаемость: структурные логи (
structlog), метрики расхода квоты (/admin/usage), аудит действий (audit_log). - Локализация данных:
Accept-Language/lang=ruпо умолчанию (конфиг).
13. Безопасность
- JWT (argon2 для паролей), роли (
operator/admin) черезDepends(). - Ключ PartsIndex — только в
.env/секретах, не в БД, не в ответах API, не в логах. - Ответы — только Pydantic-схемы, ORM наружу не отдаём.
- CORS для домена админки; rate-limit на login; аудит критичных действий.
- Оператор видит только свои задания; админ — все + управление.
14. Деплой
- Docker Compose:
api(uvicorn),worker,postgres,redis,frontend(nginx). - Публикация фронта — через
E:\Clients\Windows\proxy(Caddy) на отдельном домене приложения (документация уже наparts.rosveb.ru). - FastAPI за reverse proxy:
--proxy-headers --forwarded-allow-ips='*'. - Whitelisted-IP сервера согласовать с Tradesoft под ключ PartsIndex.
- Alembic-миграции; сиды: дефолтный admin, стартовый
param_aliases.
15. Этапы разработки
- Каркас: FastAPI + БД (миграции) + auth (JWT) + Docker Compose.
- PartsIndex-клиент + кэш на моках ответов (пока нет боевого ключа/IP).
- Обогащение + воркер + статусы (§5, §7), учёт квоты, дедуп.
- События/SSE + прогресс (§8), история заданий.
- Excel-экспорт (§4, §9.2), matcher +
param_aliases. - Админка: пользователи, usage, audit, param-aliases.
- Фронтенд (Vue 3): ввод списка, прогресс, таблица, скачивание, история.
- Интеграция с боевым ключом, наполнение
param_aliasesпо реальным данным.
16. Открытые пункты (не блокируют старт)
- Получить рабочий ключ PartsIndex (scope
access,info,relations,old-apply) и внести IP сервера в whitelist. До этого — разработка на моках ответов. - Уточнить точный перечень
titleпараметров размеров/веса по группам деталей (наполнитьparam_aliases). - Политика при множественных брендах на артикул (
brand_policyпо умолчанию). - Нужен ли multi-бренд-режим
relations(аналоги других брендов) в столбце G. - Значения retention (
JOB_TTL_DAYS) и лимитов (MAX_ROWS,API_CONCURRENCY).