物販の在庫管理ダッシュボードに「追加・削除」と「誤操作対策」を実装した話
はじめに
前回の記事の続編です。
前回、DynamoDBベースの在庫管理ダッシュボード(S3 → API Gateway → Lambda → DynamoDB)を構築しましたが、使いやすくするために2点の機能を追加しました。
1つ目は、新商品の追加や廃盤商品の削除のたびに、CSVを編集してS3にアップロードし直す必要があったこと。ちょっとした変更のために毎回この手順を踏むのが地味に面倒でした。
2つ目は、在庫が少なくなったらSNSでメール通知が届く仕組みを前回作ったのですが、+/−ボタンを連打していると、誤操作で一瞬在庫が2以下になっただけでも通知が飛んでしまう問題がありました。
この2つを解消すべく、ダッシュボードに機能を追加しました。
システム構成
前回の構成に、今回追加した部分(★NEW)を書き足すとこうなります。
[スタッフのスマホ]
│ ブラウザでアクセス
▼
[S3(静的ウェブサイトホスティング)]
HTML / CSS / JavaScript のダッシュボード
│
├─ 在庫確認: GET /inventory
├─ 在庫更新: POST /inventory/update (★Confirm時のみ一括送信)
├─ 商品追加: POST /inventory/new (★NEW)
└─ 商品削除: POST /inventory/delete (★NEW)
▼
[API Gateway]
│
├──▶ [Lambda: GetInventoryList] ──▶ [DynamoDB] (読み取り専用)
├──▶ [Lambda: UpdateInventory] ──▶ [DynamoDB] (読み書き)
├──▶ [Lambda: AddNewProduct] ──▶ [DynamoDB] (★NEW・書き込み専用)
└──▶ [Lambda: deleteInventoryItem]──▶ [DynamoDB] (★NEW・削除専用)
│(UpdateInventoryのみ)
│ old_stock > 閾値 かつ new_stock <= 閾値 のときだけ
▼
[SNS: InventoryAlerts]
│
▼
[担当者のEメール]
新しく追加したLambda関数(AddNewProduct、deleteInventoryItem)には、それぞれ必要最小限のIAM権限(PutItem/GetItemのみ、DeleteItem/GetItemのみ)を持つ専用ロールを割り当てています。1つのLambdaに強い権限を持たせるのではなく、機能ごとにロールを分けることで、仮にコードにバグがあっても被害範囲を限定できるようにしました。
①ダッシュボードからの商品追加・削除
まずは、CSVの再インポートをやめて、ダッシュボード上で直接、商品の追加・削除ができるようにしました。
商品追加
「Add New Product」フォームを新設し、SKU・バリエーション・初期在庫数を入力して送信すると、POST /inventory/new 経由でDynamoDBに直接書き込まれるようにしました。
def lambda_handler(event, context):
try:
body = json.loads(event['body'])
sku = body['SKU']
variation = body['Variation']
initial_stock = int(body['Stock'])
# すでに同じ商品が存在しないか確認(重複登録を防ぐ)
existing = table.get_item(Key={'SKU': sku, 'Variation': variation})
if 'Item' in existing:
return {
'statusCode': 409, # 409 = 競合(すでに存在する)
'headers': {'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': f'{sku} / {variation} already exists'})
}
table.put_item(Item={'SKU': sku, 'Variation': variation, 'Stock': initial_stock})
...
同じSKU・バリエーションの組み合わせが既に存在する場合は、get_itemで事前にチェックして409エラーを返し、意図しない上書きを防いでいます。
商品削除
各行の右端に🗑ボタンを追加し、押すと確認ダイアログを挟んだ上で POST /inventory/delete を呼び出し、DynamoDBから該当アイテムを削除する仕組みにしました。
async function deleteProduct(sku, variation) {
const confirmed = confirm(`Delete "${sku} / ${variation}"?\n\nThis cannot be undone.`);
if (!confirmed) return;
const res = await fetch(BASE_URL + "/inventory/delete", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ SKU: sku, Variation: variation })
});
...
}
これで、廃盤商品が出るたびにCSVを触る必要がなくなり、スマホからその場で完結するようになりました。
②pending/confirm方式による誤操作対策
前回作ったSNS通知は「在庫が閾値(2個)以下になったら即座にメールを送る」というシンプルな設計でした。これが、+/−ボタンの誤操作に対して思いのほか敏感すぎることが運用してみて分かりました。例えば、間違えて−ボタンを数回押して2以下になり、慌てて+ボタンで戻しても、その一瞬でメールが飛んでしまいます。
設計方針
- 「+」「−」ボタンを押しても、即座にAPIを呼ばないようにする(表示だけ更新)
- 変更内容を、
pendingChangesという変数に一時保存 - 画面下部に「Confirm Changes」ボタンを新設。押した時に、まとめてAPIへ送信
- 未確定の変更がある行は、見た目で分かるようにする(数字をオレンジ色に)
実際の画面はこんな感じです。「−」を押すと在庫数がオレンジ色になり、画面下部に確認バーが表示されます。
フロントエンド側の実装
+/−ボタンを押した時点ではAPIを呼ばず、pendingChanges というオブジェクトに変更を溜めていきます。
let pendingChanges = {};
function adjustStock(sku, variation, currentServerStock, delta) {
const key = makeKey(sku, variation);
if (!pendingChanges[key]) {
pendingChanges[key] = { baseStock: currentServerStock, delta: 0 };
}
pendingChanges[key].delta += delta;
// 増減がゼロに戻ったら、保留リストから外す(見た目もリセット)
if (pendingChanges[key].delta === 0) {
delete pendingChanges[key];
}
...
}
ポイントは、+1と−1のように増減が相殺されてゼロに戻った場合、自動的に pendingChanges から削除している点です。これにより「押し間違えてすぐ戻した」場合はそもそも変更として扱われず、Confirmを押しても何も送信されません。
「Confirm Changes」を押すと、保留していた変更をまとめてAPIに送信します。
async function confirmAllUpdates() {
const keys = Object.keys(pendingChanges);
if (keys.length === 0) return;
const results = await Promise.allSettled(
keys.map(key => {
const [sku, variation] = key.split('___');
const change = pendingChanges[key].delta;
return fetch(BASE_URL + "/inventory/update", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ SKU: sku, Variation: variation, change: change })
});
})
);
...
}
バックエンド側の通知条件も見直し
フロント側でpending管理をしても、Confirmで複数商品をまとめて送信した際に、「すでに在庫が少なかった商品」まで再通知されては意味がありません。そこで、Lambda側の通知条件を「更新前(old_stock)が閾値より上で、更新後(new_stock)が閾値以下になった時だけ」に絞りました。
LOW_STOCK_THRESHOLD = 2
# 「今回の更新で、初めて閾値を下回った」場合だけ通知する
# (すでに閾値以下だった商品が、一括更新に巻き込まれて再通知されるのを防ぐ)
if old_stock > LOW_STOCK_THRESHOLD and new_stock <= LOW_STOCK_THRESHOLD:
try:
sns.publish(
TopicArn=TOPIC_ARN,
Subject='Low stock alert',
Message=f'{sku} / {variation} stock is now {new_stock}'
)
except Exception as sns_error:
print(f'SNS publish failed: {sns_error}')
フロント側の「相殺されたら送信しない」設計と、バックエンド側の「閾値を初めて跨いだ時だけ通知する」設計、この2つを組み合わせることで、誤操作による誤通知をほぼ防げるようになりました。
おわりに
前回に続き、今回も「必要最低限の機能を、必要になったタイミングで足していく」進め方になりました。特にpending/confirm方式は、最初から作ろうと思っていたわけではなく、実際に運用してみて「誤操作で無駄なメールが飛ぶ」という体験があったからこそ思いついた設計です。
作って終わりではなく、使いながら不満を見つけて直していくサイクルは、業務でのシステム運用にも通じるものがあるなと感じました。
今後の展望
前回同様、ShopeeやTikTok Shopなどの公開APIとも連携し、各ECサイト上の在庫もこの仕組みに取り込んで一元管理できるようにしていきたいと考えています。
