スマホとバーコードで在庫管理をする

当サイトではバーコードを利用していろいろやっておりますが、ついに今回で在庫管理システムの最終形態に至りました。そしてそこには残念ながらPythonもExcelも出番はなかったのです(無理矢理つくりました)。

JavaScriptでバーコードリーダー

バーコードによる在庫管理システムを個人的な用途で運用するのに最も適しているデバイスはスマホです。他者の追随を許さない携帯性、リーダーとして使えるカメラ、ネットワークに接続でき、ストレージがあり、オールインワンの最強デバイスです。

ですが、現時点ではスマホでネイティブにPythonをうごかすことはできません。それっぽいことをできるようにするアプリもありますが実用になりませんでした。

よって、当サイトの過去の資産は利用できません。となると、残る手持ちのカードから最も低コストで開発できるWEBアプリ化を考えました。

そしてJavaScriptでバーコードリーダーが実装できないか?調べてみるとやっぱりライブラリがありました。それがQuaggaJSです。なんて便利な世の中なんだ。

FirebaseでWEBアプリ運用

ただし、このライブラリは当然カメラを使用するわけですが、ブラウザのセキュリティ上の制約でスキームがhttps:じゃないとカメラを使えません。

私はこのサイトを運用しているサーバーを使えるのでたいした問題ではないですが、そんなの少数派だと思われ、もうすこし導入のハードルを下げたいです。

そこでFirebaseのHostingサービスを使うことにしました。

FirebaseはGoogleが運営するBaaSです。Googleアカウントを持っていれば無料で使えます。これにHostingというサービスがあり、超絶簡単にhttpsでWEBページを配信できます。無料枠では利用制限が付きますが、個人使用で上限まで到達するのは至難の業でしょう。

他にもいろいろ機能があり、Cloud FirestoreというNoSQLデータベースも使えます。認証機能もAuthenticationで実装できます。これらを使って簡易なWEBアプリならあっという間に構築、運用できます。

これがどうやったら無料で済むのかナゾです。世界を支配している企業の考えていることは常人にはわかりません。えぇ、私のようなおっさんの個人情報でよければ何の価値もないのでGoogle様に全部差し出しますとも。

システム概要

システムの概要図は次のようになります。Firebaseを利用してバーコード在庫管理WEBアプリを配信します。

右下の方にいる人たちは今回完全にいらない子ですが、当サイトのコンセプト上およびExcel原理主義勢力下に生きる者の処世術として。

デモ動画

では、実際のうごきを見ていただくのがはやいのでデモ動画をご覧ください。音が出ます。

解説

WEBアプリのソースコードは次の通りです。JavaScriptのコードは面倒&HTMLの方が少ないので分けずに全部HTMLファイルに書いています。必要なライブラリはCDNから使います。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>食料庫管理システム</title>
    <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    Bootstrap5CDN読み込み(省略)
    <style>
        #cam-cap-area{ width: 100%; }
        video{ width: 100%; }
        canvas{ display: none; }
    </style>
</head>
<body>
<div class="container fs-3">
    <div id="cam-cap-area"></div>

    <form id="sing-in-form" style="display: none">
        <div class="form-group">
            <label for="inputEmail1">Email address</label>
            <input type="email" class="form-control" id="inputEmail1"
                   aria-describedby="emailHelp" placeholder="Enter email">
        </div>
        <div class="form-group">
            <label for="inputPassword1">Password</label>
            <input type="password" class="form-control" 
                   id="inputPassword1" placeholder="Password">
        </div>
        <button id="sing-in-btn" type="button" class="btn btn-primary">Submit</button>
    </form>
    <div class="mt-5">
        <input type="radio" class="btn-check" name="io-mode" id="out-radio" 
               value="out" checked>
        <label class="btn btn-outline-danger fs-1" for="out-radio">出庫(OUT)</label>
        <input type="radio" class="btn-check" name="io-mode" id="in-radio" 
               value="in">
        <label class="btn btn-outline-success fs-1" for="in-radio">入庫(IN)</label>
    </div>

    <table class="table mt-5">
        <thead>
        <tr>
            <th>JAN</th>
            <th>商品名</th>
            <th>在庫</th>
        </tr>
        </thead>
        <tbody id="tb"></tbody>
    </table>

    <div id="log-area">
        <p>ログ</p>
    </div>
