Складской учёт на трёх платформах без дублирования логики: расширение Chrome, мобилка и SaaS из одного кода

от автора

Складской учёт на трёх платформах без дублирования логики: расширение Chrome, мобилка и SaaS из одного кода

Я довольно давно вожусь с сервисами вокруг товарных данных: делаю price-matrix.ru (обработка прайсов поставщиков) и CatalogLoader (парсеры сайтов). InventoryMod вырос из той же темы, но с другого конца — не «собрать и разобрать данные», а «вести по ним складской учёт».

InventoryMod — учётная система для склада: товары, остатки, движения, заказы, закупки, инвентаризация, отчёты. Начал где-то в середине апреля 2026-го с расширения для Chrome с локальной базой (первый коммит в git датирован 19 мая — до него первая версия довольно долго собиралась «в столе»), потом добавились мобильное приложение и полноценный SaaS с сервером.

Главная проблема, с которой я жил всё это время, формулируется просто: как не писать три раза одну и ту же бизнес-логику, когда база данных в каждой среде своя. Где-то SQLite прямо в браузере, где-то удалённый сервер по HTTP.

Дальше разберу на реальном коде из репозитория, как из query-модулей выводится API-контракт, почему я в итоге слез с IndexedDB на SQLite-WASM и что творилось с транзакциями в резервировании остатков. Про сам продукт скажу ровно столько, сколько нужно для контекста.

Контекст: что вообще считает складская система

«Сколько чего и где лежит» — это только видимая часть. Под ней лежит история движений, а остаток из неё вычисляется. Приход, отгрузка, перемещение, списание, возврат, резерв под заказ, снятие резерва, приёмка закупки. Каждое событие пишется в stock_movements с полями «было/стало», и вместе с ним атомарно пересчитывается баланс. Стоит начать просто перезаписывать поле «остаток», как учёт однажды разойдётся с реальностью, и дальше пользователь перестаёт ему верить.

Отсюда растёт основная сложность: почти каждая операция состоит из нескольких шагов — проверить доступный остаток, обновить баланс, записать движение, иногда ещё тронуть резерв или заказ, — и выполнять её «наполовину» нельзя.

История движений склада: приход/резерв/отгрузка с полями было/стало

История движений склада: приход/резерв/отгрузка с полями было/стало

История движений. Каждое событие пишет «было/стало», а остаток из неё вычисляется.

Стек и структура

npm workspaces монорепозиторий, ~129 TS-файлов. React 19, Vite 6, Tailwind v4, Drizzle ORM поверх SQLite, TypeScript 5.8, Express на сервере.

packages/  shared/      -  доменные модели + чистая логика импорта (без БД и DOM)  database/    -  Drizzle-схема, queries/, api.ts, адаптеры Local/Remote  ui/          -  React-компоненты, страницы, хуки, i18napps/  chrome-extension/   -  расширение Chrome, автономно, SQLite-WASM  mobile-capacitor/   -  мобильное приложение, тот же build в Capacitor WebView  web-saas/           -  тот же UI, но данные через сеть  backend-node/       -  Express: JWT, RPC-эндпойнт, API-ключи

Правило простое: всё, что в packages/, ничего не знает про платформу, а приложения в apps/ — тонкие обёртки, которые подставляют реализацию хранилища. Как именно подставляют — ниже.

Один API-контракт, выведенный из реализации

Держать интерфейс InventoryAPI отдельно от реализации я пробовал недолго. Они разъезжаются примерно на второй неделе: где-то поменял сигнатуру, интерфейс забыл. Поэтому query-функции пишутся в форме (db, ...args), а контракт для UI выводится из них типами — у каждой функции просто снимается первый параметр db. UI получает сигнатуры без Drizzle и не может случайно дёрнуть базу напрямую.

// packages/database/api.ts// Снять первый параметр (db) у функции.type OmitDb<F> = F extends (db: any, ...rest: infer R) => infer Ret ? (...rest: R) => Ret : F;type BoundModule<M> = { [K in keyof M]: M[K] extends (...a: any) => any ? OmitDb<M[K]> : M[K] };export interface InventoryAPI {  products: BoundModule<typeof productQueries>;  stock:    BoundModule<typeof stockQueries>;  orders:   BoundModule<typeof orderQueries>;  import:   BoundModule<typeof importQueries>;  // ...ещё десяток модулей}// Рантайм-привязка: каждой функции пробрасываем конкретный db.function bindModule<M extends Record<string, any>>(mod: M, db: Db): BoundModule<M> {  const out: any = {};  for (const key of Object.keys(mod)) {    const val = mod[key];    out[key] = typeof val === 'function' ? (...args: any[]) => val(db, ...args) : val;  }  return out;}

