Меня зовут Николай, я делаю InventoryMod - систему управления складом для небольшого бизнеса и интернет-магазинов. В этой статье я расскажу и про продукт (для тех, кому реально нужен складской учёт), и про то, как это устроено внутри - потому что архитектура получилась любопытная: одна кодовая база на TypeScript раскатывается сразу на четыре платформы, а данные по умолчанию живут локально в SQLite прямо у пользователя.

Статья длинная, поэтому сразу оглавление:

  • какую боль решает система управления складом и кому она нужна;

  • архитектура: монорепозиторий, общий доменный слой, один API-контракт на все платформы;

  • почему локальный SQLite-WASM вместо «облака по умолчанию»;

  • две интересные фичи под капотом: «виртуальный склад» поставщика и импорт из парсинга магазинов;

  • что было больно (грабли Capacitor, транзакции, миграции).

Проблема: складской учёт «на коленке» ломается на сотне SKU

Почти любой продавец начинает с таблицы в Excel. Пока позиций десятки - это работает. Но как только SKU становится сотни, а заказы идут каждый день, таблица начинает врать: продали то, чего нет; забыли дозаказать то, что кончилось; невозможно быстро ответить, сколько денег заморожено в остатках и какая ожидается маржа.

Управление складом - это не «список товаров», а связка процессов: каталог, приход от поставщиков, отгрузка клиентам, резервирование под заказы, перемещения между складами, инвентаризация, списания/возвраты и отчёты. Как только эти операции начинают вести историю движений, а не просто перезаписывать остаток - учёт перестаёт врать.

InventoryMod закрывает именно это:

  • каталог товаров: SKU, штрихкод, категория, производитель, цена, валюта, фото, минимальный остаток;

  • несколько складов и балансы остатков по каждому;

  • движения: приход, отгрузка, перемещение, списание, возврат, корректировка - с полной историей и undo;

  • заказы клиентов с резервом и отгрузкой;

  • поставщики, заказы на закупку с закупочной ценой и приёмкой;

  • инвентаризация и сверка;

  • отчёты: стоимость запасов, прогресс закупок, оценочная стоимость продажи, ожидаемая маржа;

  • импорт/экспорт CSV и XLSX, резервные копии, бэкап в Google Drive;

  • сканирование штрихкодов и печать этикеток;

  • интерфейс на русском, английском и испанском.

Ключевое отличие от типовых SaaS: данные по умолчанию хранятся локально у пользователя. Для базовой работы не нужен аккаунт, сервер и подписка - открыл и работаешь. Это же и продуктовый аргумент (приватность, ноль вендор-лока), и техническое решение, вокруг которого выстроена вся архитектура.

Архитектура: один src, четыре платформы

Проект - это npm workspaces монорепозиторий. Стек: React 19, Vite 6, Tailwind CSS v4, Drizzle ORM (SQLite), TypeScript 5.8, Express для серверной части. Порядка 129 TS-файлов, разложенных так:

inventorymod/
 packages/               переиспользуемые библиотеки
    shared/             доменные модели + бизнес-логика импорта
    database/           Drizzle-схема, queries, API-фасад, адаптеры
    ui/                 React-компоненты, страницы, хуки, i18n
 apps/                   приложения-потребители
    web-saas/           SaaS-версия (auth, мультитенантность)
    backend-node/       Express (JWT, RPC, API keys)
    chrome-extension/   расширение Chrome (SQLite-WASM)
    mobile-capacitor/   мобильная версия (Capacitor WebView)
 docker/                 Docker Compose + Caddy + supervisord

Идея простая: вся предметная область (17 доменных интерфейсов - товары, склады, движения, заказы, поставщики, валюты, настройки, импорт) и бизнес-логика лежат в packages/ и не знают ничего о платформе. Приложения в apps/ - тонкие обёртки, которые подставляют нужную реализацию хранилища.

Связывает всё единый API-контракт InventoryAPI. У него две реализации:

  • LocalSQLiteAdapter - прямые Drizzle-запросы к локальной SQLite (расширение и мобилка);

  • RemoteSaaSAdapter - те же методы, но поверх HTTP-RPC через fetch (веб-SaaS с бэкендом).

UI и доменные сервисы вызывают InventoryAPI и не знают, локальная это база или сеть. Это и есть то, что позволяет «написать один раз - запустить везде»: расширение автономно на SQLite-WASM, мобилка - Capacitor-WebView с той же сборкой, веб - тот же UI, но через сетевой адаптер к Express-бэкенду.

Почему локальный SQLite-WASM, а не «облако по умолчанию»

