Меня зовут Николай, я делаю 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-файлах.
ссылка на оригинал статьи https://habr.com/ru/articles/1077584/