在庫てちょう中小企業のAI導入支援|在庫管理のExcel脱却から在庫管理のExcel脱却 専門

コラム仕組みと技術

生成AIで在庫管理アプリを自作すると、どこで壊れる?同じ仕様を30回頼み、マイナス在庫と二重計上を検査した

生成AIで在庫管理アプリを自作すると、どこで壊れる?(記事の表紙)

同じ仕様の在庫管理アプリを生成AI(Sonnet 5.5)に30回作らせ、同時に2人が出庫する検査を各20回かけたところ、在庫がマイナスになったのは30回中0回でした。ロットの取り違え、棚卸の差、0.1kgの端数の検査も30本とも合格です。ただし同じデータを2つのプロセスで使う追加条件では、30本とも二重に出庫できました。作れるかどうかより、検査と運用を誰が持つかが分かれ目です。(2026年10月時点)

全体の流れと費用は在庫管理のExcel脱却のページにまとめています。

要点

  • 生成AI(Sonnet 5.5)に同じ仕様を30回頼むと、30本とも動き、同時出庫でマイナス在庫になったのは0本でした(各20回の検査)。
  • ロット・棚卸・0.1kgの端数・負の数量・再起動の検査も、30本とも合格しました(2026年10月17日実施)。
  • 同じ「data.json」を2つのプロセスで使う追加条件では、30本とも二重に出庫でき、600回のうち592回が二重に通りました。
  • 作れるかより、検査と更新・バックアップの復元確認を御社が持つか、保守に任せるかが分かれ目です。
目次
  1. 在庫管理アプリは、自分で作れますか?
  2. 生成AIに同じ仕様を30回頼むと、何が起きましたか?
  3. どの検査をすれば、危ない点が見つかりますか?
  4. 仕様にない条件では、どうなりましたか?
  5. Excel・クラウド・依頼とは、何が違いますか?
  6. この実験には、どんな限界がありますか?
  7. 依頼文と検査のコードは?
  8. このサイトで相談するには?
  9. よくある質問

在庫管理アプリは、自分で作れますか?

作れます。今回、同じ仕様で生成AIに作らせた30本は、すべて起動し、品目200件の登録から入庫・出庫・棚卸まで動きました。同時に2人が出庫する検査でマイナス在庫になったのは30本中0本で、ロットの取り違えと棚卸の差の検査も30本とも合格しました。

ただし、この結果が成り立つのは、アプリが1つのプロセスで動いているあいだだけでした。分かれ目は、作ることそのものより、作ったあとの仕事です。検査、更新、バックアップを戻せる状態の維持を、御社が持つか保守に任せるかを決めておく必要があります。

生成AIに同じ仕様で作らせた在庫管理アプリ30本の検査別の合格数。仕様どおりの7つの検査は30本とも合格。仕様にない追加条件(2つのプロセスで同時出庫)は0本の合格 仕様どおりの検査は30本とも合格。仕様にない条件だけ0本 検査ごとの合格数(全30本のうち)。棒の長さは30本を満点とした割合 仕様どおり動いた 起動・品目200件の登録・入庫・参照 30/30 同時出庫 在庫1に2人が1ずつ。20回とも 30/30 ロット 空のロットから引かない・合算する 30/30 棚卸 帳簿との差と、実数への直し 30/30 端数 0.1kgを3回入庫し0.3kg出庫で、残りが0 30/30 負の数量 負の出庫・入庫を断る 30/30 再起動 止めて起動し直しても在庫が残る 30/30 追加条件 2つのプロセスで同時出庫。20回とも 0/30 自分の実験(2026年10月17日、Sonnet 5.5 に30回、検査は HTTP で実行)。追加条件は、同じ data.json を使う2つのプロセスで600回の出庫のうち592回が二重に通った
図1 検査別の合格数。自分の実験(2026年10月17日)。30本のうち合格した本数で、各検査の中身は「どの検査をすれば」の表のとおりです。

結果の全体は図1のとおりです。仕様に書いた条件では、不具合が1本も見つからなかったことになります。見つかったのは、仕様に書いていない条件のほうでした。

生成AIに同じ仕様を30回頼むと、何が起きましたか?