Первую версию мы делали на IndexedDB через Dexie. Работает везде, но у него есть потолок: сложные выборки для отчётов (join'ы, агрегаты по движениям, маржа) на IndexedDB превращаются в ручную склейку в JS. Поэтому мы мигрировали на SQLite-WASM + Drizzle ORM: нормализованная схема (3NF, честные FK), настоящий SQL для отчётов, и при этом всё это по-прежнему выполняется прямо в браузере пользователя, без сервера.

Что это даёт продукту:

  • Приватность и офлайн. Данные не покидают устройство, пока пользователь сам не включит бэкап в Google Drive. Складской учёт работает без интернета.

  • Ноль инфраструктуры для старта. Не нужно поднимать сервер, чтобы получить полноценную систему управления складом.

  • Реальный SQL для отчётов. Стоимость запасов, ожидаемая маржа, прогресс закупок считаются запросами, а не циклами по массивам.

А когда бизнес дорастает до нескольких сотрудников - тот же UI через RemoteSaaSAdapter подключается к серверной версии с мультитенантностью. Мультиарендность, кстати, встроена в доменную модель с самого начала: у всех сущностей есть campaignId, а на SaaS каждый тенант - это отдельный SQLite-файл. То есть переход «локально команда» не требует переписывания доменного слоя.

Фича 1: «виртуальный склад» поставщика (dropship под заказ)

Это любимый сценарий магазинов, которые не хотят замораживать деньги в запасах. Идея: товар физически лежит у поставщика, вы продаёте его со своей витрины и закупаете только то, что реально продали.

В модели это решается тем, что склад - не обязательно физический. Мы заводим виртуальный склад поставщика, и дальше работает обычная механика движений и резервов, но цепочка выглядит так:

  1. клиент оформляет заказ на виртуальном складе создаётся резерв (товар нельзя пообещать дважды);

  2. формируется заказ на закупку поставщику с закупочной ценой и ожидаемой датой;

  3. приёмка закупки создаёт обычный приход на ваш реальный склад;

  4. отгрузка клиенту закрывает резерв.

Важно, что всё это - не отдельная «дропшип-подсистема», а те же примитивы движений (резерв закупка приёмка отгрузка). Поэтому отчёты по марже автоматически остаются корректными: по каждому заказу видно закупочную цену и выручку, а «в пути» и «доступно у поставщика» отражаются как обычные остатки. С точки зрения кода это оказалось важным архитектурным выбором - не плодить параллельные сущности, а переиспользовать движения.

Фича 2: импорт данных парсинга магазинов

Многие пользователи приходят с уже собранными данными - выгрузками каталогов и цен, полученными парсингом магазинов и маркетплейсов (например, через сервис CatalogLoader). Задача - превратить сырой CSV/XLSX в нормальный каталог с остатками за один проход.

Здесь у нас чистая, платформонезависимая логика импорта в packages/shared (парсинг через papaparse и xlsx, маппинг колонок, валидация). Пара габлей, на которых мы учились:

  • сообщения об ошибках изначально были зашиты по-русски прямо в shared-слое - пришлось переводить на коды ошибок (FIELD_REQUIRED, INVALID_NUMBER), чтобы shared оставался локаленезависимым, а перевод жил в UI;

  • парсер чисел заменял только первую запятую (String.replace без флага /g), и "1,234,56" превращался в NaN - классика;

  • импорт - это отдельная сущность ImportJob со своим статусом и логом, чтобы большие выгрузки не блокировали UI и были воспроизводимы.

Результат для пользователя: собрал каталог парсингом загрузил файл получил сотни товаров с ценами и характеристиками без ручного ввода, дальше сравниваешь со своими ценами и обновляешь прайс. И поскольку экспорт тоже CSV/XLSX - данные легко уходят обратно на витрину или в 1С/бухгалтерию.

Грабли, о которых честно

Раз уж это Хабр - вот что реально болело:

  • Транзакции. При переходе на SQLite пришлось аккуратно оборачивать многошаговые операции (резерв+движение+обновление остатка) в транзакции, иначе при сбое получаешь рассинхрон остатка и истории. На IndexedDB это вообще было отдельной болью с переопределением транзакционного контекста.

  • Capacitor-мелочи. z-index модалок поверх нативного WebView, подпись APK, баг с алиасом колонки, возвращавшим -1 - набор мелких, но неочевидных вещей мобильной сборки.

  • Типизация домена. Повсеместный id?: number заставлял проверять на null даже после чтения из БД; правильнее паттерн Persisted<T> (id обязателен) против New<T> (без id). Статусные поля лучше сразу делать union-литералами ('new'|'reserved'|'shipped'), а не голым string.

Ничего экзотического, но именно на таких мелочах и держится доверие к складской системе: если остаток «поехал» хоть раз, пользователь перестаёт ей верить.

Итог

Получилась система управления складом, которую можно запустить тремя способами - расширение Chrome, веб-сервис, мобильное - из одной кодовой базы, с локальным SQLite по умолчанию и опциональным облаком, когда бизнес дорастает до команды. Для разработчиков интересна связка «общий доменный слой + один API-контракт + два адаптера хранилища». Для бизнеса - то, что складской учёт работает офлайн, приватно и без обязательной подписки, а сложные сценарии вроде виртуального склада поставщика и импорта из парсинга магазинов уже встроены.

Если вам нужен именно инструмент, а не архитектура - посмотрите InventoryMod (русская версия): начать можно бесплатно и без настройки. А если интересна внутрянка - задавайте вопросы в комментариях, отвечу подробно про адаптеры, миграцию с Dexie на SQLite-WASM и мультитенантность на SQLite-файлах.

Комментарии (0)