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

同じ仕様の在庫管理アプリを生成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回が二重に通りました。
- 作れるかより、検査と更新・バックアップの復元確認を御社が持つか、保守に任せるかが分かれ目です。
目次
在庫管理アプリは、自分で作れますか?
作れます。今回、同じ仕様で生成AIに作らせた30本は、すべて起動し、品目200件の登録から入庫・出庫・棚卸まで動きました。同時に2人が出庫する検査でマイナス在庫になったのは30本中0本で、ロットの取り違えと棚卸の差の検査も30本とも合格しました。
ただし、この結果が成り立つのは、アプリが1つのプロセスで動いているあいだだけでした。分かれ目は、作ることそのものより、作ったあとの仕事です。検査、更新、バックアップを戻せる状態の維持を、御社が持つか保守に任せるかを決めておく必要があります。
結果の全体は図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本でした。
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万円〜です。
出典
- 自分の実験:生成AI(Sonnet 5.5)に同じ依頼を30回(仕様どおりの版A)、2行を除いた依頼を30回(版B)。できた server.js を HTTP で検査し、追加条件として2つのプロセスでも検査した(2026年10月17日)(2026年10月17日確認)
- 在庫てちょう 料金の比較の元データ(A社・B社の公式の料金ページ、2026年10月16日確認。cms/_facts.md)(2026年10月16日確認)


