スプレッドシートで在庫の発注点を自動判定する(GAS・無料)
現在庫、発注残、1日平均出庫、調達日数、安全在庫を入力すると、発注点と推奨発注数を計算し、発注候補だけを一覧にするGoogle Apps Scriptです。コード全文を無料で掲載します。
在庫表を更新していても、「いつ発注するか」が担当者の感覚だけだと、欠品と過剰在庫の両方が起こります。現在庫だけを見ても、発注済みで未入荷の数量や、届くまでの日数を含めて判断できないためです。
このページでは、現在庫・発注残・1日平均出庫・調達日数・安全在庫を入力すると、発注点を計算し、発注候補だけを別シートへ集めるGoogle Apps Script(GAS)を全文掲載します。Googleスプレッドシートだけで動き、外部サーバーへデータを送りません。
このツールでできること・できないこと
できること
- 発注点を
1日平均出庫 × 調達日数 + 安全在庫で計算する - 現在庫だけでなく、すでに発注済みの「発注残」を加えて二重発注を防ぐ
- 発注点以下の商品だけを「発注候補」シートへ一覧化する
- 補充後の目標日数から推奨発注数を計算する
- 商品コードの重複、負数、空欄、目標日数の矛盾を理由付きで止める
- 希望すれば、毎日8時台に一覧を自動更新する
できないこと
- 発注先へ注文書やメールを送ること
- 販売予測や季節変動を自動で計算すること
- ロット数、最低発注数、ケース単位への丸め
- 複数倉庫、使用期限、製造番号の管理
- 会計ソフトや販売管理システムとの同期
発注候補は判断材料です。実際の注文確定は、販売予定、保管場所、仕入先の休業日なども確認して行ってください。
計算の考え方
発注点は、商品が届くまでに使う見込み数へ、安全在庫を足して決めます。
発注点 = 1日平均出庫 × 調達日数 + 安全在庫
入荷予定込み在庫 = 現在庫 + 発注残
推奨発注数 = 補充後の目標在庫 - 入荷予定込み在庫
補充後の目標在庫 = 1日平均出庫 × 補充後の目標日数 + 安全在庫
たとえば、1日5個使い、発注から到着まで7日、安全在庫を20個持つ商品なら、発注点は55個です。現在庫が35個でも発注残が40個あれば、入荷予定込みでは75個なので追加発注しません。発注残を見ずに現在庫だけで判断しないのが、このシートの重要な部分です。
平均出庫が0の商品は、在庫日数を計算できません。「無期限」とは判定せず、「使用量0・確認」と表示します。集計期間にたまたま動かなかったのか、休眠在庫なのかを人が確認してください。
導入手順
- Googleスプレッドシートを新規作成する
- メニュー「拡張機能 > Apps Script」を開く
- 最初から入っている
function myFunction() {}を削除する - このページ下部のコードを全文コピーして貼り付ける
Cmd+S/Ctrl+Sで保存する- スプレッドシートへ戻り、ページを再読み込みする
- メニュー「在庫発注 > 初期シートを作成」を押す
- 初回だけ表示される承認画面で、スプレッドシートと同じGoogleアカウントを選ぶ
初期設定では「在庫」と「発注候補」の2シートを作ります。すでに同名シートにデータがある場合は消去せず、見出しが一致しなければ停止します。
承認画面で止まる場合
自分で貼り付けたGASはGoogleの一般公開アプリではないため、「このアプリは確認されていません」と表示される場合があります。「詳細」を開き、自分で作成したプロジェクトへ進んで許可します。コードは開いているスプレッドシートと、設定した時間主導型トリガーだけを扱います。
複数アカウントへログインしている場合は、シート所有者と承認画面で選んだアカウントが同じか確認してください。表示中のブラウザ名だけでは判定せず、myaccount.google.com で確認すると取り違えを防げます。
入力する列
青い列だけを入力します。右側の灰色列はスクリプトの計算結果です。
| 列 | 内容 | 入力例 |
|---|---|---|
| 商品コード | 重複しない管理番号 | ITEM-001 |
| 商品名 | 品名 | 梱包箱 S |
| 現在庫 | 今ある数量 | 42 |
| 発注残 | 発注済み・未入荷の数量。なければ0 | 0 |
| 1日平均出庫 | 対象期間の出庫数 ÷ 日数 | 6 |
| 調達日数 | 発注してから入荷するまでの日数 | 5 |
| 安全在庫 | 予定外の遅延や増加に備える数量 | 20 |
| 補充後の目標日数 | 入荷後に何日分まで戻すか | 21 |
「補充後の目標日数」は「調達日数」より大きくしてください。同じか短い場合、届いた直後に再び発注点へ達する設計になるため、入力エラーとして止めます。
発注候補を更新する
入力後、メニュー「在庫発注 > 発注候補を更新」を押します。
- 「在庫」シートの右側へ、発注点・入荷予定込み在庫・判定・推奨発注数・在庫日数を書き込む
- 入力エラーと発注候補だけを「発注候補」シートへ集める
- 入力エラーを先頭、その後を在庫日数が短い順に並べる
- すべての行を配列で計算し、シートへの書き込みは範囲単位でまとめる
同時に2回動いた場合は、ドキュメントロックを取れた1回だけ処理します。Google公式のLock serviceは、共有資源へ同時アクセスする処理の衝突防止に使えます。
毎日自動更新する場合
何回か手動で更新し、入力列と計算結果が合っていることを確認してから、メニュー「毎日8時ごろに自動更新」を押します。
同じ処理の既存トリガーだけを削除してから1つ作るため、設定を押すたびにトリガーが増えません。解除する場合は「自動更新を解除」を押します。
Apps Scriptの時間主導型トリガーは、指定時刻ちょうどではなく、その時間帯の中で時刻が多少ずれることがあります。8時ちょうどの処理を保証するものではありません。
入力で起きやすい問題
1. 発注残を0に戻し忘れる
入荷済みなのに「発注残」へ数量が残っていると、在庫を二重に数えます。入荷したら、現在庫へ加算したうえで発注残を減らしてください。このコードは入荷実績までは取得できません。
2. 平均出庫の期間が短すぎる
数日分だけで平均を作ると、一時的な出庫増減の影響を強く受けます。季節商品やキャンペーン対象は、通常期間と繁忙期を分けて見直してください。コードは過去データから需要を予測しません。
3. 商品コードの大文字・小文字が混ざる
item-001 と ITEM-001 は同じコードとして扱い、両方を入力エラーにします。片方だけを勝手に採用すると、別商品の在庫を上書きする危険があるためです。
4. 0を空欄として扱ってしまう
現在庫0や発注残0は有効な値です。一方、空欄は未入力として止めます。JavaScriptでは0が偽として扱われるため、if (!value) で判定すると0まで未入力になる落とし穴があります。このコードは空欄と0を分けて検査します。
検証した範囲
2026年9月6日に、配布コードをJavaScriptとして構文確認し、計算部分をNode.jsでテストしました。
| 確認項目 | 結果 |
|---|---|
| 発注点、入荷予定込み在庫、推奨発注数、在庫日数 | PASS |
| 発注点と同数の在庫を発注候補にする境界値 | PASS |
| 発注残がある場合に追加発注を止める | PASS |
| 小数、桁区切り、全角数字の入力 | PASS |
| 平均出庫0を通常在庫と分け、安全在庫割れだけ候補にする | PASS |
| 空欄、負数、単位付き文字列、目標日数の矛盾 | PASS |
| 大文字・小文字をまたぐ商品コード重複 | PASS |
| 入力エラー優先・在庫日数順の候補一覧 | PASS |
合計37項目のアサーションが通っています。
実機ではまだ確かめていないこと
この無人実行ではGoogleアカウントのブラウザ操作を行っていないため、次は未検証です。
- 実際のGoogleスプレッドシートで初期2シートと条件付き書式が作られること
- 初回承認とメニュー表示
- 手動更新で入力列を保ったまま計算列だけを書き換えること
- 時間主導型トリガーの作成・解除と翌日の実行
配布ページを使う場合は、まずサンプル3行の結果を確認し、自分の在庫表はコピーで試してください。
公式仕様として確認したこと
- Range.setValues は、書き込み範囲と同じ大きさの2次元配列を必要とする
- Lock service は、共有資源への同時アクセスを防ぐ
- Installable triggers の時間主導型トリガーは、指定した時間帯の中で実行時刻が多少ずれる
コード
以下を全文コピーしてApps Scriptへ貼り付けてください。
/**
* 在庫の発注点を判定し、発注候補一覧を作るGoogle Apps Script
* 事務自動化ラボ https://jimu-jidoka.vercel.app/
*
* 使い方:
* 1. スプレッドシートに貼り付けて保存
* 2. シートを再読み込み
* 3. メニュー「在庫発注」→「初期シートを作成」
* 4. 入力後、「発注候補を更新」
*/
const INVENTORY_CFG = {
INPUT_SHEET: '在庫',
OUTPUT_SHEET: '発注候補',
HEADER_ROW: 1,
FIRST_DATA_ROW: 2,
INPUT_COLUMNS: 8,
OUTPUT_COLUMNS: 6,
DAILY_TRIGGER_HOUR: 8,
};
const INVENTORY_HEADERS = [
'商品コード', '商品名', '現在庫', '発注残', '1日平均出庫', '調達日数', '安全在庫', '補充後の目標日数',
'発注点', '入荷予定込み在庫', '判定', '推奨発注数', '在庫日数', '入力メモ',
];
const REORDER_HEADERS = [
'商品コード', '商品名', '判定', '現在庫', '入荷予定込み在庫', '発注点', '推奨発注数', '入力メモ',
];
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('在庫発注')
.addItem('初期シートを作成', 'setupInventorySheets')
.addItem('発注候補を更新', 'updateReorderList')
.addSeparator()
.addItem('毎日8時ごろに自動更新', 'createDailyInventoryTrigger')
.addItem('自動更新を解除', 'deleteDailyInventoryTrigger')
.addToUi();
}
function setupInventorySheets() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let input = ss.getSheetByName(INVENTORY_CFG.INPUT_SHEET);
let output = ss.getSheetByName(INVENTORY_CFG.OUTPUT_SHEET);
// 既存シートの見出しが違う場合は、新しいシートやサンプル行を作る前に停止する。
if (input && input.getLastRow() > 0) validateHeader_(input, INVENTORY_HEADERS, INVENTORY_CFG.INPUT_SHEET);
if (output && output.getLastRow() > 0) validateHeader_(output, REORDER_HEADERS, INVENTORY_CFG.OUTPUT_SHEET);
input = input || ss.insertSheet(INVENTORY_CFG.INPUT_SHEET);
output = output || ss.insertSheet(INVENTORY_CFG.OUTPUT_SHEET);
const inputWasEmpty = input.getLastRow() === 0;
const outputWasEmpty = output.getLastRow() === 0;
if (inputWasEmpty) {
input.getRange(1, 1, 1, INVENTORY_HEADERS.length).setValues([INVENTORY_HEADERS]);
input.getRange(2, 1, 3, INVENTORY_CFG.INPUT_COLUMNS).setValues([
['ITEM-001', '梱包箱 S', 42, 0, 6, 5, 20, 21],
['ITEM-002', '発送ラベル', 180, 100, 18, 3, 50, 14],
['ITEM-003', '緩衝材', 75, 0, 0, 7, 30, 21],
]);
}
if (outputWasEmpty) {
output.getRange(1, 1, 1, REORDER_HEADERS.length).setValues([REORDER_HEADERS]);
}
validateHeader_(input, INVENTORY_HEADERS, INVENTORY_CFG.INPUT_SHEET);
validateHeader_(output, REORDER_HEADERS, INVENTORY_CFG.OUTPUT_SHEET);
formatInventorySheets_(input, output, inputWasEmpty, outputWasEmpty);
updateReorderList();
}
function updateReorderList() {
const lock = LockService.getDocumentLock();
if (!lock || !lock.tryLock(5000)) {
throw new Error('別の在庫更新が実行中です。終了してから再実行してください。');
}
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const input = ss.getSheetByName(INVENTORY_CFG.INPUT_SHEET);
const output = ss.getSheetByName(INVENTORY_CFG.OUTPUT_SHEET);
if (!input || !output) {
throw new Error('先にメニュー「在庫発注」→「初期シートを作成」を実行してください。');
}
validateHeader_(input, INVENTORY_HEADERS, INVENTORY_CFG.INPUT_SHEET);
validateHeader_(output, REORDER_HEADERS, INVENTORY_CFG.OUTPUT_SHEET);
const lastRow = Math.max(input.getLastRow(), INVENTORY_CFG.FIRST_DATA_ROW);
const rowCount = Math.max(0, lastRow - INVENTORY_CFG.HEADER_ROW);
const inputRows = rowCount
? input.getRange(INVENTORY_CFG.FIRST_DATA_ROW, 1, rowCount, INVENTORY_CFG.INPUT_COLUMNS).getValues()
: [];
const result = buildInventoryResults_(inputRows);
if (rowCount) {
input.getRange(INVENTORY_CFG.FIRST_DATA_ROW, 9, rowCount, INVENTORY_CFG.OUTPUT_COLUMNS)
.setValues(result.outputRows);
}
const oldOutputRows = Math.max(0, output.getLastRow() - 1);
if (oldOutputRows) output.getRange(2, 1, oldOutputRows, REORDER_HEADERS.length).clearContent();
if (result.reorderRows.length) {
output.getRange(2, 1, result.reorderRows.length, REORDER_HEADERS.length)
.setValues(result.reorderRows);
}
output.getRange('J1').setValue('最終更新');
output.getRange('K1').setValue(new Date()).setNumberFormat('yyyy-mm-dd hh:mm');
SpreadsheetApp.flush();
if (arguments.length === 0) {
SpreadsheetApp.getUi().alert(
'在庫発注',
'発注候補 ' + result.reorderCount + '件 / 入力確認 ' + result.errorCount + '件',
SpreadsheetApp.getUi().ButtonSet.OK
);
}
return { reorderCount: result.reorderCount, errorCount: result.errorCount };
} finally {
lock.releaseLock();
}
}
function createDailyInventoryTrigger() {
const deleted = deleteInventoryTriggers_();
ScriptApp.newTrigger('updateReorderList')
.timeBased()
.atHour(INVENTORY_CFG.DAILY_TRIGGER_HOUR)
.everyDays(1)
.create();
SpreadsheetApp.getUi().alert(
'在庫発注',
'毎日8時台の自動更新を設定しました。既存の同名トリガー削除: ' + deleted + '件',
SpreadsheetApp.getUi().ButtonSet.OK
);
}
function deleteDailyInventoryTrigger() {
const deleted = deleteInventoryTriggers_();
SpreadsheetApp.getUi().alert(
'在庫発注',
'自動更新トリガーを' + deleted + '件解除しました。',
SpreadsheetApp.getUi().ButtonSet.OK
);
}
function deleteInventoryTriggers_() {
const triggers = ScriptApp.getProjectTriggers();
let deleted = 0;
for (let i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === 'updateReorderList') {
ScriptApp.deleteTrigger(triggers[i]);
deleted++;
}
}
return deleted;
}
function buildInventoryResults_(rows) {
const normalizedCodes = rows.map(function (row) {
return normalizeCode_(row[0]);
});
const counts = {};
normalizedCodes.forEach(function (code) {
if (code) counts[code] = (counts[code] || 0) + 1;
});
const outputRows = [];
const candidates = [];
let reorderCount = 0;
let errorCount = 0;
rows.forEach(function (row, index) {
const duplicate = normalizedCodes[index] && counts[normalizedCodes[index]] > 1;
const analyzed = analyzeInventoryRow_(row, duplicate);
outputRows.push(analyzed.output);
if (analyzed.kind === 'empty') return;
if (analyzed.status === '入力エラー') errorCount++;
if (analyzed.status === '発注候補') reorderCount++;
if (analyzed.status === '入力エラー' || analyzed.status === '発注候補') {
candidates.push({
priority: analyzed.status === '入力エラー' ? 0 : 1,
days: typeof analyzed.daysCover === 'number' ? analyzed.daysCover : Number.POSITIVE_INFINITY,
row: [
analyzed.code,
analyzed.name,
analyzed.status,
analyzed.currentStock,
analyzed.projectedStock,
analyzed.reorderPoint,
analyzed.recommendedOrder,
analyzed.note,
],
});
}
});
candidates.sort(function (a, b) {
return a.priority - b.priority || a.days - b.days || String(a.row[0]).localeCompare(String(b.row[0]));
});
return {
outputRows: outputRows,
reorderRows: candidates.map(function (candidate) { return candidate.row; }),
reorderCount: reorderCount,
errorCount: errorCount,
};
}
function analyzeInventoryRow_(row, duplicateCode) {
const code = normalizeCode_(row[0]);
const name = normalizeText_(row[1]);
const numericCells = row.slice(2, 8);
const isEmpty = !code && !name && numericCells.every(function (value) { return value === '' || value === null; });
if (isEmpty) return emptyAnalysis_();
const labels = ['現在庫', '発注残', '1日平均出庫', '調達日数', '安全在庫', '補充後の目標日数'];
const numbers = [];
const errors = [];
if (!code) errors.push('商品コードが空です');
if (!name) errors.push('商品名が空です');
if (duplicateCode) errors.push('商品コードが重複しています');
numericCells.forEach(function (value, index) {
const parsed = parseNonNegativeNumber_(value);
numbers.push(parsed);
if (parsed === null) errors.push(labels[index] + 'は0以上の数値で入力してください');
});
if (errors.length) return errorAnalysis_(code, name, numbers, errors);
const currentStock = numbers[0];
const orderedStock = numbers[1];
const dailyUsage = numbers[2];
const leadDays = numbers[3];
const safetyStock = numbers[4];
const targetDays = numbers[5];
if (targetDays <= leadDays) {
return errorAnalysis_(code, name, numbers, ['補充後の目標日数は調達日数より大きくしてください']);
}
const projectedStock = currentStock + orderedStock;
const reorderPoint = Math.ceil(dailyUsage * leadDays + safetyStock);
if (dailyUsage === 0) {
const belowSafety = projectedStock < safetyStock;
const status = belowSafety ? '発注候補' : '使用量0・確認';
const recommended = belowSafety ? Math.ceil(safetyStock - projectedStock) : 0;
const note = belowSafety
? '平均出庫が0のため、安全在庫までの不足分だけを表示しています'
: '平均出庫が0です。対象期間と休眠在庫かどうかを確認してください';
return successAnalysis_(code, name, currentStock, projectedStock, reorderPoint, status, recommended, '', note);
}
const targetStock = Math.ceil(dailyUsage * targetDays + safetyStock);
const status = projectedStock <= reorderPoint ? '発注候補' : '在庫内';
const recommendedOrder = status === '発注候補' ? Math.max(0, targetStock - projectedStock) : 0;
const daysCover = projectedStock / dailyUsage;
const note = status === '発注候補'
? '入荷予定込み在庫が発注点以下です'
: '入荷予定込み在庫が発注点を上回っています';
return successAnalysis_(
code,
name,
currentStock,
projectedStock,
reorderPoint,
status,
recommendedOrder,
daysCover,
note
);
}
function successAnalysis_(code, name, currentStock, projectedStock, reorderPoint, status, recommendedOrder, daysCover, note) {
return {
kind: 'data', code: code, name: name, currentStock: currentStock,
projectedStock: projectedStock, reorderPoint: reorderPoint, status: status,
recommendedOrder: recommendedOrder, daysCover: daysCover, note: note,
output: [reorderPoint, projectedStock, status, recommendedOrder, daysCover, note],
};
}
function errorAnalysis_(code, name, numbers, errors) {
const currentStock = numbers[0] === null || numbers[0] === undefined ? '' : numbers[0];
const orderedStock = numbers[1] === null || numbers[1] === undefined ? '' : numbers[1];
const projected = currentStock === '' || orderedStock === '' ? '' : currentStock + orderedStock;
const note = errors.join(' / ');
return {
kind: 'data', code: code, name: name, currentStock: currentStock,
projectedStock: projected, reorderPoint: '', status: '入力エラー',
recommendedOrder: '', daysCover: '', note: note,
output: ['', projected, '入力エラー', '', '', note],
};
}
function emptyAnalysis_() {
return {
kind: 'empty', code: '', name: '', currentStock: '', projectedStock: '',
reorderPoint: '', status: '', recommendedOrder: '', daysCover: '', note: '',
output: ['', '', '', '', '', ''],
};
}
function parseNonNegativeNumber_(value) {
if (value === '' || value === null || value === undefined || typeof value === 'boolean') return null;
const normalized = String(value)
.replace(/[0-9.-]/g, function (char) {
if (char === '.') return '.';
if (char === '-') return '-';
return String.fromCharCode(char.charCodeAt(0) - 0xFEE0);
})
.replace(/[,,\s]/g, '');
if (!/^(?:\d+\.?\d*|\.\d+)$/.test(normalized)) return null;
const number = Number(normalized);
return Number.isFinite(number) && number >= 0 ? number : null;
}
function normalizeCode_(value) {
return normalizeText_(value).toUpperCase();
}
function normalizeText_(value) {
return String(value === null || value === undefined ? '' : value).trim();
}
function validateHeader_(sheet, expected, label) {
const actual = sheet.getRange(1, 1, 1, expected.length).getDisplayValues()[0];
for (let i = 0; i < expected.length; i++) {
if (actual[i] !== expected[i]) {
throw new Error(label + 'シートの見出しが違います。' + (i + 1) + '列目は「' + expected[i] + '」にしてください。');
}
}
}
function formatInventorySheets_(input, output, formatInput, formatOutput) {
if (formatInput) {
input.setFrozenRows(1);
input.getRange(1, 1, 1, INVENTORY_HEADERS.length)
.setBackground('#17324d').setFontColor('#ffffff').setFontWeight('bold');
input.getRange('A2:H1000').setBackground('#e9f3ff');
input.getRange('C2:H1000').setNumberFormat('0.00');
input.getRange('I2:N1000').setBackground('#f3f5f7');
input.setColumnWidths(1, 14, 120);
input.setColumnWidth(2, 180);
input.setColumnWidth(14, 340);
const nonNegative = SpreadsheetApp.newDataValidation()
.requireNumberGreaterThanOrEqualTo(0)
.setAllowInvalid(false)
.setHelpText('0以上の数値を入力してください')
.build();
input.getRange('C2:H1000').setDataValidation(nonNegative);
const statusRange = input.getRange('K2:K1000');
const candidateRule = SpreadsheetApp.newConditionalFormatRule()
.whenTextEqualTo('発注候補').setBackground('#fff0d8').setFontColor('#9a4f00').setRanges([statusRange]).build();
const errorRule = SpreadsheetApp.newConditionalFormatRule()
.whenTextEqualTo('入力エラー').setBackground('#ffe3e3').setFontColor('#9f1d1d').setRanges([statusRange]).build();
input.setConditionalFormatRules([candidateRule, errorRule]);
}
if (formatOutput) {
output.setFrozenRows(1);
output.getRange(1, 1, 1, REORDER_HEADERS.length)
.setBackground('#17324d').setFontColor('#ffffff').setFontWeight('bold');
output.setColumnWidths(1, 8, 130);
output.setColumnWidth(2, 180);
output.setColumnWidth(8, 360);
}
}
ここまでで足りなくなったら
この無料版は、1つの在庫表から発注候補を作るところまでです。仕入れと販売を請求・入金まで一体で管理するものではありません。
請求側の入力、PDF発行、発行履歴、二重発行の警告まで必要な場合は、請求書自動作成スプレッドシート(GAS)の詳細ページに機能と検証範囲をまとめています。在庫管理だけで足りる場合は、この無料コードをそのまま使ってください。