キャンフェス(キャンパス文化祭)でPOSシステムを作った話。

# 導入
## おはようございます　こっちゃんです
まず最初に自己紹介をします
- N高等学校
- 通学コース 大宮キャンパス
- 高校3年
- 進路から必死に逃走中。

:::note alert
12/2になりましたが書き終わりませんでした（泣）
なので追記しまくります。
:::


今回は、キャンパスフェスティバル(文化祭)でPOSシステムを構築した話です。

:::note info
このシステムは共同で制作したものです。
主な共同制作者:(https://github.com/chikinaN)
:::

## 事のながれ
イノキャン
(N/S高等学校大宮キャンパスの中で活動しているプログラミンググループの名称)

はキャンフェス(キャンパス文化祭)で紅茶を出すことになりました。
しかし自分たちはプログラミング勢の集まりなので、ただ飲料を提供するだけでは面白くないと考えたため、POSシステム、モバイルオーダーを作ろうと考えた。

# 全体の構成
![camfes.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/aacb1655-066d-478e-b10e-51c331214f9c.png)
矢印のついた線はSocket.ioの通信を示してます。
CF TunnnelはCloudflare Tunnelのことです。
が！今回は触れません。

# 開発に使ったもの

## ハードウェア

- レシートプリンタ
- iPad(2台)
    - POS(レジ)用
    - 厨房用
 - ラズパイ(2台)
    - APIサーバー用
    - HTTPサーバー用
 - モニター+スティックPC(2台)
     - 呼び出し画面表示用
     - 呼び出し画面表示用

## 使用した技術、言語
 - Node.js
 - Socket.io
 - SQL
 - HTML+Javascipt+CSS
 - React
 - Python

APIサーバーの役割は以下のようになっています
- 注文を受け取る
-  DB操作をする
- レシートを印刷する

# システム、コード解説、動作風景
システムやコードについての説明です。

## API

### 使用モジュール等
- **`express`**: HTTPサーバーを構築するためのフレームワーク。
- **`http`**: Node.jsのHTTPサーバーモジュール。
- **`socket.io`**: WebSocket通信を提供するモジュール。
- **`sync-mysql`**: 同期的にMySQLにアクセスするためのライブラリ。非同期だと動かない箇所があったため使用
- **`exec`**: 子プロセスを作成して外部コマンドを実行するための関数。Pythonスクリプトを実行するためだけに使用

### 注文を受け取る
```javascript
  let items = JSON.parse(message);
```
POSやモバイルなどからはJson形式で注文が飛んできます。

### 注文番号生成
```javascript
  let orderID = "";

  let max = connection.query('SELECT MAX(INTERNAL_ID) FROM order_list')[0]["MAX(INTERNAL_ID)"];

  if (items[0]["type"] == "mobile") { orderID="M"; }
  orderID += (max+1).toString().padStart(3, '0');
```
モバイルとPOSの注文が混同しないように区別しています

### データベースに注文を書き込み
```javascript
  time = Math.round((new Date()).getTime() / 1000);
  connection.query('INSERT INTO order_list (INTERNAL_ID, timestamp, orderID, flag) VALUES (NULL, ?, ?, 0)', [time, orderID]);

  items.forEach((item, index) => {
    if(index === 0){ return; }
    connection.query('INSERT INTO order_item (INTERNAL_ID, orderID, item, quantity) VALUES (NULL, ?, ?, ?)', [orderID, item["item"], item["quantity"]]);
  });
```
ここでテーブルが２つに分かれている理由はなぜでしょうか。
答えは、汎用性を持たせるためです。

もし途中で「新商品を追加する。」なんて事になったとしても対処できます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/6956806a-1015-bfde-173d-69c4d92fb000.png)

### レシート印刷
```javascript
  let array = { "status": "success", "orderID": orderID };

  exec('/usr/bin/python3 ../camfes/print.py '+print_arg, function(err, stdout, stderr) {
    if (stdout) console.log('stdout', stdout);
    if (stderr) console.log('stderr', stderr);
    if (err !== null) console.log('err', err);
  });
```
これが肝心のレシート印刷です。
このあとprint.pyについて触れるのですが、ここだけまさかのハードコーディングです。
print_argの中は
```
M001 1 2 3 4
```
のようになっています。
(左から注文番号、個数、個数、個数、個数)

### 後処理
```javascript
  socket.emit('order_end', array);

  new_data = [{ "orderID": orderID, "items": items.slice(1), "flag": 0 }];
  io.emit('order_share', new_data);
});
```
注文が通ったときにするべきことはまだあります。
注文表示用のモニターとキッチン端末の情報を更新しなければなりません。
そのため、APIに送られてきた注文を加工して、POSやモニターに送ります。

## 注文部(POS モバイル共通)

まずはAPIを叩き、商品名と商品価格を取得します
```js: 商品名と商品価格を取得するコード
    fetch(api_url+"/getprice")
      .then((response) => {
        return response.json()
      })
```

この実装にした理由ですが、急遽商品名や商品価格の変更があったとしても柔軟に対応するためです。ちなみに、stock(在庫)は使いませんでした。

## POS
![IMG_0003.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/adb29efb-499d-13b0-4936-fd7233053560.png)


```js: 注文送信
async function send_data(e) {
  e.preventDefault();
  if (document.querySelector("#total").textContent.charAt(0) == 0) {
    toastr.error('空注文はできません', 'エラー')
    return false;
  }

  let data = [];
  data.push({type: "pos"})
  Array.from(document.getElementById("menus").children).forEach((a) => {
    let amount = a.querySelectorAll(".amount")[0];
    data.push({item: amount.dataset.name, quantity: amount.value})
  });

  blockUI.showOverlayAsync();
  socket.emit('order', JSON.stringify(data));
}
```

