Skip to content

ТЗ: Многопользовательская админка сопоставления артикулов

  • Дата: 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-каталоги). Это подтверждено разбором обеих спецификаций. Следствия для ТЗ:

  1. Нужен ключ PartsIndex со scope access, info, relations, old-apply. Тестовый OEM-ключ OEM-API-F1B2A560-… для этого не подходит (403 access deny).
  2. Ключ привязан к IP-whitelist — боевой сервер должен выходить в интернет с whitelisted-IP (иначе 403 errorCode 1003 "IP not allowed"). Учесть при деплое.
  3. Квоты по запросам ограничены — обязательны кэш и дедупликация артикулов.

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 Widthvalues[0].value
CДлинаentities → param Length
DВысотаentities → param Height
EВесentities → param Weight
FМарки и модели применимостиcars → нумерованный список, по строке на модель: N. brand model modif (годы)
GКросс-номера (аналоги)relationscode (+ 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 если нет бренда) → relationscars; собирает 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/нет ключаrunningrunning (retry), canceled
completedвсе строки обработаны (в т.ч. с not_found)runningexpired (по retention)
failedфатальная ошибка (не по строкам)runningrunning (retry), expired
canceledотменено оператором/админомpending, running, blockedexpired
expiredрезультат/файл удалён по retentioncompleted, 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.readyExcel сгенерирован и доступен{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

sql
-- пользователи
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 — на диск в volume storage/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 в processingqueued для незавершённых 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/loginJWT-логин
GET/auth/meпрофиль
POST/jobsсоздать задание (список артикулов + опц. бренды + brand_policy)
GET/jobsистория заданий пользователя (пагинация, фильтр по статусу)
GET/jobs/{id}статус + прогресс (polling)
GET/jobs/{id}/eventsSSE-поток событий (§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, чтобы не зависало в runningJob 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. Этапы разработки

  1. Каркас: FastAPI + БД (миграции) + auth (JWT) + Docker Compose.
  2. PartsIndex-клиент + кэш на моках ответов (пока нет боевого ключа/IP).
  3. Обогащение + воркер + статусы (§5, §7), учёт квоты, дедуп.
  4. События/SSE + прогресс (§8), история заданий.
  5. Excel-экспорт (§4, §9.2), matcher + param_aliases.
  6. Админка: пользователи, usage, audit, param-aliases.
  7. Фронтенд (Vue 3): ввод списка, прогресс, таблица, скачивание, история.
  8. Интеграция с боевым ключом, наполнение param_aliases по реальным данным.

16. Открытые пункты (не блокируют старт)

  1. Получить рабочий ключ PartsIndex (scope access,info,relations,old-apply) и внести IP сервера в whitelist. До этого — разработка на моках ответов.
  2. Уточнить точный перечень title параметров размеров/веса по группам деталей (наполнить param_aliases).
  3. Политика при множественных брендах на артикул (brand_policy по умолчанию).
  4. Нужен ли multi-бренд-режим relations (аналоги других брендов) в столбце G.
  5. Значения retention (JOB_TTL_DAYS) и лимитов (MAX_ROWS, API_CONCURRENCY).

Внутренняя документация проекта parts-catalogs.com