2026年10月17日に、次の条件で同じ依頼を30回出しました。

項目 内容
モデル Sonnet 5.5(Anthropic)。道具を持たせず、コードだけを返させた
依頼 Node.js の標準モジュールだけで1ファイル。品目約200件(個とkg)、ロット指定の入庫・出庫、棚卸、HTTPのJSON API。2人が同時に出庫しても使えること、kgは0.1kg刻み、在庫は「data.json」に保存
回数 30回(版A)。同時出庫と0.1kg刻みの2行を除いた依頼も30回(版B)

できたコードは平均220行でした。26本は「同期処理なので同時でも割り込まれない」という趣旨のコメントを書いていました。依頼は毎回、前の結果を見せずに新しく出しています。

どの検査をすれば、危ない点が見つかりますか?

在庫の記録が壊れる場面に絞り、6つの検査にしました。

検査 やること 合格の条件
同時出庫 在庫1の品目に、2人が同時に1ずつ出庫(20回) 1人だけ成功し、在庫が0以上
ロット ロットA 8とB 5があり、Bを5出庫したあと、Bをもう1出庫 2回目は断り、Aは8のまま
棚卸 帳簿7のところへ6と数えて入力 差が-1で、在庫が6になる
端数 0.1kgを3回入庫し、0.3kg出庫 残りがちょうど0
負の数量 数量-2で出庫・入庫 断り、在庫は変わらない
再起動 止めて起動し直す 在庫が同じ数で残る

結果は、版Aと版Bのどちらも、30本が6つすべてに合格です。0.1を3回足すと0.30000000000000004になる(計算値)ので端数は落とし穴になりえますが、30本とも残りがちょうど0でした。

検査が見つけられるかも確かめました。ファイルを読んで書き戻すだけの素朴なアプリをわざと1本書き、同じ検査にかけました。同時出庫は20回中6回が二重に通り、端数は5.6×10のマイナス17乗が残り、負の出庫も通りました。

仕様にない条件では、どうなりましたか?

仕様どおりの検査はすべて合格だったので、条件を足しました。同じ「data.json」を使う2つのプロセスを動かし、在庫1の品目に1ずつ同時に出庫する検査です。30本×20回=600回のうち592回で、2人とも成功しました。合格は30本中0本です。

30本とも、起動時にファイルを読んでメモリに在庫を持ち、更新は自分のメモリに対して行うからです。相手のプロセスが保存した在庫は読み直しません。コメントに「複数のプロセスでは破れる」と書いたものは0本でした。

在庫1に2人が同時に出庫したときの流れの比較図。1つのプロセスでは、1人目が通って在庫が0になり、2人目は断られる。2つのプロセスでは、どちらも起動時に読んだ在庫1をメモリに持っているため、2人とも通り、棚には1個しかないのに2個が出庫になる 守っているのは、1つのプロセスのメモリの中の在庫 在庫1に、2人が同時に1個ずつ出庫を押したとき プロセス1つ(30本の前提) プロセス2つ(追加条件) 1人目が通る メモリの在庫 1 を見て、0にして保存 2人目は断られる メモリの在庫は 0 409(在庫が足りない) 記録と棚が合う 出庫は1個 在庫の記録は 0 どちらも起動時に読む プロセスAのメモリ 1 プロセスBのメモリ 1 (相手の更新は見ない) 2人とも通る Aは1を見て0にする Bも1を見て0にする 200が2回返る 記録と棚がずれる 出庫は2個、棚には 1個しかなかった 記録は 0 自分の整理(2026年10月)。点線の枠が、30本すべてで起きた流れ
図2 在庫1への同時出庫の流れ。自分の整理(2026年10月)。プロセスが2つになると、どちらも古い在庫を見て出庫を通します。

2つのプロセスは、予備のサーバーを足したとき、台数を増やす設定にしたとき、古いプロセスが残ったまま別のポートで起動したときにできます。画面だけ見ていると気づけません。

Excel・クラウド・依頼とは、何が違いますか?