</div>
<script type="module">
    import {initializeApp} from 'https://www.gstatic.com/firebasejs/9.6.3/firebase-app.js'
    import {
        getAuth,
        onAuthStateChanged,
        signInWithEmailAndPassword
    } from 'https://www.gstatic.com/firebasejs/9.6.3/firebase-auth.js'
    import {
        getFirestore,
        doc,
        getDocs,
        addDoc,
        updateDoc,
        increment,
        collection,
        serverTimestamp
    } from 'https://www.gstatic.com/firebasejs/9.6.3/firebase-firestore.js'

    const firebaseConfig = {
        apiKey: "*********************************",
        authDomain: "*******.firebaseapp.com",
        projectId: "*******",
        storageBucket: "*******.appspot.com",
        messagingSenderId: "**************",
        appId: "*********************************"
    }

    initializeApp(firebaseConfig)
    const auth = getAuth()
    const audio = new Audio('src/beep.wav')

    onAuthStateChanged(auth, user => {
        if (user) {
            const db = getFirestore()
            tableCreate(db)
            startCamCap(db)
        } else {
            $('#sing-in-form').show()
        }
    })

    $(function () {
        $('#sing-in-btn').click(function () {
            let email = $('#inputEmail1').val()
            let password = $('#inputPassword1').val()

            signInWithEmailAndPassword(auth, email, password)
                .then(userCredential => {
                    $("#sing-in-form").hide()
                })
                .catch(error => {
                    console.log(error.message)
                })
        })
    })

    const startCamCap = db => {
        Quagga.init({
            inputStream: {
                name: 'Live',
                type: 'LiveStream',
                target: document.querySelector('#cam-cap-area'),
                constraints: {
                    facingMode: 'environment'
                },
            },
            locator: {
                patchSize: 'medium',
                halfSample: true,
            },
            numOfWorkers: 0,
            decoder: {
                readers: ['ean_reader', 'ean_8_reader']
            },
            locate: true,
        }, err => {
            if (!err) {
                Quagga.start()
            } else {
                console.log(err)
            }
        })

        let canScan = true

        Quagga.onDetected(success => {
            const code = success.codeResult.code

            if (isJan(code) && canScan) {
                const target = $('#' + code)
                const mode = $('input:radio[name="io-mode"]:checked').val();
                let quantity = 0

                if (target[0]) {
                    canScan = false
                    target.css('background-color', 'yellow')
                    let logMsg = new Date().toLocaleString() + ' ' + target.prev().text()

                    switch (mode) {
                        case 'in':
                            logMsg += ' を入庫した。'
                            quantity = 1
                            break
                        case 'out':
                            logMsg += ' を出庫した。'
                            quantity = -1
                    }

                    target.text(Number(target.text()) + quantity)
                    audio.play()

                    updateDoc(doc(db, 'food', code), {
                        stock: increment(quantity)
                    })

                    addDoc(collection(db, 'log'), {
                        jan: code,
                        io: mode,
                        timestamp: serverTimestamp()
                    })

                    $('#log-area').append(`<p>${logMsg}</p>`)

                    setTimeout(() => {
                        target.css('background-color', 'transparent')
                        canScan = true
                    }, 2000)
                }
            }
        })

        const isJan = code => {
            const l = code.length
            const s = code.substring(0, 2)
            return (l === 8 || l === 13) && (s === '49' || s === '45')
        }
    }

    const tableCreate = async db => {
        const querySnapshot = await getDocs(collection(db, 'food'))
        querySnapshot.forEach(doc => {
            $('#tb').append(`<tr><td>${doc.id}</td><td>${doc.data().product_name}</td>
            <td id="${doc.id}">${doc.data().stock}</td></tr>`)
        })
    }
</script>
</body>
</html>

JavaScriptはあまりさわらないので変な書き方をしていたらすみません。インターネッツの偉い人、はやくブラウザでPythonが走るようにしてください。

