Меня зовут Николай, я делаю 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 под заказ)
Это любимый сценарий магазинов, которые не хотят замораживать деньги в запасах. Идея: товар физически лежит у поставщика, вы продаёте его со своей витрины и закупаете только то, что реально продали.
В модели это решается тем, что склад - не обязательно физический. Мы заводим виртуальный склад поставщика, и дальше работает обычная механика движений и резервов, но цепочка выглядит так:
клиент оформляет заказ на виртуальном складе создаётся резерв (товар нельзя пообещать дважды);
формируется заказ на закупку поставщику с закупочной ценой и ожидаемой датой;
приёмка закупки создаёт обычный приход на ваш реальный склад;
отгрузка клиенту закрывает резерв.
Важно, что всё это - не отдельная «дропшип-подсистема», а те же примитивы движений (резерв закупка приёмка отгрузка). Поэтому отчёты по марже автоматически остаются корректными: по каждому заказу видно закупочную цену и выручку, а «в пути» и «доступно у поставщика» отражаются как обычные остатки. С точки зрения кода это оказалось важным архитектурным выбором - не плодить параллельные сущности, а переиспользовать движения.
Фича 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-файлах.