Синхронизировать контракт с реализацией руками не приходится, потому что контракт — это и есть реализация с отрезанным db. Добавил функцию в stockQueries — она сама появилась в InventoryAPI, и TypeScript начинает требовать её в обеих средах.

Два адаптера: локальный SQLite и удалённый по HTTP

Локальный адаптер (расширение, мобилка) собирает API прямо поверх Drizzle-инстанса, тут и смотреть не на что:

// packages/database/adapters/LocalSQLiteAdapter.tsexport function createLocalAdapter(db: Db): InventoryAPI {  return createInventoryApi(db);}

Удалённый адаптер (SaaS) реализует тот же интерфейс, но каждый вызов уходит на один RPC-эндпойнт. Расписывать сотню методов вручную желания не было, поэтому там Proxy, который превращает обращение api.stock.reserve(...) в HTTP-запрос:

// packages/database/adapters/RemoteSaaSAdapter.tsconst moduleProxy = (module: string) =>  new Proxy({}, { get: (_t, method: string) => (...args: unknown[]) => call(module, method, args) });return new Proxy({}, { get: (_t, module: string) => moduleProxy(module) }) as unknown as InventoryAPI;

Раскладку аргументов по query-string и телу запроса берёт общий реестр маршрутов, тот же, что генерит серверные роуты и документацию, поэтому клиент и сервер не разъезжаются. UI вызывает api.orders.create(...) одинаково и вообще не знает, локальная под ним SQLite или сеть.

Минус у Proxy тоже есть, и он вылезает не сразу: по нему невозможно кликнуть «go to definition», а стек в консоли обрывается на анонимной функции внутри прокси. Пока модулей было пять, это не мешало; сейчас я иногда жалею, что не сделал кодогенерацию клиента из того же реестра маршрутов.

Почему я ушёл с IndexedDB на SQLite-WASM

Первая версия была расширением для Chrome и жила на IndexedDB через Dexie. На старте выбор казался очевидным: расширению не нужен сервер, IndexedDB есть в любом браузере, Dexie удобный. Для сценария «положить-достать» так и есть, всё отлично.

Добили меня отчёты. Стоимость запасов, ожидаемая маржа, прогресс закупок — под капотом это join’ы и агрегаты по движениям, а на IndexedDB каждый такой отчёт превращается в ручную склейку массивов в JS. Ещё на мобилке через Capacitor вылезали странные баги, вроде алиаса колонки, который в одной сборке возвращал -1 вместо значения.

Переезд оказался дорогим, и это была главная расплата за раннее решение. Код на IndexedDB к тому моменту уже работал, а у пользователей уже лежали данные, так что задач было две, и обе неприятные. Первая — переписать всю работу с данными под другую модель и SQL. Вторая, которая хуже, — перевезти существующих пользователей со старой IndexedDB-базы в новую SQLite, ничего не потеряв. Заложи я SQLite сразу (в браузере это реально через WASM), не потратил бы неделю с лишним на переписывание и на код миграции.

Взамен получил нормализованную схему (3NF, честные внешние ключи) и настоящий SQL для отчётов, при этом всё по-прежнему крутится в браузере пользователя, без сервера. Учёт работает офлайн, данные никуда не уезжают, пока сам не включишь бэкап в Google Drive, а чтобы начать, не надо поднимать никакую инфраструктуру.

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

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

Отчёты считаются SQL-запросами по движениям. Ради этого и был переезд с IndexedDB на SQLite.

Мультитенантность я заложил в модель сразу, ещё в локальной версии: у всех сущностей есть campaignId, локально просто зафиксированный в 1. Из-за этого переход от одного пользователя локально к команде на сервере не потребовал переписывать доменный слой, а на SaaS каждый тенант стал отдельным SQLite-файлом.

Транзакции: место, где нельзя ошибиться

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