アロー関数というシャレオツな書き方は普段しないのですが、Firebaseのサンプルが全部そうなっていたので頑張って合わせました(ほとんどIDEがやってくれましたが)。あと「;」は付けない派です。

QaagaJS

Firebaseはひとまずおいといて、バーコードリーダー機能を提供してくれるQuaggaJSですが、まずinitメソッドで動作のパラメータを設定します。Google先生に聞くと詳しく解説しているページが上のほうに出てくるので、そこからサンプルコードを拝借しましょう。

基本そのままのコードで問題ないですが、ひとつ重要なパラメータはinputStreamtargetで、ここでカメラ映像をどの要素へ投影するか決めています。省略すると暗黙にinteractiveというIDの要素を指定したとみなされます。なので、targetで要素を明示的に指定するか#interactiveという要素を用意しておく必要があります。

initのコールバックでstartメソッドを呼ぶとカメラが起動し指定した要素に映像が表示されます(初回はカメラ使用権限の許可が必要)。

onDetectedメソッドはカメラ映像にバーコードを検知したときのイベントリスナーです。ここにやりたいことを書いていきます。イベントから渡ってくる引数(ここではsuccess)のcodeResult.codeがバーコードの値です。

QuaggaJSの基本的な使い方はこれだけです。他にもリアルタイムに映像にバーコードをトラッキングする枠を出すなどできるようです。

スマホ&カメラの性能によると思いますが、私の場合バーコードがカメラに写ると5回くらい連続でonDetectedが実行されてしまうのでcanScanでフラグを立てて1回検知したら2秒間はonDetectedの中身が走らないようにしています。

Firebase

続いてFirebaseですが、こちらはリファレンスがPythonと違ってかなりわかりやすいので、公式サイトだけで何とか使えるようになるでしょう。

まず必要なライブラリのメソッドをCDNからimportしておきます。

firebaseConfigはFirebaseをWEBから利用するためのAPIキー等で、Firebaseのプロジェクト設定で確認できます。

initializeAppメソッドにfirebaseConfigを渡すとFirebaseを利用する準備が完了します。あとは必要な機能を呼んでいくだけです。

onAuthStateChangedはユーザー認証を提供するAuthenticationの機能で、ログイン状態を検知します。第一引数にgetAuthで取得したAuthインスタンスを渡すと、ログイン状態に変更があるたびに第二引数のコールバックが実行されるようになります。

ログイン状態はCookieで勝手に管理してくれるのでめちゃ楽です。これを使ってログインしていない状態ならメールアドレスとパスワード入力フォームを表示、すでにログイン状態なら次の処理に進むようにしています。

signInWithEmailAndPasswordはメールアドレスとパスワードによる認証をおこないます。Firebaseであらかじめ作成したユーザーのメールアドレスとパスワードが、フォームから送信されたそれと等しければログイン成功となる一番単純な方式です。一度ログインすると次からは↑のonAuthStateChangedにより認証フォームは表示されません。

updateDocaddDocはそれぞれCloud Firestoreのデータを書き換える、追加するメソッドです。バーコードの値を元に在庫数を増減させたり、ログを記録したりしています。

getDocsでCloud Firestoreのデータを全部取得できます。ここから在庫一覧表を作成します。

以上が大まかなロジックで、流れとしては
WEBページ表示→ログイン成功→データ取得→テーブル作成→カメラ起動→バーコード検知→データ更新・書き込み→テーブル更新・ログ表示 となります。

Cloud Firestoreにはアクセス権を設定してあり、ログインしていない状態ではデータの操作、取得はできないようになっています。

上のソースコードと付帯するツールを配布しますのでお試しください。

使用方法

まずFirebaseの準備をします。Firebaseはわかってる人以外を相手にするつもりはさらさらないサービスなのであらかじめご承知おきください。プログラミングができることが前提になっており、実際に利用できるようになるまでの手順も多いです。

1.FirebaseのWEBページにログインしてプロジェクトを作成します。一本道なので画面の指示通りにすすめてください。

2.プロジェクトが作成されたらメイン画面からアプリの追加をクリックします。プラットフォームの種類はウェブ</>にします。