違いは作る速さより、作ったあとに誰が何を持つかです。A社・B社のようなクラウドの在庫管理サービスは、機能の更新とサポートが料金に含まれます(B社のPremiumは2年目から年間保守料がかかります)。自作や依頼で作るアプリは、保守契約がなければ、次の作業を御社が持ちます。

  • OSや実行環境の更新
  • 証明書の更新(画面を社外に出すとき)
  • バックアップの復元確認(別の場所に戻して、在庫が同じ数で出ること)

保守契約は任意で、月1万円〜です。都度の対応なら月0円で、納品後3か月は不具合を無料で直します。在庫てちょうの一式は30万円〜で、内訳と5年の比べ方は計算機と方式の比較にあります。

Excelの在庫表から移すときの崩れやすい点は在庫表の移行の記事、帳簿と実際が合わない原因は差異の7つの原因の記事で扱っています。全体の流れは在庫管理のExcel脱却にまとめました。

この実験には、どんな限界がありますか?

1つの仕様、1つのモデル、HTTPの検査だけです。画面、ログイン、バーコードの読み取りは検査していません。

30本は同じ依頼の結果で、30人が別々に作ったものとは違います。依頼の書き方を変えれば、結果も変わりえます。20回の同時出庫は、1つのプロセスでは構造上競合しにくいため、合格しても競合が起きないとは言い切れません。モデルが新しくなれば、これも変わります。

追加条件は、0本という結果を見てから足しました。先に決めた検査ではありません。

再現するには、下の依頼文を同じモデルに30回出し、できたファイルを下の検査にかけてください。特定の製品には依存しません。結果が違えば、モデルや依頼の差として比べられます。

依頼文と検査のコードは?

再現できるよう、実際に出した依頼文と、検査に使ったコードを全文載せます。

依頼文(版A)の全文
中小企業向けの在庫管理アプリを作ってください。Node.js 22 の標準モジュールだけで書き、npm install は使いません。1ファイル server.js にまとめ、完成したコード全体を1つのコードブロックで返してください。説明は不要です。

要件
- 品目は約200件。品目は品目コード(sku)・名称・単位(個またはkg)を持つ。kgの品目は0.1kg刻みで入出庫する。
- 入庫(ロット番号つき)、出庫(ロット指定)、棚卸(実際に数えた数を入れると、帳簿との差を出して在庫を実数に直す)ができる。
- 事務所の2人が同時に出庫操作をしても使えること。
- 在庫は data.json に保存し、再起動しても残る。
- 画面は不要。HTTP の JSON API だけ。ポートは環境変数 PORT。

API(この形を守る)
POST /api/items      body {"items":[{"sku","name","unit"}]}  品目を登録し直す。200 {"count":件数}
POST /api/receive    body {"sku","lot","qty"}  入庫。200 {"ok":true}
POST /api/issue      body {"sku","lot","qty"}  出庫。成功は 200 {"ok":true}。在庫が足りないときは 409 {"ok":false}
GET  /api/stock?sku=XXX  その品目のロット別在庫。200 [{"sku","lot","qty"}]
POST /api/stocktake  body {"sku","lot","counted"}  棚卸。200 {"book":帳簿の数,"counted":数えた数,"diff":counted-book}。在庫を counted に直す

版Bは、この文から「kgの品目は0.1kg刻みで入出庫する。」と「事務所の2人が同時に出庫操作をしても使えること。」の2行を除いたものです。

6つの検査のコード(Node.js)
// 在庫管理アプリ自作の検査(仕様どおりの条件)。使い方: node check-spec.mjs <生成物の親フォルダ(1〜30のフォルダにserver.js)> <結果JSON>
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const [,, parent, outFile] = process.argv;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const results = [];
async function call(base, method, url, body) {
  const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 5000);
  try {
    const r = await fetch(base + url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, signal: ac.signal });
    let j = null; try { j = await r.json(); } catch {}
    return { s: r.status, j };
  } catch (e) { return { s: 0, j: null, err: String(e.cause?.code || e.name) }; } finally { clearTimeout(t); }
}
const qtyOf = (rows, lot) => Array.isArray(rows) ? rows.filter(x => x.lot === lot).reduce((a, x) => a + Number(x.qty), 0) : NaN;
async function stock(base, sku) { const r = await call(base, 'GET', '/api/stock?sku=' + sku); return r.s === 200 ? r.j : null; }

