文化祭で注文システムを作ってレシートで遊んだ話

## はじめに
今年も豊橋技術科学大学の文化祭である**技科大祭**が開催されました．そこで出店する模擬店で利用するために，注文システムを開発しました．

この記事では，そのシステムの話を中心に書いています．

開発経験の浅い初学者のため，間違ったことを書いているかもしれません．そのような点がございましたら，コメント等で教えて頂ければ幸いです．

:::note warn
注文システムの開発や記事の公開については，私個人の判断で行っています．
大学やサークル，その他の関係者へのお問い合わせはご遠慮くださいますようお願いいたします．
:::

## 経緯
私の所属するサークルが出店する模擬店では，これまでは注文を手書きでとっており，注文管理や技科大祭終了後の集計が面倒であるという問題がありました．

そこで，この問題を解決するために**注文システム**を開発することになりました．私に開発を任されたため，せっかくならWebアプリを用意した方がかっこいいし使いやすいだろうということで，勉強中の`React+TypeScript`で開発することにしました．手っ取り早く注文システムを作りたいなら**スプレッドシートでマクロを組んで共有する**のでもよいと思いますが，かっこよさを優先しました．

また，趣味でレシートプリンタを買ったため，ついでにそれを使って遊ばせてもらうことにしました．

![receiptprinter](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/b51541fb-dcdb-27f1-f3a5-925d9b136c6f.jpeg)
ある日突然レシートを印刷したくてたまらなくなってしまった

## 開発・動作環境
### 開発環境
- `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`](https://www.ofsc.or.jp/receiptline_/)を利用し，それを印刷することにしました．

ちゃんと調べてから買えばよかったという学びです．いつか今回買ったものを直接操作できるようにしたいです．

## 注文発行システム
### 概要
PC3台を無線(Wi-Fi)で接続するためにルーターを設置し，LANを構築しました．意外と知られていないというか，勘違いされていることが多いのですが，Wi-Fiとインターネットは別物です．**ルーターは，それ自体がインターネットに接続されていなくとも，Wi-Fiを飛ばすことができます．** つまり，ルーターをDHCPサーバとして利用しているということです(この認識であっていますか？)．

レシートプリンタはUSB接続のため，レジ用PCに接続して利用しました．自宅でテストしているときはプリントする際の音の大きさが気になっていたのですが，当日は広い部屋で周りの音もあったため，特に気になりませんでした．

注文システムにおけるフロントエンド-バックエンド間の通信には，`SWR`の`refreshInterval`による**ポーリング**を用いています．これは，一定時間ごとにデータの更新をチェックする手法です．この後詳しく書きますが，レジ用PCはフロントエンド(注文の入力)とバックエンド(サーバ)の役割を担っており，レジ用PCとキッチン用PCの間の通信はサーバを介して行います．そのとき，サーバからの受信の部分でポーリングを利用しています．

イベントリスナーでもよかった気はしますが，リアルタイム性をそこまで重視しなくてよかったのと，ポーリングのほうが実装が楽そうだったのでこのようにしました．

### フロントエンド
用意した画面は`OrderInput`と`KitchenView`，`ServedOrdersView`の3種類です．この後これらの画面について説明します．

UIフレームワークには`TailwindCSS`を利用しています．直感的にデザインできるので，今回のようにあまり複雑でない画面のデザインは簡単に書けて嬉しいです．

#### `OrderInput`画面
注文を入力する画面です．注文された商品の個数を`+`ボタンや`-`ボタンで変更し，テイクアウトならチェックを入れ，イベント用の割引券を持っているならその枚数の入力，預かり金額の入力，最後に`注文を送信`ボタンで注文を確定します．

![orderinput](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/4d3311d7-70c3-459e-dd94-ed60bdbeadd8.png)

注文画面が縦長なのは，`KitchenView`や`ServedOrdersView`と合わせて縦長にしたかったためです．これら2つの画面は，時系列順がわかりやすい縦方向に情報を追加していく必要があり，かつ万が一に備えてスマホから操作できるようにする必要がありました．私のサブモニターが縦置きで，そこに映していたため見やすく感じていたわけではありません．

また，どの画面でも，念のため各画面へのリンクを設置していますが，使うことはありませんでした．

「合計金額」や「お釣り」の値は，各商品の個数ボタンや預かり金額を入力すると即座に変更が反映されます．例えば，割引券を考慮した合計金額の計算は以下のように行います．

```tsx
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`しています．

```typescript
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`が付きます．かわいいですね．

![receipt_compare](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/dd0d69cd-34e1-3e48-8c47-10dbf06228ee.jpeg)
こんな感じのレシートを印刷していました．空白部分にはQRコードを設置していました．

#### `KitchenView`画面
キッチン係とホール係の人が見る画面です．注文が上から古い順に並べられており，「提供しました」ボタンを押すとグレーアウトし，一つの注文単位に含まれるすべての商品が提供されると，この画面からは削除されます．

![KitchenView](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/3adeb768-a15b-6711-08df-1db5ba4b0835.png)


サーバを介して注文内容を受け取る際，商品とその個数，`isServed`プロパティ，`isTakeout`プロパティ，さらにサーバで保持しているオーダーIDを受け取ります．オーダーIDはレシートにも印刷される数で，注文単位ごとに一つ与えられます．

各商品に対する`提供しました or Served`ボタンは以下のようになっています．

```tsx
<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`で注文を探して表示を更新しています．

```tsx
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`しています．

```typescript
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`画面
提供済みの注文一覧を見られる画面です．どの注文を提供し終わったかとかの確認をしたいときのために作りました．基本的には見ない画面です．

![servedordersview](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/e4b51236-f420-89fb-f728-e66f40c361e1.png)

変更があったら更新するだけなので，やってることは`KitchenView`と同じです．

### バックエンド
`Express.js`を利用しています．各ページとの通信や，ファイルの保存，レシート画像(`.svg`)の生成・発行を行います．提供された注文の`.json`を保存することで，売り上げに関する統計を取ることができます．

`OrderInput`からの注文を受信し，それを`KitchenView`に送信すると同時にレシートを発行．`KitchenView`内での変更を同期し，一つの注文が全て提供されると，`ServedOrdersView`に送信し，`KitchenView`からはその注文を削除するという流れになっています．

レシート内容は以下のように作成しています．`generateReceipt`がレシート本体，`generateOrderId`がテーブルに置いてもらったり，テイクアウトの際に利用したりする引換券です．

```javascript
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秒程度かかりました．

```javascript
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メモとかを印刷してボードに貼ったりしたいです．今度それに関する記事を書く予定です．

また，注文システム開発にあたり，アドバイスをくれた友人にはとても感謝しています．ありがとうございました．

最後に私の趣味で買ったカルトンで締めます．これも文化祭で活躍していました．最近は，財布からあふれた小銭やしまうのが面倒な小物を雑に散らかして使っています．

ここまで読んでいただき，ありがとうございました．

![カルトン](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3898648/1c488238-a4cc-c2b0-446c-0a4c1690ae5e.jpeg)