3.アプリの名前を適当に決めます。このアプリのFirebase Hostingも設定するをチェックします。プロジェクトIDが自動的に選択されるのでアプリを登録ボタンを押します。

4.以降の手順で表示されるアプリの設定情報はすべて後から確認できるので今は無視します。すべて次へ次へと押していき、最後にコンソールの表示を押します。

プロジェクトとアプリの登録作業はこれで完了です。次に認証システムを設定します。

5.プロジェクトの左メニューの構築からAuthenticationを選択して始めるボタンを押します。

6.Sign-in Methodタブのログインプロバイダでメール/パスワードを有効化します。

7.Usersタブからユーザーを作成します。メルアドは形式を成していれば架空でOKです。

8.作成したユーザーのUIDを後で使うのでコピーしておきます。ポイントするとコピーボタンがでます。

これで認証システムの設定は完了です。次にデータベースを設定します。

9.左メニューの構築からFirestore Databaseを選択してデータベースの作成ボタンを押します。

10.セキュリティルールのポップアップが出るので本番環境モードにします。

11.ロケーションは適当に決めてください。サーバーの物理的所在地です。私は気分で東京リージョン(asia-northeast1)にしてます。

12.ルールタブでセキュリティルールの編集をします。デフォルトで次のようなルールになっています。

rules_version = '2';
service cloud.firestore {    
    match /databases/{database}/documents {
        match /{document=**} {
            allow read, write: if false;
        }
    }
}

この状態ではWEBアプリからのすべてのアクセスを拒否します。次のように書き換えます。

rules_version = '2';
service cloud.firestore {
    match /databases/{database}/documents {
        match /{document=**} {
            allow read, write: if request.auth != null &&
            request.auth.uid == '5.でコピーしたUID';
        }
    }
}

公開ボタンでルールを適用します。これで先に登録したユーザーだけがログイン状態で読み書き可能となります。つまり自分だけが操作できます。※見やすさを考慮して&&の次で改行していますが、しない方がいいと思います。ちなみに&&以降を消すと、ログインしているユーザーが全員読み書き可能になります。

これでデータベースの設定は完了です。次にWEBアプリの配信設定です。

13.左メニューの構築からHostingを選択して始めるボタンを押します。

14.Hosting設定のステップバイステップが出ますが無視していいです。npmを使っているという猛者は手順通りにやってください。ここではnpmは使わずスタンドアロン版でやります。軽く説明を読んでコンソールに進むを押します。

15.https://firebase.google.com/docs/cliへアクセスしてFirebase CLIのスタンドアロンバイナリを入手します。

16.ダウンロードしたFirebase CLI(firebase-tools-instant-win.exe)をWEBアプリファイル保存用のフォルダを適当な場所に作成して(以下アプリフォルダ)その中へ置きます。

Firebase CLIを使って次の手順で初期設定をします。

17.Firebase CLIを起動して firebase login と入力してEnterキー

18.ブラウザが起動してログイン画面になります。Googleアカウントでログインして権限を付与するとCLIでもログインが完了します。

続けてHostingの設定を次の手順でします。

19.firebase init と入力してEnterキー

20.?Are you ready~ではそのままEnterキー

21.選択肢がでるので、↑↓で( )Hosting: Configure files for ~を青い状態にしてスペースキーで(※)Hosting: ~にしてからEnterキー

22.?Please Select an Option:では↑↓でUse an existing projectを青い状態にしてEnterキー

23.作成済みのプロジェクト名が表示されるので、複数ある場合は↑↓で対象のプロジェクトを青い状態にしてEnterキー

24.?What do you want to~ではそのままEnterキー

25.?Configure as a single-page~ではそのままEnterキー

26.?Set up automatic builds~ではそのままEnterキー

Firebase initialization complete!と出たらOK。

27.CLIを閉じてアプリフォルダを開くと次のようなファイルができています。

これでWEBアプリ配信の準備は完了です。次に配信するアプリを準備します。

28.このページ下部からアプリファイル一式(zip)を入手します。

29.ダウンロードしたzipを展開したフォルダにあるpublicフォルダの中身を、アプリフォルダにあるpublicフォルダに入れます。index.htmlは上書きします。アプリフォルダのpublicフォルダは次のようになります。

