Article

コストゼロの在庫管理アプリ活用

高田晃太郎
コストゼロの在庫管理アプリ活用

バーコード入力の誤りは手入力の約1/10,000に低減するというGS1の資料は、いまも現場の常識として広く参照されています²。さらにInvestopediaなどがまとめるデータでは、在庫保管コストは年間で在庫金額の20〜30%に達することが一般的とされ、過剰在庫と欠品の両方が利益を圧迫します³。中小規模のD2CやB2B卸の現場で報告される事例では、在庫差異の主因は入出庫の遅延記録と二重登録に起因し、モバイル入力とオフライン耐性、そして単純で速いUIこそが差異を縮小する鍵とされます(PWAを組み合わせた在庫アプリは、手作業起因の入力エラーや遅延の低減に有効とする報告があります⁴)。ここで焦点を当てたいのが、無料枠を軸にした在庫管理アプリです。クラウドの無償枠とオープンなライブラリを組み合わせれば、CTOやエンジニアリーダーが短期間で試験導入から本番まで持ち込める可能性があり、月額サブスクリプションに固定化されることなく業務のスループット改善とシステムの拡張性を両立できます。コアとなる要件は、バーコード対応、モバイルPWA、ロールベース権限、監査可能な台帳、そして欠品を未然に防ぐ発注点の可視化です。以降ではNext.jsとSupabaseを軸に、無料枠での実装と運用指標、そしてROIの見立てまで具体的に示します。

なぜ「コストゼロ」で在庫管理は現実的か

有償WMSは豊富な機能を提供しますが、その多くは導入初期の現場には過剰であることが少なくありません(ECの拡大に伴い倉庫業務の複雑化とWMS需要が高まっている背景があります⁵)。反対に、無料枠のサーバーレスとオープンライブラリを束ねる構成なら、必要最小限から始めて、規模拡大に応じた段階的な拡張が可能になります。実装の骨格は、Next.jsでPWA(インストール可能なWebアプリ)化したフロントエンド、SupabaseのPostgresで監査可能な取引台帳とRLS(行レベルセキュリティ。行単位のアクセス制御)を用いた権限管理、そして@zxing/browserによるバーコードスキャンです。VercelやSupabaseの無料枠は、開発〜小規模本番の用途で実用的な範囲があります。無償であることは品質と必ずしも相反しません。適切な設計であれば、入出庫の記録はほぼリアルタイムに整合し、棚卸はスキャンで高速化し、欠品は閾値計算で予防でき、そしてログとメトリクスで継続的な改善が回せます。

無料で成立する技術スタックの全体像

アプリはモバイルブラウザで動作するPWAとして提供し、オフライン時はIndexedDB(ブラウザ内蔵のローカルDB)に一時保管、オンライン復帰時にバックグラウンド同期でサーバーへ送信します。サーバー側はNext.jsのAPI Routeで取引を受け付け、SupabaseのPostgresに永続化します。スキーマは仕入・出庫・棚卸を単一のinventory_txnテーブルで表現し、製品・ロケーション・ロット等の正規化テーブルで一貫性を確保します。権限はRLSでロールに応じた読み書きを制御し、監査ログはトリガで残します。さらにEdge Function(CDNエッジで動くサーバーレス関数)で夜間に発注点を自動算出し、ダッシュボードにアラートを表示します。

セキュリティと拡張性の両立

無料枠で最も気にすべきは鍵管理と漏洩リスクです。サーバー側ではサービスロール鍵を使用し、フロントはユーザーセッションで動かして特権鍵の露出を避けます。PostgresのRLSとポリシーで最小権限を貫き、監査列と不変カラムで改ざん耐性を持たせます。拡張時は、在庫評価(移動平均・FIFO)のマテビューや、入荷検品フローのワークフロー化、RFID拡張などをプラグイン的に追加できます。物流現場ではケースやパレットなどの梱包単位管理にSSCC(Serial Shipping Container Code)をバーコード表示して扱う運用も一般的で、こうした体系とも親和性があります⁶。

実装ガイド(完全版)

スキーマ設計(Postgres / Supabase)

-- schema.sql
create table products (
  id uuid primary key default gen_random_uuid(),
  sku text unique not null,
  name text not null,
  barcode text unique,
  unit text not null default 'EA',
  reorder_point integer not null default 0,
  created_at timestamptz not null default now()
);

create table locations (
  id uuid primary key default gen_random_uuid(),
  code text unique not null,
  name text not null,
  created_at timestamptz not null default now()
);

create type txn_type as enum ('RECEIVE','ISSUE','ADJUST','COUNT');