// packages/database/queries/stock/reservationQueries.tsexport async function reserve(db: Db, input: ReserveInput, campaignId = DEFAULT_CAMPAIGN): Promise<number> {  if (input.items.some(i => i.qty <= 0)) throw new Error('Quantities must be positive for reserve');  return await withTx(db, async (tx) => {    const operationId = await addOperation(tx, campaignId, { operationType: 'Reserve', ...input });    for (const item of input.items) {      const balance = await getBalance(tx, input.warehouseId, item.productId, campaignId);      if (balance.availableQty < item.qty)        throw new Error(`Insufficient available stock. Requested: ${item.qty}, Available: ${balance.availableQty}`);      // 1) запись/наращивание резерва      // 2) upsert баланса: reservedQty += qty, availableQty = qty - reserved      // 3) движение типа 'Reserve' с reservedDelta и полями before/after      await tx.insert(stockMovements).values({        campaignId, warehouseId: input.warehouseId, productId: item.productId,        movementType: 'Reserve', qtyDelta: 0, reservedDelta: item.qty,        qtyBefore, qtyAfter: qtyBefore, reservedBefore, reservedAfter,        operationId, /* ... */      });    }    return operationId;  });}
Резервы: активные резервы под заказы с возможностью снять или списать

Резервы: активные резервы под заказы с возможностью снять или списать

Резервы отделяют «есть на складе» от «доступно к продаже». На этом же строится работа под заказ.

Обратите внимание на движение: qtyDelta: 0, но reservedDelta: item.qty — физически товар со склада не ушёл, просто перестал быть доступным. На этом разделении «есть на складе» и «доступно к продаже» держится сценарий работы под заказ с виртуальным складом поставщика: заводим склад поставщика, резервируем на нём под заказ клиента, формируем закупку, приёмка создаёт обычный приход на реальный склад, отгрузка закрывает резерв. Отдельной «дропшип-подсистемы» тут нет, работают те же примитивы движений, и отчёты по марже поэтому остаются корректными сами собой.

Импорт из парсинга магазинов и одна коварная мелочь с числами

Многие приходят с готовыми выгрузками — каталоги и цены, собранные парсингом магазинов. Логика импорта лежит в packages/shared, намеренно без зависимостей от БД и DOM, чтобы одним и тем же кодом рисовать превью в браузере и писать данные на сервере.

Отдельная головная боль — парсинг чисел. В файлах у людей встречается всё подряд: 1 234,56, 1,234.56, 1234,56, неразрывные пробелы из Excel. Наивный replace(',', '.') ломается на первом же числе с разделителем тысяч. Пришлось честно вычислять, какой разделитель десятичный, — правый:

// packages/shared/logic/import.tsexport function parseNumber(v: unknown): number | null {  if (typeof v === 'number') return Number.isFinite(v) ? v : null;  let s = String(v ?? '').trim();  if (!s) return null;  s = s.replace(/[\s  ]/g, ''); // убрать пробелы, включая неразрывные  const lastComma = s.lastIndexOf(','), lastDot = s.lastIndexOf('.');  if (lastComma > -1 && lastDot > -1) {    // оба разделителя: правый  -  десятичный, левый  -  разряды    s = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');  } else if (lastComma > -1) {    s = s.replace(',', '.');  }  const n = Number(s);  return Number.isFinite(n) ? n : null;}

Ещё один урок из того же слоя. Сначала тексты ошибок валидации были зашиты по-русски прямо в shared. Когда дошло до английской и испанской локали, выяснилось очевидное: shared-пакет обязан быть локаленезависимым. Ошибки переехали на коды (FIELD_REQUIRED, INVALID_NUMBER), а переводы к ним — в UI.

Мастер импорта: маппинг колонок CSV/XLSX и превью с валидацией строк

Мастер импорта: маппинг колонок CSV/XLSX и превью с валидацией строк

Мастер импорта: авто-маппинг колонок и превью с валидацией. Логика одна и та же в браузере и на сервере.

Мобилка: где я сильно недооценил трудозатраты

Когда основная кодовая база уже работала на вебе и в расширении, я по наивности рассчитывал, что мобильная версия соберётся почти сама: Capacitor берёт тот же build, оборачивает в WebView, готово. На практике уперся в две вещи, и каждая съела заметно больше времени, чем я закладывал.

Первая — нативный SQLite. В браузере у меня SQLite-WASM, а на устройстве нативный SQLite через @capacitor-community/sqlite, обёрнутый в drizzle sqlite-proxy. Я самонадеянно считал, что это «тот же SQLite», а на уровне драйвера нашлись два неочевидных отличия.

