Каждое устройство, получающее в Revamp-IT вторую жизнь, сначала должно попасть в систему. Звучит банально. Банальным это не было. Именно этот неприметный шаг — приёмка — годами отнимал у мастерской больше всего времени, и именно здесь меньше всего добавленной ценности: ноутбук не становится лучше оттого, что кто-то вручную набирает его характеристики. Это история о том, как мы превратили пятнадцать минут ручной работы в приёмку, занимающую секунды, — и что за этим стоит технически.
Проблема: приёмка была повинностью
В начале была таблица. Чтобы завести устройство, нужно было по порядку сделать следующее: взвесить его, снять габариты рулеткой, сделать фотографию, а затем нагуглить характеристики и правдоподобную цену — номер модели, CPU, RAM, продажную цену, всё это вручную набивалось в личную таблицу (по большей части копипастом).
Затем начиналась цепочка передач. Хайнц сводил отдельные листы в одну сводную таблицу и экспортировал её в CSV. Джем загружал этот CSV в Kivitendo, нашу ERP на Perl и юридически значимую учётную книгу. И даже тогда устройство нигде не было видно: в магазине оно не появлялось автоматически — это был ещё один отдельный ручной шаг.
От пяти до пятнадцати минут чистой ручной работы — на каждое устройство, распределённой между несколькими людьми. Итог был предсказуем: приёмкой должна была заниматься вся команда, но занимались немногие. Не из злого умысла, а потому что барьер был слишком высок. Процесс, который никому не в радость, становится узким местом — а на складе растёт гора незаведённых устройств.
Идея: свести время приёмки практически к нулю
Цель была намеренно радикальной: сократить время на заведение товара в систему на 99,9 %. Не «чуть быстрее» — на другой порядок величины.
Логика такая: фотография устройства уже содержит почти всё, что нам нужно знать, — марку, модель, зачастую даже состояние. Написанное название («Lenovo ThinkPad T450 i5») — тоже. С помощью ИИ этот сырой материал превращается в структурированные поля, а благодаря тщательно выстроенным системам, которые общаются друг с другом по API, должно хватать одного жеста: сфотографировать или написать — и устройство оказывается там, где ему место. Привязанным к месту хранения в базе данных или сразу опубликованным как объявление в магазине.
Как это работает сегодня
Одна приёмка, четыре канала
Geräte-Eingang (приёмка устройств, erfassung в коде) имеет намеренно простой интерфейс с четырьмя входными каналами: текст, фото, файл (CSV/Excel) и голос. За этим стоит важное проектное решение — это каналы, а не четыре отдельных рабочих процесса. Как бы данные ни поступали, они сходятся в одной и той же записи товара.
Текстовый канал устроен хитро: он автоматически определяет, является ли строка одним устройством или целым списком. Одна строка идёт на /api/admin/erfassung/text, несколько строк — на /api/admin/erfassung/bulk-text — так что можно вставить сразу целый поддон устройств и получить обратно таблицу пакетной проверки. Файлы CSV и Excel проходят через bulk-upload, голос — через voice.
Каскад ИИ
Сердце находится в src/lib/erfassung/ai-extraction.ts. extractProductFromText пропускает текст через резервный каскад из трёх провайдеров (callWithFallback): сначала Groq (llama-3.3-70b-versatile), затем OpenRouter, затем локальный Ollama. Если всё отказывает, последней сетью служит regex-парсер (fastParseProductText) — приёмка никогда не падает наглухо, она просто становится менее точной.
Для фотографий эстафету принимает extractProductFromImage. Тут стоит рассказать небольшую историю из машинного отделения: Groq вывел из эксплуатации свою прежнюю vision-модель (Llama 4 Scout) — запросы внезапно стали возвращаться с 404 model_not_found, и анализ фотографий в продакшене умер. Сегодняшняя замена — qwen/qwen3.6-27b, единственная доступная на данный момент модель Groq, работающая с изображениями. Но Qwen3 — это модель с рассуждением: перед ответом она размышляет вслух в блоке <think>…</think>. Наивный парсер по принципу «взять первый {…}» тут же выуживал пример JSON из этого блока размышлений и падал. Исправление — это маленькая, невзрачная функция extractJsonObject, которая убирает блоки рассуждений и json-ограждения кода, прежде чем JSON будет прочитан. Голос, в свою очередь, транскрибируется через whisper-large-v3-turbo от Groq и затем проходит через то же текстовое извлечение.
Две вещи делают результат пригодным к использованию, а не просто впечатляющим. Во-первых, уверенность по каждому полю: каждое извлечённое поле несёт степень достоверности; форма проверки подсвечивает только те поля, которые действительно требуют второго взгляда (например, состояние, о котором текст ничего не сказал), вместо того чтобы обвешивать каждое значение процентом. Во-вторых, категоризация: detectCategory — это упорядоченная таблица шаблонов, отображаемая на существующие коды категорий. Порядок неслучаен — шаблоны аксессуаров, принтеров, мониторов и сети срабатывают раньше, чем бренды ноутбуков, а внутренние компоненты — последними, так что название устройства всегда побеждает. Благодаря этому «Dockingstation Lenovo ThinkPad» корректно относится к Сети, а не к ноутбукам.
В интерфейсе это выглядит так: вы набираете фразу — и через секунды появляется заполненная форма: марка, модель, развёрнутое краткое описание, категория. Ровно одно поле здесь несёт маленькую оранжевую подсказку «Prüfen» (проверить): состояние, потому что текст его не называл. Всё остальное молчит. В этом и суть — ИИ не кричит «уверен на 95 %» в каждой строке, он тихо указывает на то единственное, что человеку стоит подтвердить.
Единый источник правды для записи
Как бы ни различались каналы, запись происходит ровно в одном месте: createErfassungProduct() в src/lib/erfassung/create-product.ts. Эта функция — единый источник правды для «устройство появляется на свет». В одной транзакции она присваивает читаемый человеком номер позиции (I-YYMMDD-NNNN), записывает запись извлечения (ai_extracted_products), создаёт складскую запись (inventory_items, с местом, коробкой и количеством), связывает профили клиентов, загружает изображение в R2 (объектное хранилище) и связывает его — и опционально сразу же публикует объявление.
Поскольку всё проходит через эту одну функцию, повсюду действуют одни и те же инварианты. Именно поэтому мы позже смогли за один проход мигрировать 197 товаров из старого магазина Shopware (подробнее ниже): импорт вызывает именно createErfassungProduct, а не изобретает второй, слегка отличающийся способ записи.
Контроль качества
После проверки наступает единственное настоящее операционное решение: куда дальше? CAPTURE_DESTINATIONS — качество, склад, запчасти, переработка или «магазин без проверки» — отображаются на ступени приёмки. Устройство категории, требующей проверки, которое вы хотите опубликовать напрямую, перехватывается защитным барьером и попадает вместо этого как черновик в конвейер восстановления с чек-листом QC — если только кто-то не примет явно зафиксированное решение «опубликовать без проверки». Требует ли категория проверки — ведётся не отдельно, а выводится из самого чек-листа: «требует проверки» означает, что у него есть обязательный тестовый или проверочный пункт безопасности для этого класса устройств.
На маркетплейс
Когда устройство публикуется, publishRevampitListing превращает его в активное объявление (флаг is_revampit), переносит изображение из R2 в изображения объявления и индексирует запись в Meilisearch для поиска. Переход от «заведено» к «видно в магазине» — это, таким образом, вызов API, а не второй человек со второй формой.
Стресс-тест: 197 товаров из старого магазина
Лучшим подтверждением того, что процесс держится, стала миграция каталога. У старого магазина Shopware не было пригодного API — зато была чистая метаинформация Open Graph на каждой странице товара. Небольшой скрапер прошёл по списку /Alles/, вытащил название, бренд, цену, описание и URL изображения, а одноразовый эндпоинт миграции создал каждый из 197 товаров как черновик — через createErfassungProduct, с изображением, скачанным на стороне сервера и перезалитым в R2. Категории выводились через detectCategory, дубликаты предотвращались через сохранённый номер Shopware (идемпотентно, повторяемо сколько угодно). То, что вручную заняло бы дни, стало делом минут.
И поскольку миграция шла через ту же функцию, что и любая одиночная приёмка, черновики можно было затем опубликовать вторым проходом — каждый становится активным объявлением, а изображение из R2 автоматически едет вместе с ним. Из 11 видимых объявлений стало 208, каждое с изображением, ценой и категорией. Старый магазин не просто перепечатали, его перенесли.
Три принципа, на которых всё держится
Прежде чем перейти к соседним системам, стоит взглянуть на три решения, которые определяют разницу между «работает в демо» и «держится в продакшене» — и которые повторяются по всему коду.
Единый источник правды для записи. Будь то фото, голос, CSV, одиночная приёмка или пакетная миграция: запись происходит исключительно через createErfassungProduct. Было бы тысяча соблазнов построить «быстрый второй, слегка иной путь» для миграции. Именно этого мы не сделали — и именно поэтому номера позиций, обработка изображений, барьер QC и складские инварианты одинаково применяются к каждому пути. Ошибка исправляется в одном месте, а не в пяти.
Уверенность, а не проценты. ИИ выдаёт уверенность для каждого поля — но на экране нет процентов. Он показывает подсказку «проверить» только там, где уверенность падает ниже порога. Число вроде «73 %» — не инструкция для человека за верстаком; «взгляни сюда ещё раз» — инструкция. Хорошая автоматизация сокращает число решений, а не умножает его.
Идемпотентность везде, где что-то уходит наружу. Каждая синхронизация с Kivvi несёт ключ идемпотентности; каждая мигрированная запись несёт свой номер Shopware; каждый запуск публикации пропускает то, у чего уже есть объявление. Звучит как мелочь, но именно это причина, по которой мы можем повторять миграции, синхронизации и переиндексацию сколько угодно, без дубликатов и без страха. Повторяемость — не роскошь, это предпосылка для того, чтобы можно было чинить систему на ходу.
Системы, которые общаются друг с другом
Приёмка — лишь половина дела. Устройство ещё должно попасть туда, где живут склад и учёт. И вот тут становится архитектурно интересно, потому что от этого зависят два совершенно разных мира.
Kivvi: чистая мембрана
Kivvi — это современная швейцарская облачная ERP (TypeScript, Drizzle/Postgres), в которую мы синхронизируем устройства. Она облегчает жизнь, потому что предлагает ровно то, что нужно интеграционному партнёру: версионированный REST API под /api/v1/. Наш syncToKivvi (src/lib/kivvi/client.ts) делает POST /api/v1/inventory-items с bearer-токеном (kv_…), хранящимся на сервере в виде SHA-256-хеша.
Здесь решающими являются три свойства — и в коде Kivvi они даже названы в честь Revamp-IT:
- Идемпотентность. Вызов несёт
Idempotency-Key; повторный пуш не создаёт дубликата. Именно поэтому мы можем повторять попытки без опасений. - Неблокирующе, после коммита. Синхронизация работает по принципу «отправил и забыл»: она стартует после транзакции базы данных приёмки и никогда не блокирует само заведение. Затем мы записываем
kivvi_inventory_item_idиkivvi_sync_statusобратно в складскую запись. Если Kivvi не сконфигурирован (нетKIVVI_API_URL), клиент аккуратно возвращает{ success: false }вместо того, чтобы бросать исключение — в dev-среде синхронизации просто нет. - Двунаправленность. Kivvi присылает подписанные webhook'и обратно (
inventory_item.status_changedи т. д.). Когда устройство там продаётся, мы узнаём об этом — без опроса.
Маленький, но важный шаг перевода: словарь состояний RevampIT отображается на enum Kivvi (new → like_new, defect → parts_only, неизвестное → untested). Без этого отображения валидация Kivvi отклоняет запись с HTTP 400. Маленькие контракты, соблюдаемые чётко.
Kivitendo: переводчик, а не второй мозг
Другой сосед — Kivitendo, ERP на Perl в стиле MVC, наша юридически значимая учётная книга, которую мы намеренно сохраняем. Загвоздка: у Kivitendo нет API. Его «интерфейс» — это View, HTML-формы для людей, а контроллер связан с этими формами. Каждый запрос — это POST плоских полей формы на controller.pl?action=Part/save, которые Kivitendo пересобирает в одну глобальную структуру $::form.
Запись там следует шаблону загрузить → наложить → сохранить, всегда на целом объекте. У этого есть коварное следствие: скаляры сохраняются при пропуске — но коллекции (цены, поставщики) работают по принципу «удалить и заменить». Отправьте подмножество — и потеряете остальное. Так что нельзя «просто изменить одно поле», не загрузив сперва полное состояние.
Как подключить к этому современную систему? Не пересобирая логику Kivitendo, а тонким слоем-переводчиком — сервисом на Node, имитирующим браузер. Его единственный поток: receive → load (SELECT) → map inward → merge → send as a $::form POST → map outward → return. Слой сам никогда не пишет SQL; запись происходит исключительно через собственный контроллер Kivitendo, так что его валидация, история и транзакция остаются ровно в одном месте. Ведущий принцип: бизнес-логика живёт в Kivitendo — мы переводчик, а не второй мозг.
Изящная часть: нужные отображения по каждой сущности (как внутри называется то или иное внешнее поле, какие ключи форм, какие пользовательские переменные) генерируются небольшими локальными LLM — извлекаются из Perl-ORM и контроллеров Kivitendo и проверяются круговым проходом против реального, захваченного POST'а формы. Отображение верно тогда и только тогда, когда оно воспроизводит POST, который Kivitendo принял, параметр за параметром. Ничего не угадывается. Этот кусок пока экспериментальный (сущность Part стоит; против живого инстанса она ещё не закалена), но путь ясен: чистый, версионированный контракт снаружи, неизменная правда Kivitendo внутри.
Честная оговорка: многое из этого выведено из нескольких захватов и чтения исходников, а не наблюдалось в контролируемых условиях. Наименее подтверждённое — как раз самое важное: как Kivitendo сигнализирует об успехе против неудачи (редирект с &id=… против ответа 200 с телом ошибки). Это стоит сперва проверить против работающего инстанса. Честная архитектура называет свои открытые допущения.
Недостающее звено: хранение и логистика
А вот и часть, которая ещё не готова, — названа намеренно, потому что это запланированная работа, которая должна завершить продукт.
Сегодня приёмка устройств — это, по сути, поштучный реестр с чек-листом QC и указателем «где оно». Есть аккуратная таблица storage_locations (название, вид: основной склад / магазин / вторичный склад / во владении участника / …), а складская запись несёт storage_location_id, свободный box_id и легаси-поле location. Это отвечает на вопрос: «на какой полке лежит именно это устройство?»
Чего не хватает — это всего, что за этим стоит, и это, честно говоря, и есть настоящее управление складом:
- Нет журнала движений товара. Счётчики
quantity_reserved/quantity_soldсуществуют как столбцы, но никуда не записываются. Нет проводок прихода/расхода, нет истории движений. - Нет мультисклада, нет перемещений. Плоский список мест, без иерархии, без остатков по складам.
- Нет комплектации, нет приёмки товара, нет пополнения. Короче: нет управления складом, есть лишь «где что лежит».
Хорошая новость: точка стыковки уже существует. Kivvi приносит ровно те складские примитивы, которых нам не хватает — warehouses, stockLevels (остатки по товару и складу) и журнал stockMovements только на добавление со знаковыми количествами. Правда, на учётной, а не операционной гранулярности: Kivvi знает склады как название и адрес, но не знает ячеек, маршрутов комплектации, перевозчика. Поэтому у будущего складского модуля RevampIT есть два чистых варианта — либо напрямую управлять warehouseId + location в Kivvi, либо смоделировать операционный слой (ячейки, движения, комплектация) самому и держать Kivvi как учётную книгу остатков за ним. Благодаря двунаправленным webhook'ам обе стороны остаются синхронизированными.
А Kivitendo? В принципе, склад можно было бы зеркалировать и там — через тот же слой-переводчик, набросанный выше. У Kivitendo есть концепция склада/остатков в его модели; движение товара стало бы тогда ещё одной сущностью, идущей тем же путём: загрузить, слить, отправить как $::form POST на нужный контроллер. Больший труд лежит не в концепции, а в аккуратности — остатки учётно значимы, а семантика Kivitendo «коллекции заменяются» требует всегда отправлять полное состояние. Для учётной книги ровно такая осторожность оправдана.
Что дальше
Приёмка решена: из фотографии или написанного названия за секунды появляется чистая, категоризированная, проиллюстрированная запись — размещённая на складе или опубликованная в магазине и синхронизированная с Kivvi. Барьер, из-за которого приёмкой почти никто не занимался, исчез.
Остаётся его физический двойник: знать, где находится каждое устройство, и аккуратно проводить каждое движение. Это следующий кусок — мост между нашим поштучным реестром и журналом остатков Kivvi, а где нужно — и вплоть до Kivitendo. Как только он встанет, круг замкнётся: от жеста, которым устройство заводится, до полки, с которой оно продаётся, — без того, чтобы кому-то приходилось вести таблицу посередине.