注文を送信するコード、この部分だけ見ても一見なんのことかわからないでしょう。
それもそのはず、短い期間での開発なのでゴリ押し実装が結構多いです。

```js
 if (document.querySelector("#total").textContent.charAt(0) == 0) {
```
この部分は、合計値を表示する部分が「0円」の場合は注文を送信しないための処理です。
単価が0円の商品はないため。こんな処理で十分なのです。

```js
 Array.from(document.getElementById("menus").children).forEach((a) => {
```
商品の個数が変更になる可能性があったため、商品名が存在する回数ループすることで
各商品ごとの注文個数を取得しています。

## 厨房
![IMG_0004.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/5316e469-2aa7-777d-9fd8-791baf5acc8f.png)

## モニター
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/e2ffe3e4-e01d-7416-b5bc-7d376a6dae90.png)

## print.py
レシートプリンタを操作するためにPython-escposというライブラリを使用しました。

```python
p = Usb(0x416, 0x5011, in_ep=0x87, out_ep=0x06)
```
プリンタを指定するときにin_epとout_epを設定しないとうまく動きません。
最初に参考にしたサイトには書いていなかったのでつまずきました。

```python
#注文部表示
draw.text((10,310), "--------------------------------", font=fontA, fill=0)
if (int(args[2]) != 0):
    draw.text((10,350), "ｽﾄﾚｰﾄﾃｨｰ(ｱｲｽ)*{0: <3}  {1: >3,}ｶﾞﾘｵﾝ".format(int(args[2]),int(args[2])*STTEA_TANKA), font=fontAa, fill=0)

if (int(args[3]) != 0):
    draw.text((10,390), "ｽﾄﾚｰﾄﾃｨｰ(ﾎｯﾄ)*{0: <3}  {1: >3,}ｶﾞﾘｵﾝ".format(int(args[3]),int(args[3])*STTEA_TANKA), font=fontAa, fill=0)

if (int(args[4]) != 0):
    draw.text((10,430), "ﾐﾙｸﾃｨｰ(ｱｲｽ)*{0: <5}  {1: >3,}ｶﾞﾘｵﾝ".format(int(args[4]),int(args[4])*MILKTEA_TANKA), font=fontAa, fill=0)

if (int(args[5]) != 0):
    draw.text((10,470), "ﾐﾙｸﾃｨｰ(ﾎｯﾄ)*{0: <5}  {1: >3,}ｶﾞﾘｵﾝ".format(int(args[5]),int(args[5])*MILKTEA_TANKA), font=fontAa, fill=0)

#合計
total_price = (int(args[2])*STTEA_TANKA)+(int(args[3])*STTEA_TANKA)+(int(args[4])*MILKTEA_TANKA)+(int(args[5])*MILKTEA_TANKA)
draw.text((10,500), "--------------------------------", font=fontA, fill=0)
draw.text((40,560), "小計:{: >6,}ガリオン".format(total_price), font=fontB, fill=0)

#注文コードの表示
if (args[1] != "mobile"):
    #draw.rectangle((10, 640, 390, 760), fill=0)
    font = ImageFont.truetype('mplus-1m-regular.ttf', 48, encoding='unic')
    draw.text((30,640), "こちらはモニターに表示されます、お客様の\nご注文番号です。今しばらくお待ちください。 ", 0 , font=fontS)
    draw.text((110,680), args[1], 0 , font=fontC)
else:
    draw.text((30,640), "ご利用ありがとうございました。", 0 , font=fontS)
```
注文部の表示です。ひどいコードですね。
さっきPOSのところで柔軟な対応とか言っておきながら、
印刷部分は決めうちのハードコーディングです。
キャンフェス当日までに商品名の変更が何度もあったので、何度も書き換えました()

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/562a50e5-b8cd-5082-824b-d2e96755ba6a.png" width=50%px;>

## モバイルオーダー
ここ担当したのは別の人です。自分より10000倍(10進数)くらいすごいプログラマーです。
ちきなという人間です。

# 設営
机に固定されるラズパイたち
![A60E4F33-09F2-47C1-AED2-D1A188AC945E_1_102_o.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/9ac105fd-8da4-37c8-8e65-d2428df76639.jpeg)

机の裏。(サーバールーム感あってかっこいい?)
![4E3C3C4A-4410-40AA-AE14-E39A1C84A3D9_1_105_c.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/e68fe2eb-643c-3962-24aa-7cc1ceac8a64.jpeg)


# 実際の画面

POS用のiPad
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/e7ec5067-9bc2-1af0-ab8d-cd054bcd410c.png)


キッチン用のiPad
![フレーム-22-11-2024-11-16-07.tiff](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3121927/98969a4e-727b-cff4-7f07-eee46e3bd959.tiff)

呼び出し用モニター(もう一台は大きいモニターを使用)

# 反省と振り返り
技術的にはとても良い出来であった。
しかし実際の運用では商品を一杯作るのに時間がかかってしまいそこがボトルネックになってしまった。

また、当日は常に騒音に包まれており、イベントブースではモバイルオーダー注文者を特定するのが難しかった。
もし今後の人生で同じことをやる機会があれば、プッシュ通知などを実装しても良いかも。

# 先駆者様、参考にしたもの

[文化祭で某チェーン店を再現して失敗した話](https://qiita.com/NaYuA/items/1cda0211ec44fb25d422)
[学祭で自作のレジと注文管理システムを開発・運用した話](https://zenn.dev/kaguragateway/articles/d5d836336d640a)