QRコードをスキャンして備品の補充申請！

[コラボフロー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/collaboflow) 2日目の記事です！
コラボフローってなんぞや？って方は [こちらのページ](https://www.collabo-style.co.jp/about/) をご参照ください！

---

1日目の「[コラボフローカスタマイズのすゝめ](https://qiita.com/miko3/items/484cc21136987efb4d11)」で QR コードネタがあったので、まさかの速攻で被りか！？と内心焦りましたが、セーフです。ふぅ～

本日は QR コードを**読み取ります！**

さて。

オフィスの備品といえば鉛筆から始まり、油性マジックやコピー用紙、紙コップ等々、よく補充が必要なものが沢山、、依頼をするにも申請書に記入が面倒くさい！総務部など手配を受ける側も申請書からの依頼のほかに、口頭やチャットの不定形な依頼の交通整理も一苦労・・。

**めんどいですよね！？**

そんな時にワークフロー化しつつ、スマホやタブレットからもっと手軽に入力できたら・・・
カメラで・・・んぉ！？

「QRコード」×「JSON」でイケるんじゃね？
キタコレ！

で、作りました。

# どでか備品タグ

これを

<img alt="" width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/74e48add-c26f-d753-3819-cfef7d967886.jpeg">

こうじゃ！

<img alt="" width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/3d043204-614b-43ef-f841-a06c9529edfb.jpeg">

## 取り付ける

油性マジックの備品箱（仮）に取り付けます。

<iframe width="560" height="315" src="https://www.youtube.com/embed/dkIJW7ChQWA" title="備品タグ取り付け" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>

**圧倒的なデカさ！！**

ロマンを感じますね。

# いざいざスキャン！

今回は「備品アイテムNo」と「設置場所（手配先）」の記入を自動化しました。
先にアプリの動作風景をどうぞ。

<iframe width="560" height="315" src="https://www.youtube.com/embed/355MSkTBKc4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>

**めっちゃ楽しい！！**

申請書を開いて「備品QRコード スキャン」ボタンをタップすると、QRコードを読み取って上部にあるパーツに自動で設定してくれます。

予想以上に軽快に動作してサクッと入ってくれましたｗ
（むしろ、ボタンタップ直後に認識してスキャン完了、カメラ映像が見えない見せ場泣かせで数テイクする羽目に...）

# 実装

ここからは巻き戻って、どんな実装になっているか紹介していきます。

## 仕組み

コアとなる部分は jsQR を使って QR コードの読み取りをしています。

* jsQR
https://github.com/cozmo/jsQR

純粋な文字列として生成したQRコードを `JSON.parse()` を使ってデータ化と対象のQRコードを選別しています。

映像は `navigator.mediaDevices.getUserMedia()` で得られたカメラストリームを canvas と video で描画しています。
この辺りのコードは jsQR のデモを参考にしています。

## フォーム

<img alt="" width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/93bd6422-e1ec-31ab-dbeb-94b9960f648e.png">

今回は通常フォームで作りました。各パーツIDと種類、概要は以下の通り。

* fidItem - テキスト（1行）パーツ。備品アイテムNo、必須入力にします。
* fidArea - テキスト（1行）パーツ。備品設置場所、必須入力にします。
* fidRequestSets - テキスト（数値）パーツ。必要セット数、今回は手入力用です。
* fidQRData - テキストエリアパーツ。デバッグ用です。

およびラベル「操作パネル」には以下を記述しています。
後ほどの JavaScript カスタマイズコードによって div 内に色々注入していきます。

<img alt="" width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/5c79d766-81c5-47a3-af60-637e879893b9.png">

```html
<div id="renderBase"></div>
```

## JavaScript カスタマイズコード

200行超えのソースなので折りたたんでいます。
qr-scanner.js というファイル名でアップロード用に保存します。

<details><summary>qr-scanner.js</summary><div>

```javascript
/**
 * コラボフロー Advent Calendar 2022 2日の記事
 * JSON文字列形式のQRコードをスキャンして複数パーツへ値セットするサンプル
 */
(() => {
  "use strict";

  /** @type HTMLCanvasElement */
  let $g_canvas;
  /** @type HTMLVideoElement */
  let $g_video;
  /** @type HTMLTextAreaElement */
  let $g_status;
  /** パーツセット */
  let g_parts;

  let scanStartTime = 0;
  const SCAN_TIMEOUT = 10000; // 10秒

  /**
   * 特定タグの解析
   * @param {string} text JSON文字列データ
   * @param {string} tagMark 対象のタグ名
   * @returns {{tag:string; item:string; area:string} | null}
   */
  const parseTagData = (text, tagMark) => {
    try {
      const data = JSON.parse(text);
      if (data.tag === tagMark) {
        return data;
      }
    } catch (_ex) {}
    return null;
  };

  /**
   * 枠線を引く
   * @param {CanvasRenderingContext2D} ctx
   * @param {{x:number; y:number;}[]} points 2点以上の座標
   * @param {string} strokeStyle 罫線スタイル・色
   */
  const drawPath = (ctx, points, strokeStyle) => {
    ctx.beginPath();
    points.forEach((point, index) => {
      if (index === 0) {
        ctx.moveTo(point.x, point.y);
      } else {
        ctx.lineTo(point.x, point.y);
      }
    });
    ctx.closePath();
    ctx.lineWidth = 4;
    ctx.strokeStyle = strokeStyle;
    ctx.stroke();
  };

  /**
   * QR コードを認識します
   * @param {CanvasRenderingContext2D} ctx
   * @returns {QRCode | null} 認識したら jsQR の QRCode オブジェクト
   */
  const detectQR = (ctx, sx, sy, width, height) => {
    const image = ctx.getImageData(sx, sy, width, height);
    const code = jsQR(image.data, image.width, image.height, {
      inversionAttempts: "dontInvert",
    });
    return code;
  };

  /**
   * スキャンを停止してカメラをOFFにします
   */
  const stopScan = () => {
    $g_canvas.hidden = true;
    if ($g_video.srcObject) {
      $g_video.srcObject.getTracks().forEach((track) => track.stop());
      $g_video.srcObject = undefined;
    }
  };

  /** @type FrameRequestCallback */
  const scanTick = (ts) => {
    if (!$g_video) return;

    if ($g_video.readyState === $g_video.HAVE_ENOUGH_DATA) {
      const width = $g_video.videoWidth;
      const height = $g_video.videoHeight;

      // 描画領域をカメラ映像と同じ大きさに揃える
      $g_canvas.width = width;
      $g_canvas.height = height;
      $g_canvas.style.left = `${window.innerWidth / 2 - width / 2}px`;
      $g_canvas.style.top = "120px";
      $g_canvas.hidden = false;

      // 投影処理
      const ctx = $g_canvas.getContext("2d");
      ctx.drawImage($g_video, 0, 0, width, height);

      const qr = detectQR(ctx, 0, 0, width, height);
      if (qr) {
        // 認識した QR コード領域を左上角から時計回りに罫線を引く
        drawPath(
          ctx,
          [
            qr.location.topLeftCorner,
            qr.location.topRightCorner,
            qr.location.bottomRightCorner,
            qr.location.bottomLeftCorner,
          ],
          "#67A2F0"
        );

        const tagData = parseTagData(qr.data, "備品");
        if (tagData) {
          g_parts.fidQRData.value = "認識\n" + JSON.stringify(tagData);
          g_parts.fidItem.value = tagData.item;
          g_parts.fidArea.value = tagData.area;
          $g_status.innerText = "備品タグをスキャンしました！";
          stopScan();
          return;
        }
      }
    }

    // 一定時間で中止する
    if (Date.now() - scanStartTime > SCAN_TIMEOUT) {
      $g_status.innerText = "備品タグを読み取れませんでした";
      stopScan();
      return;
    }

    // スキャンの継続
    window.requestAnimationFrame(scanTick);
  };

  /**
   * スキャンボタンをクリック
   */
  const handleScanQR = () => {
    // 初めての起動
    // ユーザーに対してブラウザーがカメラデバイスへのアクセス許可を求めます
    navigator.mediaDevices
      .getUserMedia({ video: { facingMode: "environment" } })
      .then((stream) => {
        // アクセス許可あり＆デバイス発見したら、スキャン動作開始する
        $g_status.innerText = "QRコードにカメラをかざしてください";
        scanStartTime = Date.now();
        $g_video.srcObject = stream;
        $g_video.setAttribute("playsinline", true);
        $g_video.play().then(() => {
          window.requestAnimationFrame(scanTick);
        });
      })
      .catch((reason) => {
        console.error("Scan failed", reason);
        $g_status.innerText = "読み取り準備に失敗しました。" + reason.message;
      });
  };

  const createCanvas = () => {
    const $el = document.createElement("canvas");
    $el.hidden = true;
    $el.style.position = "fixed";
    return $el;
  };

  const createStatusView = () => {
    const $el = document.createElement("div");
    return $el;
  };

  const createScanButton = (clickHandler) => {
    const $el = document.createElement("button");
    $el.type = "button";
    $el.innerHTML = "備品QRコード<br/>スキャン！";
    $el.style.cursor = "pointer";
    $el.style.minWidth = "140px";
    $el.style.minHeight = "80px";
    $el.addEventListener("click", clickHandler);
    return $el;
  };

  /**
   * レンダリング
   */
  const renderControl = ($base) => {
    while ($base.firstChild) {
      $base.removeChild($base.firstChild);
    }
    $g_video = document.createElement("video");

    $g_canvas = createCanvas();
    $base.appendChild($g_canvas);

    $g_status = createStatusView();
    $base.appendChild($g_status);

    // 最後にスキャンボタンが表示されたら準備 OK の合図
    const $btn = createScanButton(handleScanQR);
    $base.appendChild($btn);
  };

  // ------------------------------------------------------
  // 申請入力画面イベント
  // ------------------------------------------------------
  collaboflow.events.on("request.input.show", (data) => {
    // 後からパーツ値設定用に引き出しておく
    g_parts = data.parts;

    const $renderBase = document.getElementById("renderBase");
    if (!$renderBase) {
      console.debug("renderBase is missing.");
      alert("操作パネルの設置位置が見つかりませんでした。");
      return;
    }

    try {
      renderControl($renderBase);
    } catch (ex) {
      console.error(ex);
      alert("操作パネル設置エラー: " + ex.message);
    }
  });
})();
```
</div></details>

### カスタマイズ設定

最初に「URL指定」で jsQR の CDN を指定します。
https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js

次に「ファイルアップロード」で先ほど保存した `qr-scanner.js` ファイルをアップロード追加します。

## 申請経路

申請経路を用意します。

初段の申請書類の設定でフォームを選択した後、自動入力させる2パーツは「編集可」のチェックを外しておきます。これでフォーム設定時のパーツ設定で必須になっている事との組み合わせで「QRコードをスキャンして自動設定されるまで申請できない」ようにできます。

<img alt="" width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934/02b9fcce-711f-6c79-072f-5cb88016d0b1.png">

判定者などは適宜。

# QR コード作り！

今回は無料の「クルクルmanager」を利用しました。日本語のテキストも UTF-8 で生成してくれました。
https://m.qrqrq.com/

※生成時にサーバー送信されるので、送るデータは検討してください。

# 完成！

冒頭の動画のようにスキャンできれば完成です。

# アドバンテージ

これ、何が良いかというと、QRコード内の文字列をカスタマイズすれば自由な組み合わせが出来ること。
在宅勤務でも備品として支給しているものがあれば、設置場所に「○○宅」などにして必要な品目だけ、個別にQRコードを生成・配布・差し替えしやすいところ。

個々での記入でゆらぎが出るのは困るが、マスター化する程でも無い時などにつかえるかも？

他にも、イベント設備等の貸出・回収において機材・梱包ケースごとに QR コードを張り付けてスキャン・点検ワークフローなんかにも応用できそうです。

1日目の [QR コード生成](https://qiita.com/miko3/items/484cc21136987efb4d11#%E5%85%A5%E5%8A%9B%E5%80%A4%E3%82%92qr%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AB%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB) と組み合わせて、複数のワークフローをリレーした応用もできそうかも！？

# さいごに・謝辞

なかなか業務では触る機会が無いカメラ関係の挙動に翻弄しつつ作りました。
クローズしたと思ったら、カメラ横の撮影中ランプがまだ点いてるやんと。生配信終わったと思って気を抜いたらまだ切れてなかった生主な気分になりました。。ひぃーー。

カメラクローズを以下の記事で知りました。助かった。
https://qiita.com/yorifuji/items/5d6f93c0a65df7551622

長いコードを折りたためることを下の記事で知りました。超便利。
https://qiita.com/matagawa/items/31e26e9cd53c3e61ae07

これらの記事のおかげで完成しました。

---

それではまた！