はじめに
ハロー,昨日です.
今年も豊橋技術科学大学の文化祭である技科大祭が開催されました.そこで出店する模擬店で利用するために,注文システムを開発しました.
この記事では,そのシステムの話を中心に書いています.
開発経験の浅い初学者のため,間違ったことを書いているかもしれません.そのような点がございましたら,コメント等で教えて頂ければ幸いです.
注文システムの開発や記事の公開については,私個人の判断で行っています.
大学やサークル,その他の関係者へのお問い合わせはご遠慮くださいますようお願いいたします.
経緯
私の所属するサークルが出店する模擬店では,これまでは注文を手書きでとっており,注文管理や技科大祭終了後の集計が面倒であるという問題がありました.
そこで,この問題を解決するために注文システムを開発することになりました.私に開発を任されたため,せっかくならWebアプリを用意した方がかっこいいし使いやすいだろうということで,勉強中のReact+TypeScript
で開発することにしました.手っ取り早く注文システムを作りたいならスプレッドシートでマクロを組んで共有するのでもよいと思いますが,かっこよさを優先しました.
また,趣味でレシートプリンタを買ったため,ついでにそれを使って遊ばせてもらうことにしました.
開発・動作環境
開発環境
Windows OS
-
Vite
React
TypeScript
SWR
Express.js
ReceiptLine
TailwindCSS
動作環境
- ノートPC3台
- レジ用に1台,2か所に分かれているキッチン用に1台ずつ配置
- スマホ・タブレットでも利用可
-
IODATA WN-DX1200GR
- システム全体のLANを隔離するためのルーター
-
NEC MultiCoder 300S2DCU
- レシートプリンタ
レシートプリンタ
あまり調べずに買ったせいで問題が発生したため,ちょっと詳しく書きます.
まず,趣味で欲しくなったとはいえ,私の性格上すぐに飽きると考えました.そのため,信頼できそうなメーカーかつ安いものをオンラインショップで探しました.そこで見つけたのがNEC MultiCoder 300S2DCU
です.
買う前にちょっと調べた限りでは,有名なメーカーからは開発用のSDK
が配布されているものが多かったため,「何を買ってもおそらく大丈夫だろう」と考えて買ったのですが,今回買った機種はそれがありませんでした.
プリンタを操作するためのOPOS
アプリケーションは配布されていたのですが,使い方がよくわからず,開発時間もあまりとれなかったため,諦めてしまいました.
そこで,レシート内容をテキストで記述し,それを.svg
ファイルに変換してくれるnpm
ライブラリのReceiptLine
を利用し,それを印刷することにしました.
ちゃんと調べてから買えばよかったという学びです.いつか今回買ったものを直接操作できるようにしたいです.
注文発行システム
概要
PC3台を無線(Wi-Fi)で接続するためにルーターを設置し,LANを構築しました.意外と知られていないというか,勘違いされていることが多いのですが,Wi-Fiとインターネットは別物です.ルーターは,それ自体がインターネットに接続されていなくとも,Wi-Fiを飛ばすことができます. つまり,ルーターをDHCPサーバとして利用しているということです(この認識であっていますか?).
レシートプリンタはUSB接続のため,レジ用PCに接続して利用しました.自宅でテストしているときはプリントする際の音の大きさが気になっていたのですが,当日は広い部屋で周りの音もあったため,特に気になりませんでした.
注文システムにおけるフロントエンド-バックエンド間の通信には,SWR
のrefreshInterval
によるポーリングを用いています.これは,一定時間ごとにデータの更新をチェックする手法です.この後詳しく書きますが,レジ用PCはフロントエンド(注文の入力)とバックエンド(サーバ)の役割を担っており,レジ用PCとキッチン用PCの間の通信はサーバを介して行います.そのとき,サーバからの受信の部分でポーリングを利用しています.
イベントリスナーでもよかった気はしますが,リアルタイム性をそこまで重視しなくてよかったのと,ポーリングのほうが実装が楽そうだったのでこのようにしました.
フロントエンド
用意した画面はOrderInput
とKitchenView
,ServedOrdersView
の3種類です.この後これらの画面について説明します.
UIフレームワークにはTailwindCSS
を利用しています.直感的にデザインできるので,今回のようにあまり複雑でない画面のデザインは簡単に書けて嬉しいです.
OrderInput
画面
注文を入力する画面です.注文された商品の個数を+
ボタンや-
ボタンで変更し,テイクアウトならチェックを入れ,イベント用の割引券を持っているならその枚数の入力,預かり金額の入力,最後に注文を送信
ボタンで注文を確定します.
注文画面が縦長なのは,KitchenView
やServedOrdersView
と合わせて縦長にしたかったためです.これら2つの画面は,時系列順がわかりやすい縦方向に情報を追加していく必要があり,かつ万が一に備えてスマホから操作できるようにする必要がありました.私のサブモニターが縦置きで,そこに映していたため見やすく感じていたわけではありません.
また,どの画面でも,念のため各画面へのリンクを設置していますが,使うことはありませんでした.
「合計金額」や「お釣り」の値は,各商品の個数ボタンや預かり金額を入力すると即座に変更が反映されます.例えば,割引券を考慮した合計金額の計算は以下のように行います.
const totalPrice = (() => {
const newTotalPrice = Object.entries(orders).reduce(
(total, [item, quantity]) => total + productPrices[item as keyof typeof orders] * quantity,
0
) - couponCount * DISCOUNT_PER_COUPON;
return (newTotalPrice > 0 ? newTotalPrice : 0);
})();
注文を送信
ボタンでサーバへ注文を送信する際,商品とその個数,isServed
プロパティ,isTakeout
プロパティ,合計金額,預かり金額,お釣りを.json
形式で以下のようにサーバにPOST
しています.
export const addOrder = async (orderData: { items: { item: string }[], totalPrice: number, receivedAmount: number, change: number }) => {
const response = await fetch(`${apiUrl}/add-order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData),
});
if (!response.ok) throw new Error('Failed to add order');
};
テイクアウトのときは注文番号にT
が付きます.かわいいですね.
こんな感じのレシートを印刷していました.空白部分にはQRコードを設置していました.
KitchenView
画面
キッチン係とホール係の人が見る画面です.注文が上から古い順に並べられており,「提供しました」ボタンを押すとグレーアウトし,一つの注文単位に含まれるすべての商品が提供されると,この画面からは削除されます.
サーバを介して注文内容を受け取る際,商品とその個数,isServed
プロパティ,isTakeout
プロパティ,さらにサーバで保持しているオーダーIDを受け取ります.オーダーIDはレシートにも印刷される数で,注文単位ごとに一つ与えられます.
各商品に対する提供しました or Served
ボタンは以下のようになっています.
<button
onClick={() => handleServeItem(order.id, itemIndex)}
className={`ml-4 mb-2 px-2 py-1 rounded ${item.served ? 'bg-gray-500' : 'bg-green-500'} text-white`}
disabled={item.served}
>
{item.served ? 'Served' : '提供しました'}
</button>
また,それに対する処理は以下のようになっています.map
で注文を探して表示を更新しています.
const [localData, setLocalData] = useState<Order[] | undefined>(data);
const handleServeItem = async (orderId: number, itemIndex: number) => {
await markItemAsServed(orderId, itemIndex);
if (localData) {
const updatedOrders = localData.map((order) => {
if (order.id === orderId) {
return {
...order,
items: order.items.map((item, index) =>
index === itemIndex ? { ...item, served: true } : item
),
};
}
return order;
});
setLocalData(updatedOrders);
}
mutate();
};
また,orderId
とitemIndex
の.json
をサーバにPOST
しています.
export const markItemAsServed = async (orderId: number, itemIndex: number) => {
const response = await fetch(`${apiUrl}/mark-served`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ orderId, itemIndex }),
});
if (!response.ok) throw new Error('Failed to mark item as served');
};
ServedOrdersView
画面
提供済みの注文一覧を見られる画面です.どの注文を提供し終わったかとかの確認をしたいときのために作りました.基本的には見ない画面です.
変更があったら更新するだけなので,やってることはKitchenView
と同じです.
バックエンド
Express.js
を利用しています.各ページとの通信や,ファイルの保存,レシート画像(.svg
)の生成・発行を行います.提供された注文の.json
を保存することで,売り上げに関する統計を取ることができます.
OrderInput
からの注文を受信し,それをKitchenView
に送信すると同時にレシートを発行.KitchenView
内での変更を同期し,一つの注文が全て提供されると,ServedOrdersView
に送信し,KitchenView
からはその注文を削除するという流れになっています.
レシート内容は以下のように作成しています.generateReceipt
がレシート本体,generateOrderId
がテーブルに置いてもらったり,テイクアウトの際に利用したりする引換券です.
const generateReceipt = (order, maxOrderId) => {
const now = new Date();
const year = now.getFullYear();
const month = ('0' + (now.getMonth() + 1)).slice(-2);
const day = ('0' + now.getDate()).slice(-2);
const hours = ('0' + now.getHours()).slice(-2);
const minutes = ('0' + now.getMinutes()).slice(-2);
const daysOfWeek = ['日', '月', '火', '水', '木', '金', '土'];
const dayOfWeek = daysOfWeek[now.getDay()];
const printOrderId = order.isTakeout ? `T${maxOrderId + 1}` : `${maxOrderId + 1}`;
let quantity = 0;
let receiptText = `
{width: *,7}
{align: center}
{image:${logoData}}
{width: 3,*,3}
| |豊橋技術科学大学 Jazz研究会 | |
| |${year}/${month}/${day}(${dayOfWeek})${hours}:${minutes} | |
| |Order #${printOrderId} | |
{width: 3,*,*,5}
`;
order.items.forEach(item => {
quantity += 1;
receiptText += `| |${itemMapJa[item.item]} | ¥${item.price}| |\n`;
});
receiptText += `
{width: 3,*,*,5}
| |^"合計 | ${quantity}点 ¥${order.totalPrice.toLocaleString()}| |
| |お預かり | ¥${order.receivedAmount.toLocaleString()}| |
| |^"お釣り | ^¥${order.change.toLocaleString()}| |
{width:,2,*,2; border:none}
| ||☆ご来店ありがとうございました。☆| |
{code:${QR}; option:qrcode,5,H}
`;
return receiptText;
};
const generateOrderId = (order, maxOrderId) => {
const printOrderId = order.isTakeout ? `T${maxOrderId + 1}` : `${maxOrderId + 1}`;
let orderId = `
{width: 3,*,3}
| |^^^^^^^"#${printOrderId}| |
`;
return orderId;
};
レシートの印刷は,以下のようにChrome
のkiosk-printing
を利用してバックグラウンドで自動印刷します.PCによって印刷までにかかる時間がかなり違いました.性能の良いPCを使ったとき,OrderInput
の注文を送信
ボタンを押して1秒も経たずに印刷が開始されましたが,レジで利用した私のPCは印刷まで3秒程度かかりました.
exec(`"${chromePath}" --kiosk-printing --no-default-browser-check --disable-extensions "file:///${filePath.replace(/\\/g, '/')}"`, (error, stdout, stderr) => {
if (error) {
console.error(`Error opening Chrome: ${error.message}`);
return;
}
if (stderr) {
console.error(`stderr: ${stderr}`);
return;
}
console.log(`stdout: ${stdout}`);
});
当日の様子
当日,注文システムにはバグや不具合が出ることもなく,安定して動作していました.テストはしていましたが,開発時間が短かったのと,直前に機能追加をしていたのもあり,ずっと心配していました.無事に終了したときには本当に安心しました.
文化祭でレシートを発行したらお客さんは驚いたり面白がったりしてくれるかなと思っていましたが,意外とリアクションがあることは少なかったです.ただ,何人かは反応してくださって嬉しかったです.これからもレシートの楽しさを世に広めていきたいですね.
おわりに
注文システムを作るのは楽しかったですし,とても勉強になりました.レシートプリンタは直接命令できるようにしたり,簡単なTODOメモとかを印刷してボードに貼ったりしたいです.今度それに関する記事を書く予定です.
今回作成した注文システムですが,要望が多ければ,暇なときにコードを整理をしてからリポジトリを公開しようと考えています.今のまま公開してしまうと,React
を使っているのにその規則やマナーを守って書いていない部分がたくさんあるため,人が倒れます.
また,注文システム開発にあたり,アドバイスをくれた友人にはとても感謝しています.ありがとうございました.
最後に私の趣味で買ったカルトンで締めます.これも文化祭で活躍していました.最近は,財布からあふれた小銭やしまうのが面倒な小物を雑に散らかして使っています.
ここまで読んでいただき,ありがとうございました.