create table inventory_txn (
  id uuid primary key default gen_random_uuid(),
  product_id uuid not null references products(id),
  location_id uuid not null references locations(id),
  kind txn_type not null,
  qty integer not null,
  lot text,
  ref_doc text,
  actor uuid,
  meta jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now(),
  -- 監査列(不変)
  inserted_at timestamptz not null default now(),
  inserted_by text not null default current_user
);

create index on inventory_txn (product_id, location_id, created_at);

-- 現在庫ビュー(単純和)
create view stock as
select product_id, location_id,
       sum(case when kind in ('RECEIVE','ADJUST','COUNT') then qty else -qty end) as qty
from inventory_txn
group by product_id, location_id;

-- 不変化(UPDATE禁止)
create or replace function forbid_update() returns trigger as $$
begin
  raise exception 'immutable ledger: update not allowed';
end; $$ language plpgsql;

create trigger trg_txn_no_update
before update on inventory_txn for each row execute procedure forbid_update();

在庫は取引の集計結果として派生させ、更新を台帳に限定することで履歴の完全性を守ります。評価方法の変更にも強く、集計のビューを切り替えるだけでロジックの影響範囲を局所化できます。

受払API(Next.js / TypeScript)

// app/api/transactions/route.ts (Next.js App Router)
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@supabase/supabase-js';

const schema = z.object({
  productId: z.string().uuid(),
  locationId: z.string().uuid(),
  kind: z.enum(['RECEIVE','ISSUE','ADJUST','COUNT']),
  qty: z.number().int(),
  lot: z.string().optional(),
  refDoc: z.string().optional(),
  meta: z.record(z.any()).optional()
});

export async function POST(req: Request) {
  try {
    const body = await req.json();
    const parsed = schema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: 'invalid-payload', detail: parsed.error.flatten() }, { status: 400 });
    }

    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.SUPABASE_SERVICE_ROLE_KEY!
    );

    const { data, error } = await supabase
      .from('inventory_txn')
      .insert({
        product_id: parsed.data.productId,
        location_id: parsed.data.locationId,
        kind: parsed.data.kind,
        qty: parsed.data.qty,
        lot: parsed.data.lot,
        ref_doc: parsed.data.refDoc,
        meta: parsed.data.meta ?? {}
      })
      .select('id, created_at')
      .single();

    if (error) {
      if (error.code === '23503') {
        return NextResponse.json({ error: 'fk-violation' }, { status: 409 });
      }
      return NextResponse.json({ error: 'db-error', detail: error.message }, { status: 500 });
    }

    return NextResponse.json({ ok: true, id: data.id, ts: data.created_at });
  } catch (e: any) {
    return NextResponse.json({ error: 'unexpected', detail: String(e?.message ?? e) }, { status: 500 });
  }
}

エラーはユーザー起因とサーバー起因を分けて応答し、監視では500系の発報に絞ると運用の雑音が減ります。RPSの頭打ちを感じたら接続のキープアライブとDBインデックスの確認が効きます。なお、サービスロール鍵は必ずサーバー側で扱い、フロントエンドに露出させない設計を徹底します。

バーコードスキャンUI(@zxing/browser)

// components/Scanner.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { BrowserMultiFormatReader } from '@zxing/browser';

export default function Scanner({ onDetect }: { onDetect: (code: string) => void }) {
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const reader = new BrowserMultiFormatReader();
    let mounted = true;
    async function start() {
      try {
        const devices = await BrowserMultiFormatReader.listVideoInputDevices();
        const deviceId = devices?.[0]?.deviceId;
        if (!deviceId) throw new Error('no-camera');
        await reader.decodeFromVideoDevice(deviceId, videoRef.current!, (res) => {
          if (!mounted || !res) return;
          onDetect(res.getText());
        });
      } catch (e: any) {
        setError(e?.message ?? 'scan-failed');
      }
    }
    start();
    return () => {
      mounted = false;
      reader.reset();
    };
  }, [onDetect]);

  return (
    <div>
      <video ref={videoRef} style={{ width: '100%' }} muted playsInline />
      {error && <p style={{ color: 'crimson' }}>{error}</p>}
    </div>
  );
}

庫内の照度やカメラ性能に左右されるため、UIではスキャン結果を即時にフォームへ流し、数量入力はテンキー優先でミスタップを防ぐ設計が有効です。誤読率は環境に依存するものの、業界事例ではバーコード導入により入力・識別エラーを大幅に削減した報告があります²。EAN/UPCの良好条件では、1フレームで安定して解読できることも珍しくありません。