Первое касается BLOB-параметров. В BLOB у меня лежат картинки товаров и штрихкоды, и плагин отказывался их биндить: «голый» Uint8Array он не понимает и падает с No value for type. Выяснилось, что значение он ждёт строго в формате Node Buffer, { type: 'Buffer', data: [...] }. Ушло на это часа три с учётом тестирования, потому что сообщение об ошибке никак не намекает, чего именно плагину не хватает. Конвертирую параметры прямо на границе драйвера:

// apps/mobile-capacitor/src/sqlite.tsconst encodeParams = (params: unknown[] | undefined): unknown[] =>  (params ?? []).map((p) =>    p instanceof Uint8Array ? { type: 'Buffer', data: Array.from(p) } : p);

Второе отличие — транзакции. Drizzle через sqlite-proxy шлёт ручные BEGIN/COMMIT обычным run(), и это конфликтует с собственным управлением транзакциями внутри плагина. Bulk-операции зависали или срывались на середине — ровно там, где всё обязано быть атомарным. Пришлось переопределить db.transaction на нативные begin/commit/rollback плагина:

(db as any).transaction = async (fn: (tx: Db) => Promise<any>) => {  let active = false;  try { active = (await conn.isTransactionActive())?.result === true; } catch {}  if (active) return await fn(db);          // уже внутри  -  просто выполняем  await conn.beginTransaction();  try {    const r = await fn(db);    await conn.commitTransaction();    return r;  } catch (e) {    try { await conn.rollbackTransaction(); } catch {}    throw e;  }};

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

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

Бэкапы: что и где

Раз данные по умолчанию лежат у пользователя локально, бэкап становится обязательной частью, а не приятной опцией: удалил расширение, переставил телефон — и без копии всё потеряно. Поэтому бэкап есть на каждой платформе, но в трёх разных формах.

Первая — файловый бэкап, сырой .sqlite. Самый честный вариант: выгрузить всю базу одним файлом и при желании открыть её чем угодно. Экспорт идёт через VACUUM INTO, поэтому копия консистентна, даже если в этот момент кто-то пишет. В браузере это обычное скачивание файла, на мобилке — нативное сохранение или share через @capacitor/filesystem. Восстановление на мобилке устроено аккуратно: файл кладётся во временный, дальше ATTACH DATABASE, проверка, что это вообще наша база (есть таблица products), и только потом копирование.

Вторая — облачный бэкап в Google Drive. Тут есть деталь для тех, кто делал OAuth на нескольких платформах: способ авторизации везде свой, а работа с Drive общая. Расширение получает токен через chrome.identity, веб — через Google Identity Services, мобилка — через нативный Google Sign-In (@capgo/capacitor-social-login). Дальше все трое идут в один makeDriveCloudBackup поверх Drive REST:

// packages/ui/src/util/driveCloudBackup.ts// Платформа поставляет только АВТОРИЗАЦИЮ (getToken/connect/...),// а upload/list/ротация  -  общие.export function makeDriveCloudBackup(auth: DriveAuth, opts: DriveCloudOpts = {}): CloudBackupIO {  const client = createDriveClient({ getToken: auth.getToken, /* ... */ });  // upload с ротацией: хранить только последние keepN копий в своей папке}

Scope принципиально узкий, drive.file: приложение видит только свою папку бэкапов, а не весь диск пользователя. Ротация (keepN) подчищает старые копии автоматически.

Третья форма есть только на SaaS — серверный автобэкап по cron, про него ниже.

Способ бэкапа

Расширение Chrome

Веб (SaaS)

Мобильное приложение

Файл .sqlite (экспорт/импорт)

да, скачивание

да, скачивание

да, save/share нативно

Google Drive (авто, ротация)

да (chrome.identity)

да (GIS)

да (нативный Sign-In)

Серверный автобэкап по cron

да

Как деплоится веб-версия

SaaS я сознательно держу простым в эксплуатации: один Docker-образ вместо зоопарка сервисов, раскатка сводится к docker compose up с двумя контейнерами.

Образ один, и он делает всё. На этапе сборки в Dockerfile собирается статика фронтенда (npm run build --workspace @inventory/web-saas), а в рантайме тот же Node-бэкенд и отдаёт эту статику, и обслуживает /api. Отдельный веб-сервер под фронт я не ставил:

# docker/saas.dockerfile (сокращённо)FROM node:20-bookworm-slimWORKDIR /appCOPY package.json package-lock.json ./# ...манифесты воркспейсов отдельным слоем  -  кэш, пока не менялись package.jsonRUN npm ciCOPY . .RUN npm run build --workspace @inventory/web-saas   # собрать фронтENV STATIC_DIR=/app/apps/web-saas/distENV DATA_DIR=/dataENV PORT=4000

В compose два контейнера. Первый, saas, — Node на :4000, наружу не торчит, слушает только 127.0.0.1. Второй, caddy, — reverse-proxy на 80/443.

Про Caddy отдельно, потому что выбор неочевидный. Первый вариант был nginx + certbot, как у всех, и он даже работал. Но при каждой пересборке я заново вспоминал, где лежат сертификаты, не забыл ли смонтировать /etc/letsencrypt и почему certbot renew в кроне контейнера не отработал. У Caddy это одна строчка конфига: если задан реальный домен и HTTPS=true, он сам получает и продлевает сертификат Let’s Encrypt. Caddyfile генерится на лету в entrypoint:

# docker/caddy-entrypoint.shif [ "${HTTPS:-false}" = "true" ] && [ "${DOMAIN}" != "localhost" ]; then    SITE="${DOMAIN}"        #  авто-сертификат Let's Encryptelse    SITE=":80"              # локально  -  просто HTTPficat > /etc/caddy/Caddyfile <<EOF${SITE} {    reverse_proxy saas:4000}EOFexec caddy run --config /etc/caddy/Caddyfile

Всё состояние (control-база, tenant-файлы tenants/*.sqlite, логи, бэкапы) лежит в томе /data, смонтированном наружу, так что сам контейнер остаётся stateless и пересобрать его не жалко. Внутри работает supervisord и держит два процесса: бэкенд и cron для ночных бэкапов.

Серверный бэкап устроен так же, как в клиенте: консистентная копия каждого SQLite через VACUUM INTO плюс простой ретеншн по числу папок.

# docker/backup-internal.sh (по cron внутри контейнера)for f in "$DATA_DIR/control.db" "$DATA_DIR"/tenants/*.sqlite; do  [ -f "$f" ] || continue  sqlite3 "$f" "VACUUM INTO '$DEST/$(basename "$f")'"done# удалить папки бэкапов сверх BACKUP_RETENTIONls -1dt "$DATA_DIR"/backups/*/ | tail -n +"$((RET + 1))" | xargs -r rm -rf

Обновление сводится к одному скрипту rebuild.sh: git pull, затем docker compose build, затем up -d. Node пересобирается со свежей статикой, том /data переживает пересборку, Caddy остаётся с уже выписанными сертификатами. Отдельный CI/CD-конвейер под маленький сервис я заводить не стал.

Почему так примитивно, а не Kubernetes с managed-Postgres? Потому что каждый тенант — это отдельный SQLite-файл, а не строки в общей базе. Резервная копия тенанта делается через cp одного файла, «переезд» клиента — тоже. Красивых цифр под нагрузкой я пока не покажу: сервис только появился, тенантов мало, базы крошечные, всё отвечает практически мгновенно — это не price-matrix, где у клиента внутри одной базы миллионы товаров и гигабайты данных. Так что честнее сказать так: до потолка одной машины я ещё даже не приблизился, и решение выбрано не по замерам, а по стоимости эксплуатации. Горизонтальное масштабирование отдельного тенанта я при этом теряю — когда упрусь, придётся переезжать, и я это понимаю.

Что бы я сделал иначе

Пара честных недоработок, до которых руки ещё не дошли:

  • Повсеместный id?: number. После чтения из БД id всегда есть, но тип этого не знает, и код усыпан проверками на null. Правильнее пара Persisted<T> / New<T>.

  • Часть статусных полей всё ещё string, хотя должны быть union-литералами. OrderStatus уже сделан как 'new' | 'reserved' | 'shipped' | ..., а вот movementType пока голая строка — и это периодически аукается опечатками.

Итог

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

Если хотите поспорить про Proxy вместо кодогенерации для RPC, про SQLite-WASM в проде или про транзакции в резервах — я в комментариях. Сам продукт, если интересно посмотреть глазами пользователя: InventoryMod.

ссылка на оригинал статьи https://habr.com/ru/articles/1077994/