このindex.htmlにはアプリの設定情報を書き込む必要があります。アプリの設定情報はFirebaseのページにあるのでコピーしにいきます。

30.Firebaseのプロジェクトのページから歯車ボタン→プロジェクトの設定を開きます。

31.ウェブアプリのところに最初に作ったアプリ名が表示されています。SDKの設定と構成構成に切り替えて右下のコピーボタンを押します。

32.アプリフォルダのpublicフォルダにあるindex.htmlをテキストエディタで開きます。

33.ソースコードの75行目あたりにあるconst firebaseConfigを選択して24.でコピーしたデータをペーストします。
次のように本物の設定値に置き換わります。ファイルを保存終了します。

これでアプリの準備は完了です。次にアプリを配信するためにHostingにファイルをアップロードします。

34.Firebase CLIを起動して firebase deploy と入力してEnterキー

アプリフォルダにあるpublicフォルダの中身がアップロードされます。

Deploy complete!と出たらCLIを閉じます。

これでWEBアプリが配信されました。次に在庫データをデータベースに登録します。

35.FirebaseのプロジェクトのページからFirestore Databaseを開きます。

36.データタブコレクションを開始をクリックします。

37.コレクションIDに「food」と入力して次へ

38.ドキュメントIDJANコードを入力して
フィールド:product_name タイプ:string 値:商品名
フィールド:stock タイプ:number 値:在庫数
フィールド:lower タイプ:number 値:下限数
を入力して保存ボタン。入力例は次の通りです。

ドキュメントを追加から同様にいくつか登録しておきます。

これですべての準備が完了しました!

WEBアプリのURLはHostingページで確認できます。QRコードなどにしてスマホでページへアクセスします。

ログインしていない状態ではログインフォームが表示されるので作成したアカウントでログインします。

初回ログイン時にカメラ使用許可を求められるので許可します。

カメラが起動して在庫データが表示されます。下限はスペースの関係で表示していません。

バーコードを読み取ると在庫数が増減します。

Firestoreのデータも変更されています。

ログも記録されます。

これらのデータはデモ動画でやっているようにPythonから操作できます。が、ちょっとページが長くなりすぎたので、Googleアナリティクスに「おまえのサイト読み込み重すぎ、あとモバイルに配慮なさすぎ」と警告されるので(ガン無視しますが)その方法は次回にします。

留意事項

publicフォルダはいわゆるドキュメントルートに相当します。deployコマンドで中身がすべてインターネット上にアップロードされURLがわかれば誰でも見られる状態になります。アカウントのパスワードをメモしたテキストファイルなどを置いておかないように注意してください。

Firebaseのアクセス制限について、Firestoreのデータへはセキュリティルールを使って細かくアクセス制限できますが、ユーザー作成を禁止することはできないようです。ページ内に自分ではユーザー登録用のスクリプトを作らなくても、ちょっと詳しい第三者がfirebaseConfigの値からAPIキー等を使ってAuthenticationに新規ユーザー登録をリクエストすれば、そこから作成したユーザーでアプリにログインしたり、プログラムによる総当たり攻撃により登録済みユーザーとしてログインされる可能性があります。

一応対抗措置を講じることはできますが、あまり重要なデータを扱うのはやめたほうがいいでしょう。今回のように別に見られようが何のダメージもない用途でお使いください。

より詳しく知りたい方はhttps://firebase.google.com/docs/projects/api-keysを確認してください。

あと下限値のデータを全く使用していませんので、不要ならご自身で削除していただくか、別記事を参考にLINEにアラートなどを(スマホでやってるので意味があるか微妙ですが)実装してください。

アプリを入手する

利用上のご注意

  • ダウンロードしたファイルを利用したことにより生じた結果については、利用者ご自身に責任を負っていただきます。
  • ご利用前に使用方法をご確認ください。
  • 当方は成果物の正確性について最善を尽くしますが保証はいたしません。
  • Windows11 Microsoft365 環境でのみ動作確認済み。

Downloadボタンを押下した時点で注意事項に同意したものとみなします。

firebase-bcstockmanager.zip

utilフォルダにあるスクリプトの使い方はこちら。

おわり。