オフライン対応(Service Worker / Background Sync)

// public/sw.js
const DB = 'invdb';
const STORE = 'queue';

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open('app-v1').then((c) => c.addAll(['/','/offline'])));
});

self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;
  event.respondWith(
    caches.match(request).then((cached) => cached || fetch(request).then((res) => {
      const copy = res.clone();
      caches.open('app-v1').then((c) => c.put(request, copy));
      return res;
    }).catch(() => caches.match('/offline')))
  );
});

// IndexedDB helpers
function idb() {
  return new Promise((resolve, reject) => {
    const open = indexedDB.open(DB, 1);
    open.onupgradeneeded = () => open.result.createObjectStore(STORE, { autoIncrement: true });
    open.onsuccess = () => resolve(open.result);
    open.onerror = () => reject(open.error);
  });
}

self.addEventListener('sync', (event) => {
  if (event.tag !== 'sync-txns') return;
  event.waitUntil((async () => {
    const db = await idb();
    const tx = db.transaction(STORE, 'readwrite');
    const store = tx.objectStore(STORE);
    const all = store.getAll();
    await new Promise((r) => (all.onsuccess = r));
    const items = all.result || [];
    for (const payload of items) {
      try {
        const res = await fetch('/api/transactions', {
          method: 'POST', headers: { 'Content-Type': 'application/json' } , body: JSON.stringify(payload)
        });
        if (res.ok) store.delete(payload.id);
      } catch {}
    }
  })());
});

フロントはオンライン判定に依存せず、送信失敗時は必ずキューに積み、バックグラウンド同期か次回画面遷移で再送します。こうすることで、電波状況が悪い倉庫でも入力体験が途切れません(オフライン対応PWAを用いた在庫アプリの有効性に関する報告があります⁴)。なお、Background Syncはブラウザ実装に差があるため、フォールバックとして「次回起動時の再送」も必ず用意します。

発注点の自動提案(Supabase Edge Function + cron)

// supabase/functions/reorder/index.ts (Deno)
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

export const handler = async (req: Request): Promise<Response> => {
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
  const { data: recent, error } = await supabase.rpc('calc_daily_demand');
  if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500 });

  // reorder_point = safety_stock + lead_time_days * avg_daily_demand
  const suggestions = (recent as any[]).map(r => ({
    product_id: r.product_id,
    suggested_point: Math.ceil(r.safety_stock + r.lead_time_days * r.avg_daily_demand)
  }));
  const { error: upsertErr } = await supabase.from('reorder_suggestions').upsert(suggestions, { onConflict: 'product_id' });
  if (upsertErr) return new Response(JSON.stringify({ error: upsertErr.message }), { status: 500 });
  return new Response(JSON.stringify({ ok: true, count: suggestions.length }));
};

// supabase.toml
// [functions.reorder] schedule = '0 3 * * *'  # 毎日3時

需要計算は移動平均から始め、季節性がある場合は週期的なウェイトやプロモーション期間の除外などを徐々に導入します。まずは「欠品を減らす」ことを主要な成功指標に据えると改善が進みます。

RLSポリシーの要点(最小権限)

-- RLSを有効化
alter table inventory_txn enable row level security;

-- 例: 組織単位の分離(org_idを各テーブルに追加している前提)
create policy "org_read" on inventory_txn
for select using (auth.uid() in (select user_id from members where org_id = inventory_txn.org_id));

create policy "org_write" on inventory_txn
for insert with check (auth.uid() in (select user_id from members where org_id = inventory_txn.org_id));

アプリ側のロールとDBポリシーが二重管理にならないよう、認可の単一責任はできる限りDBに寄せます。UIは許可のある操作だけを見せ、認可エラーは例外的に扱うと運用が安定します。

パフォーマンス、信頼性、運用

入力体験の指標はタップから登録完了までのラウンドトリップと、スキャンから数量確定までの時間です。クラウドの無料枠と近年のスマートフォン環境であれば、APIの往復はおおむね数十〜数百ms台が目安になり、バーコード解読も良好条件では即時に近い応答が期待できます。オフライン時は即時保存のため体感はさらに速く、再送はバックグラウンドで吸収されます。信頼性は、取引テーブルの不変化と監査列で担保し、更新禁止の方針に合わせて誤登録は逆仕訳で相殺します。夜間のEdge Functionは失敗しても業務に直結しにくいため、リトライとSlack通知を組み合わせれば現実的に運用できます。

簡易ベンチマークの再現方法(k6)

// bench/inv_txn.js (k6)
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 20,
  duration: '60s',
};