async function runOne(i) {
  const dir = path.join(parent, String(i)); const port = 4100 + i;
  for (const f of ['data.json']) fs.rmSync(path.join(dir, f), { force: true });
  const base = `http://127.0.0.1:${port}`;
  const res = { id: i, gate: null, concurrent: null, lot: null, stocktake: null, rounding: null, negqty: null, persist: null, detail: {} };
  const p = spawn('node', ['server.js'], { cwd: dir, env: { ...process.env, PORT: String(port) }, stdio: 'ignore' });
  let exited = false; p.on('exit', () => exited = true);
  try {
    for (let k = 0; k < 30; k++) { await sleep(100); const r = await call(base, 'GET', '/api/stock?sku=P001'); if (r.s) break; if (exited) break; }
    const items = Array.from({ length: 200 }, (_, k) => ({ sku: 'P' + String(k + 1).padStart(3, '0'), name: '品目' + (k + 1), unit: k >= 150 ? 'kg' : '個' }));
    const r1 = await call(base, 'POST', '/api/items', { items });
    const r2 = await call(base, 'POST', '/api/receive', { sku: 'P001', lot: 'L1', qty: 10 });
    const st = await stock(base, 'P001');
    res.gate = r1.s === 200 && r2.s === 200 && qtyOf(st, 'L1') === 10;
    if (!res.gate) { res.detail.gate = `items=${r1.s} receive=${r2.s} stock=${JSON.stringify(st)}`.slice(0, 120); return res; }

    // 1 同時出庫(在庫1に対して2人が同時に1を出庫。20回)
    let bad = { both: 0, neg: 0, none: 0, err: 0 };
    for (let k = 0; k < 20; k++) {
      const sku = 'P' + String(11 + k).padStart(3, '0');
      await call(base, 'POST', '/api/receive', { sku, lot: 'L1', qty: 1 });
      const [a, b] = await Promise.all([call(base, 'POST', '/api/issue', { sku, lot: 'L1', qty: 1 }), call(base, 'POST', '/api/issue', { sku, lot: 'L1', qty: 1 })]);
      const ok = [a, b].filter(x => x.s === 200).length; const s = await stock(base, sku); const q = s ? qtyOf(s, 'L1') : NaN;
      if (a.s === 0 || b.s === 0 || s === null) bad.err++;
      else if (q < 0) bad.neg++;
      else if (ok === 2) bad.both++;
      else if (ok === 0) bad.none++;
    }
    res.concurrent = bad.both + bad.neg + bad.none + bad.err === 0;
    res.detail.concurrent = bad;

    // 2 ロット
    const lotFail = [];
    const sku2 = 'P101';
    for (const [lot, qty] of [['A', 5], ['B', 5], ['A', 3]]) await call(base, 'POST', '/api/receive', { sku: sku2, lot, qty });
    let s = await stock(base, sku2);
    if (qtyOf(s, 'A') !== 8 || qtyOf(s, 'B') !== 5) lotFail.push('同じロットの入庫が合算されない');
    let r = await call(base, 'POST', '/api/issue', { sku: sku2, lot: 'B', qty: 5 });
    s = await stock(base, sku2);
    if (r.s !== 200 || qtyOf(s, 'B') !== 0 || qtyOf(s, 'A') !== 8) lotFail.push('指定ロット以外から引いた');
    r = await call(base, 'POST', '/api/issue', { sku: sku2, lot: 'B', qty: 1 });
    s = await stock(base, sku2);
    if (r.s === 200 || qtyOf(s, 'B') < 0 || qtyOf(s, 'A') !== 8) lotFail.push('空のロットの出庫を通した');
    r = await call(base, 'POST', '/api/issue', { sku: sku2, lot: 'Z', qty: 1 });
    if (r.s === 200) lotFail.push('存在しないロットを通した');
    res.lot = lotFail.length === 0; res.detail.lot = lotFail;

    // 3 棚卸
    const stFail = []; const sku3 = 'P102';
    await call(base, 'POST', '/api/receive', { sku: sku3, lot: 'A', qty: 10 });
    await call(base, 'POST', '/api/receive', { sku: sku3, lot: 'B', qty: 4 });
    await call(base, 'POST', '/api/issue', { sku: sku3, lot: 'A', qty: 3 });
    r = await call(base, 'POST', '/api/stocktake', { sku: sku3, lot: 'A', counted: 6 });
    if (!(r.s === 200 && r.j && r.j.book === 7 && r.j.counted === 6 && r.j.diff === -1)) stFail.push('帳簿・差の値が違う');
    s = await stock(base, sku3);
    if (qtyOf(s, 'A') !== 6) stFail.push('棚卸後の在庫が実数にならない');
    if (qtyOf(s, 'B') !== 4) stFail.push('別ロットまで書き換えた');
    r = await call(base, 'POST', '/api/stocktake', { sku: sku3, lot: 'C', counted: 2 });
    s = await stock(base, sku3);
    if (!(r.s === 200 && r.j && r.j.book === 0 && r.j.diff === 2) || qtyOf(s, 'C') !== 2) stFail.push('新しいロットの棚卸が合わない');
    res.stocktake = stFail.length === 0; res.detail.stocktake = stFail;

    // 4 丸め(kg品目に0.1を3回入庫、0.3出庫→ちょうど0)
    const rf = []; const sku4 = 'P160';
    for (let k = 0; k < 3; k++) await call(base, 'POST', '/api/receive', { sku: sku4, lot: 'K', qty: 0.1 });
    r = await call(base, 'POST', '/api/issue', { sku: sku4, lot: 'K', qty: 0.3 });
    s = await stock(base, sku4); const q = qtyOf(s, 'K');
    if (r.s !== 200) rf.push('0.3の出庫が拒否された');
    else if (Math.abs(q) > 0) rf.push('端数が残る(' + q + ')');
    res.rounding = rf.length === 0; res.detail.rounding = rf;

    // 5 不正な数量(負の数で出庫・入庫して在庫が変わらないこと)
    const nf = []; const sku5 = 'P103';
    await call(base, 'POST', '/api/receive', { sku: sku5, lot: 'A', qty: 5 });
    r = await call(base, 'POST', '/api/issue', { sku: sku5, lot: 'A', qty: -2 });
    if (r.s === 200) nf.push('負の出庫を通した');
    r = await call(base, 'POST', '/api/receive', { sku: sku5, lot: 'A', qty: -3 });
    if (r.s === 200) nf.push('負の入庫を通した');
    s = await stock(base, sku5);
    if (qtyOf(s, 'A') !== 5) nf.push('負の数量で在庫が変わった(' + qtyOf(s, 'A') + ')');
    r = await call(base, 'POST', '/api/issue', { sku: 'NOPE', lot: 'A', qty: 1 });
    if (r.s === 200) nf.push('存在しない品目を通した');
    res.negqty = nf.length === 0; res.detail.negqty = nf;

    // 6 再起動後も在庫が残る
    p.kill(); await sleep(400);
    const p2 = spawn('node', ['server.js'], { cwd: dir, env: { ...process.env, PORT: String(port) }, stdio: 'ignore' });
    p.child2 = p2;
    for (let k = 0; k < 30; k++) { await sleep(100); const rr = await call(base, 'GET', '/api/stock?sku=P001'); if (rr.s) break; }
    const s1 = await stock(base, 'P102'); const s2 = await stock(base, 'P101');
    res.persist = !!s1 && !!s2 && qtyOf(s1, 'A') === 6 && qtyOf(s1, 'C') === 2 && qtyOf(s2, 'A') === 8;
    res.detail.persist = res.persist ? [] : ['再起動後に在庫が戻らない'];
    p2.kill(); await sleep(150);
    return res;
  } catch (e) { res.detail.error = String(e); return res; }
  finally { p.kill(); await sleep(150); }
}
for (let i = 1; i <= 30; i++) { results.push(await runOne(i)); console.log(i, JSON.stringify(results.at(-1))); }
fs.writeFileSync(outFile, JSON.stringify(results, null, 1));
2つのプロセスで同時に出庫する追加の検査のコード
// 追加検査(仕様にない条件): 同じ data.json を2つのプロセスが使うとき、在庫1に同時に1ずつ出庫して二重に通らないか(20回)
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const [,, parent, outFile] = process.argv;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function call(base, method, url, body) {
  const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 5000);
  try {
    const r = await fetch(base + url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, signal: ac.signal });
    let j = null; try { j = await r.json(); } catch {}
    return { s: r.status, j };
  } catch (e) { return { s: 0, j: null }; } finally { clearTimeout(t); }
}
const up = async base => { for (let k = 0; k < 30; k++) { await sleep(100); if ((await call(base, 'GET', '/api/stock?sku=P001')).s) return; } };
const out = [];
for (let i = 1; i <= 30; i++) {
  const dir = path.join(parent, String(i)); fs.rmSync(path.join(dir, 'data.json'), { force: true });
  const b1 = `http://127.0.0.1:${4600 + i}`, b2 = `http://127.0.0.1:${4800 + i}`;
  const p1 = spawn('node', ['server.js'], { cwd: dir, env: { ...process.env, PORT: String(4600 + i) }, stdio: 'ignore' });
  await up(b1);
  const items = Array.from({ length: 200 }, (_, k) => ({ sku: 'P' + String(k + 1).padStart(3, '0'), name: 'n' + k, unit: '個' }));
  await call(b1, 'POST', '/api/items', { items });
  for (let k = 0; k < 20; k++) await call(b1, 'POST', '/api/receive', { sku: 'P' + String(11 + k).padStart(3, '0'), lot: 'L1', qty: 1 });
  const p2 = spawn('node', ['server.js'], { cwd: dir, env: { ...process.env, PORT: String(4800 + i) }, stdio: 'ignore' });
  await up(b2);
  let dup = 0, err = 0;
  for (let k = 0; k < 20; k++) {
    const sku = 'P' + String(11 + k).padStart(3, '0');
    const [a, b] = await Promise.all([call(b1, 'POST', '/api/issue', { sku, lot: 'L1', qty: 1 }), call(b2, 'POST', '/api/issue', { sku, lot: 'L1', qty: 1 })]);
    if (!a.s || !b.s) err++; else if (a.s === 200 && b.s === 200) dup++;
  }
  p1.kill(); p2.kill(); await sleep(200);
  out.push({ id: i, dup, err, pass: dup === 0 && err === 0 }); console.log(i, dup, err);
}
fs.writeFileSync(outFile, JSON.stringify(out, null, 1));

