NFCタグを活用したワイン在庫管理システム(倉庫・店舗対応版)の設計を書いてみます。
倉庫が複数あり、出荷先の店舗も管理する形を想定してみましょう。
このシステムでは 「どの倉庫にどのワインが何本あるか」、「どの倉庫からどの店舗へ出荷されたか」 を記録することを目的としています。
1. URLツリーと画面
URLツリー
/login - ログイン画面
/dashboard - ダッシュボード
/wines - ワイン一覧
/wines/create - ワイン登録
/wines/{wine_id} - ワイン詳細
/wines/{wine_id}/edit - ワイン編集
/bottles/register - NFCタグ登録(倉庫内在庫追加)
/bottles/ship - NFCタグ出荷(倉庫から店舗へ)
/bottles/{tag_id} - ボトル詳細(NFCタグ読み取り後)
/transactions - 在庫変更履歴(追加/出荷)
/warehouses - 倉庫一覧
/warehouses/create - 倉庫登録
/stores - 店舗一覧
/stores/create - 店舗登録
/settings - 設定画面(管理者用)
2. 業務フローと画面紹介
📌 1. ログイン (/login
)
- 管理者はメールアドレスとパスワードでログイン。
- 認証成功後、ダッシュボードへ遷移。
📌 2. ダッシュボード (/dashboard
)
- 全倉庫のワイン在庫サマリを表示(倉庫ごとの在庫数、最新出荷記録など)。
- 倉庫・ワイン管理の各画面へのリンクを提供。
📌 3. ワイン登録 (/wines/create
)
- 新しいワインを登録(ワイン名、ヴィンテージ、産地、価格など)。
- 登録後、「倉庫に在庫を追加」ボタンをクリックし、NFCタグ登録へ。
📌 4. 倉庫登録 (/warehouses/create
)
- 倉庫の基本情報を登録(倉庫名、住所など)。
- 各倉庫ごとのワイン在庫を管理。
📌 5. 店舗登録 (/stores/create
)
- 店舗の基本情報を登録(店舗名、住所など)。
- 出荷時に出荷先店舗を選択。
📌 6. NFCタグ登録 (/bottles/register
)
- ワインのNFCタグをスキャン → 倉庫を選択し在庫に追加。
- システムがタグのIDを読み取り、
bottles
テーブルに保存。
📌 7. 在庫一覧 (/warehouses
)
- 各倉庫のワイン在庫状況を確認。
📌 8. NFCタグ読み取り (/bottles/{tag_id}
)
- NFCタグをスマホで読み取ると、該当ワイン情報と倉庫内在庫状況を表示。
📌 9. 出荷 (/bottles/ship
)
- NFCタグをスキャンし、「どの倉庫からどの店舗へ出荷するか」を選択。
- システムが
bottles
のstatus
をshipped
に更新し、transactions
テーブルに記録。
📌 10. 在庫変更履歴 (/transactions
)
- 在庫追加・出荷の履歴を一覧表示(倉庫ごと・店舗ごとでフィルタ可能)。
3. データベース設計(倉庫・店舗対応)
users(管理者情報)
Column | Type | Description |
---|---|---|
id | BIGINT (PK) | ユーザーID |
name | VARCHAR(255) | 名前 |
VARCHAR(255) | メールアドレス | |
password | VARCHAR(255) | パスワード(ハッシュ化) |
created_at | TIMESTAMP | 登録日時 |
updated_at | TIMESTAMP | 更新日時 |
wines(ワイン情報)
Column | Type | Description |
---|---|---|
wine_id | BIGINT (PK) | ワインID |
name | VARCHAR(255) | ワイン名 |
vintage | INT | ヴィンテージ(年) |
region | VARCHAR(255) | 産地 |
price | DECIMAL(10,2) | 価格 |
created_at | TIMESTAMP | 登録日時 |
updated_at | TIMESTAMP | 更新日時 |
warehouses(倉庫情報)
Column | Type | Description |
---|---|---|
warehouse_id | BIGINT (PK) | 倉庫ID |
name | VARCHAR(255) | 倉庫名 |
location | TEXT | 住所 |
created_at | TIMESTAMP | 登録日時 |
updated_at | TIMESTAMP | 更新日時 |
stores(店舗情報)
Column | Type | Description |
---|---|---|
store_id | BIGINT (PK) | 店舗ID |
name | VARCHAR(255) | 店舗名 |
location | TEXT | 住所 |
created_at | TIMESTAMP | 登録日時 |
updated_at | TIMESTAMP | 更新日時 |
bottles(ボトル/NFCタグ情報)
Column | Type | Description |
---|---|---|
tag_id | VARCHAR(64) (PK) | NFCタグの識別子 |
wine_id | BIGINT (FK) | 紐付けるワインのID(外部キー) |
warehouse_id | BIGINT (FK) | 現在の倉庫ID(出荷時にNULLになる) |
status | ENUM | in_stock (在庫)or shipped (出荷済) |
added_at | TIMESTAMP | 在庫追加日時 |
shipped_at | TIMESTAMP | 出荷日時 |
transactions(在庫履歴)
Column | Type | Description |
---|---|---|
transaction_id | BIGINT (PK) | 取引ID |
tag_id | VARCHAR(64) (FK) | 対象ボトル(外部キー) |
wine_id | BIGINT (FK) | 関連ワインのID(外部キー) |
warehouse_id | BIGINT (FK) | 出荷元の倉庫ID |
store_id | BIGINT (FK) | 出荷先の店舗ID |
action | ENUM | ADD (追加)or SHIP (出荷) |
timestamp | TIMESTAMP | 実行日時 |
user_id | BIGINT (FK) | 実行した管理者(外部キー) |
4. 業務フロー
- ワインを登録(
/wines/create
) - 倉庫にNFCタグを登録(
/bottles/register
) - 倉庫ごとに在庫を管理(
/warehouses
) - 出荷時にNFCタグをスキャンし、倉庫→店舗へ移動(
/bottles/ship
) - 在庫変更履歴を記録(
/transactions
)
業務を詳しくてみていきます。
1. ワインを登録(/wines/create)
📌 人の動き
- 管理者はワインの基本情報(名前、ヴィンテージ、産地、価格)をシステムに登録する。
- ワイン情報を登録した後、そのワインのNFCタグを倉庫に紐付ける必要がある。
2. 倉庫にNFCタグを登録(/bottles/register)
📌 人の動き
- 管理者は登録済みのワインにNFCタグを紐付けるため、スマホでタグをスキャン。
- 画面で倉庫を選択し、「在庫に追加」ボタンをクリックする。
3. 倉庫ごとに在庫を管理(/warehouses)
📌 人の動き
- 管理者は各倉庫にあるワインの在庫を一覧で確認。
- 必要があれば在庫の移動や追加を実施。
4. 出荷時にNFCタグをスキャン(/bottles/ship)
📌 人の動き
- 管理者は出荷するワインのNFCタグをスマホでスキャン。
- 画面で出荷元の倉庫と配送先の店舗を選択。
- 「出荷する」ボタンを押すと在庫が更新される。
5. 在庫変更履歴を記録(/transactions)
📌 人の動き
- 管理者は在庫の追加・出荷履歴を確認。
- 出荷履歴をフィルタして、倉庫ごとの動きをチェック。
ラベルにスマフォをかざして在庫をプラスする時のapiとUI
1. Laravel(APIサーバー)
📍 routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\InventoryController;
Route::post('/scan-nfc', [InventoryController::class, 'registerOrUpdate']);
📍 app/Http/Controllers/InventoryController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Bottle;
use App\Models\Wine;
use App\Models\Transaction;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
class InventoryController extends Controller
{
public function registerOrUpdate(Request $request)
{
$tagId = $request->input('tag_id');
$warehouseId = $request->input('warehouse_id'); // どの倉庫か
$wineId = $request->input('wine_id'); // どのワインか
if (!$tagId || !$warehouseId || !$wineId) {
return response()->json(['error' => 'Invalid data'], 400);
}
return DB::transaction(function () use ($tagId, $warehouseId, $wineId) {
// 既に登録されたボトルか確認
$bottle = Bottle::where('tag_id', $tagId)->first();
if (!$bottle) {
// 新規登録
$bottle = Bottle::create([
'tag_id' => $tagId,
'wine_id' => $wineId,
'warehouse_id' => $warehouseId,
'status' => 'in_stock',
'added_at' => now(),
]);
// 在庫を+1
Wine::where('wine_id', $wineId)->increment('stock_count');
// 在庫追加履歴を記録
Transaction::create([
'tag_id' => $tagId,
'wine_id' => $wineId,
'warehouse_id' => $warehouseId,
'action' => 'ADD',
'timestamp' => now(),
]);
return response()->json(['message' => 'New bottle registered and stock increased']);
}
return response()->json(['message' => 'Tag already exists, no change'], 200);
});
}
}
📍 app/Models/Bottle.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Bottle extends Model
{
use HasFactory;
protected $fillable = [
'tag_id',
'wine_id',
'warehouse_id',
'status',
'added_at',
];
public $timestamps = false;
}
📍 app/Models/Wine.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Wine extends Model
{
use HasFactory;
protected $fillable = [
'wine_id',
'name',
'vintage',
'region',
'price',
'stock_count',
];
}
📍 app/Models/Transaction.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Transaction extends Model
{
use HasFactory;
protected $fillable = [
'tag_id',
'wine_id',
'warehouse_id',
'action',
'timestamp',
];
}
2. Next.js(NFCタグスキャン + API連携)
📍 components/NFCScanner.tsx
import { useState, useEffect } from 'react';
const NFCScanner = () => {
const [tagId, setTagId] = useState<string | null>(null);
const [message, setMessage] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
if ("NDEFReader" in window) {
const nfcReader = new NDEFReader();
nfcReader.scan().then(() => {
nfcReader.onreading = (event) => {
const decoder = new TextDecoder();
for (const record of event.message.records) {
const tagData = decoder.decode(record.data);
setTagId(tagData);
handleRegister(tagData);
}
};
}).catch((error) => {
console.error("NFC読み取りエラー:", error);
});
}
}, []);
const handleRegister = async (tagId: string) => {
setLoading(true);
try {
const response = await fetch("/api/register-nfc", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
tag_id: tagId,
warehouse_id: 1, // 適当な倉庫ID
wine_id: 1 // 適当なワインID
})
});
const data = await response.json();
setMessage(data.message);
} catch (error) {
setMessage("エラーが発生しました");
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div className="container">
<h2>NFCスキャン</h2>
<p>タグをスマホにかざしてください</p>
{loading ? <p>読み取り中...</p> : <p>{message}</p>}
</div>
);
};
export default NFCScanner;
📍 pages/api/register-nfc.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
try {
const { tag_id, warehouse_id, wine_id } = req.body;
if (!tag_id || !warehouse_id || !wine_id) {
return res.status(400).json({ error: 'Invalid request' });
}
const apiResponse = await fetch(process.env.LARAVEL_API_URL + '/api/scan-nfc', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ tag_id, warehouse_id, wine_id })
});
const data = await apiResponse.json();
return res.status(apiResponse.status).json(data);
} catch (error) {
return res.status(500).json({ error: 'Server error' });
}
}
実装の流れ
- 管理者がスマホでNFCタグをかざす
- Next.jsのNFCScannerコンポーネントがタグのIDを取得
- Next.jsがLaravelのAPI
/api/scan-nfc
にリクエストを送る - Laravelがデータベースをチェック
- 未登録のタグなら → 新規登録 & 在庫を+1
- 既に登録済みなら → 何もしない
- レスポンスをNext.jsに返し、画面に結果を表示