export default function () {
  const payload = JSON.stringify({
    productId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
    locationId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
    kind: 'ISSUE',
    qty: 1
  });
  const res = http.post('https://your.app/api/transactions', payload, { headers: { 'Content-Type': 'application/json' } });
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.3);
}

RPSが頭打ちになる場合は、DBへのINSERT競合やシリアライズ待ちの可能性があります。トランザクションの単純化と適切なインデックス、必要に応じたバッチ化で多くは解決します。無料枠の同時接続上限に迫ったら、読み取りをキャッシュに逃がし、書き込みはキューイングで平準化すると伸び代が生まれます。

監視と可観測性

SLOはAPI成功率とp95、そして未同期キューの滞留件数で定義すると実務的です。Vercelのログ出力に構造化JSONを流し、Supabaseのクエリログと突合せれば、遅延の発生箇所は即座に特定できます。棚卸期間やセール時のピークに合わせてしきい値を一時的に緩める運用も現実的です。

ビジネスインパクトと導入ステップ

在庫差異の削減、棚卸の短縮、欠品率の低下は、どれも直接的な利益に還元されます。例えば毎週の棚卸・部分棚卸に相応の時間を要しているチームが、スキャンとPWAで大幅に短縮できれば、時給や人員構成に応じて月あたり複数万円規模の削減効果が見込めるケースがあります。さらに欠品が減れば、機会損失の回収効果も小さくありません。ライセンス費を抑えられることは投資回収のハードルを下げ、まずは一拠点・一商品群からの部分導入でも十分に意味があります。

導入は現場の摩擦を最小にすることが成功条件です。SKUの同定はバーコードを第一に、無い場合はシンプルな短縮コードとオートコンプリートで運用負荷を下げます。UIは数量の増減とロケーション切り替えに最短のタップ数で到達できる配置にし、誤タップに備えたアンドゥを必ず用意します。データの移行はCSVの双方向を前提にし、当面はレガシー台帳との併用を許容しつつ、現場の成功事例を積み上げて全社展開へ進めます。

無料枠の限界についても先回りしておきます。リクエストやストレージの上限に近づいた時点で、有料プランへ段階的に切り替える想定を設計に組み込みます。スケール時は読み取りをキャッシュ層に逃がし、書き込みはキューとリトライで吸収し、最終的にはイベントストリームで非同期化します。アーキテクチャの背骨をシンプルに保てば、これらの増築は痛みなく進められます。

関連リーディングと深掘り

PWAのオフライン戦略は詳しく解説しています。サーバーレスの費用最適化、ノーコード/ローコードの使い分け、データセキュリティも参考になります。

まとめ:小さく速く作り、確実に効かせる

在庫の正しさは現場の速度を生み、正しい在庫は顧客体験の安定をもたらします。無料枠のスタックであっても、設計を尽くせば十分な信頼性と性能を引き出せます。まずは最小のスコープでPWA、バーコード、受払の三点セットを動かし、欠品率と棚卸時間という二つの指標で成果を測ってください。数字が改善し始めたら、発注点の自動提案や権限のきめ細かさ、評価方法の高度化へ拡張します。あなたのチームにとって何が最小の勝ち筋か、そして最短で証明できるかを問い続けることが、業務改善とシステム効率化を同時に進めるいちばんの近道です。

GS1の資料や、在庫保管コストの相場感をまとめたInvestopediaの解説も合わせて確認すると、現場の合意形成が進めやすくなります²³。

参考文献

  1. Zebra Technologies. How to use barcode tech to automate data capture, PPID and other workflows in healthcare. https://www.zebra.com/ap/en/blog/posts/2023/how-to-use-barcode-tech-to-automate-data-capture-ppid-other-workflows-in-healthcare.html
  2. GS1. GS1 Official Website. https://www.gs1.org/
  3. Investopedia. Carrying Cost of Inventory. https://www.investopedia.com/terms/c/carrying-cost-of-inventory.asp
  4. Enhancing Inventory Management with Progressive Web Applications (PWAs): A Scalable Solution for Small and Large Enterprises. ResearchGate. https://www.researchgate.net/publication/390609007_Enhancing_Inventory_Management_with_Progressive_Web_Applications_PWAs_A_Scalable_Solution_for_Small_and_Large_Enterprises
  5. ITトレンド. 倉庫管理システム(WMS)の基礎と導入動向(解説記事). https://it-trend.jp/warehouse_management_system/article/159-0040
  6. サカタのタネ(物流情報). 物流梱包単位などにSSCCをバーコード表示(解説). https://www.sakata.co.jp/logistics-491/