このサイトで相談するには?

自分で作る場合も、作る人に頼む場合も、検査と更新とバックアップの確認を御社が持つか、保守に任せるかを先に決めます。作る内容と進め方は依頼の内容にあります。

導入例(イメージ)は導入例と費用にあります。

自分で作るか頼むかは、同じ在庫表で試作の画面をお見せしてから決められます。何をどう頼むか決まっていなくても大丈夫です。準備は要りません。無料で相談する

よくある質問

生成AIで作った在庫管理アプリは、そのまま使えますか?
今回の検査では、30本とも仕様どおりに動きました。ただし1つのプロセスで動かす前提で、2つのプロセスで同時に出庫すると30本とも二重に通りました。
生成AIのアプリで、マイナス在庫は起きましたか?
起きませんでした。2人が同時に出庫する検査(各20回)で、30本中0本です。同時出庫を依頼に書かなかった30本も、0本でした。
自作したアプリのバックアップは、何を確かめますか?
保存したファイルを別の場所に戻し、在庫が同じ数で表示されることを確かめます。取っただけでは、戻せる保証がありません。
保守契約なしで頼むと、何を御社が持ちますか?
実行環境(OSなど)の更新、証明書の更新、バックアップの復元確認です。保守契約は任意で、月1万円〜です。

出典

  1. 自分の実験:生成AI(Sonnet 5.5)に同じ依頼を30回(仕様どおりの版A)、2行を除いた依頼を30回(版B)。できた server.js を HTTP で検査し、追加条件として2つのプロセスでも検査した(2026年10月17日)(2026年10月17日確認)
  2. 在庫てちょう 料金の比較の元データ(A社・B社の公式の料金ページ、2026年10月16日確認。cms/_facts.md)(2026年10月16日確認)