パルパンぷよ

娘のパン屋さんが12月5日に三周年感謝祭を行うので、特別企画アプリとして「パルパンぷよ」を作成しました。上下左右の矢印キーまたはジェスチャーで操作できます。

言わずと知れた「ぷよぷよ」のアイテムが商品のパンになっています。出来るだけ多くの商品を紹介したかったため、アイテムを8種類としたら全消しがとても困難になってしまいました。おかげで全消し完成のときに表示される「完売しました」のメッセージが出てくるはずのものがデバッグできてません。

元ネタはMONACAセガが公開している「ぷよぷよプログラミング」です。ただ、こちらはMONACAの開発環境が必要なので、これを更に解説している【週間】ぷよぷよを作るのほうを参考にしました。

パン屋さんのサイトがまだhttps化しておらず、セキュリティ優先のアンドロイド版chromeではブロックされてしまいましたが、Firefoxなら動作しました。スマホを考えて最低限必要な画面構成にしましたが、左右にスペースがあるのでもう少し機能を加えることができそうです。

アイテムさえ差し替えれば、モナカや煎餅、ショートケーキを落とすこともできますので、いろんなお店の企画をしてみたいと思います。

PostgreSQLのJSONBを使う

目標

業務でIPアドレスの管理台帳を作成することになり、機器テーブルにIPアドレスのフィールドを設けて管理しようとしています。
しかし、ノートPCでは有線と無線の2つを登録しなければならず、当初は機器IDとIPアドレスおよびMACアドレスの組み合わせの別テーブルを作ろうとしましたが、PostgreSQLではJSON形式のデータが取り扱えるとのことで、JSONデータにしておけば今後の拡張も自在になる(かも知れない)ことを期待してJSON型利用に方針変更です。

テーブル設計

機器の基本情報に追加してdetailsをNIC情報と配置履歴情報をJSONBで定義しています。PostgreSQLではJSON型とJSONB型があり、JSON型のほうはtextで扱いやすいらしいですが、JSONBのほうが検索スピートが早いというだったのでJSONBを利用しました。テーブルのほうは、書き込まれるJSONデータの内容にはまったく感知せず、タイプをJSONBとしておけばJSONの文法に従った記述を行うことで、key-valueの組み合わせでデータを保存できます。

create table equip_books (
    id serial ,
    name varchar(20) not null unique,
    model_name varchar(30) ,
    maker varchar(20) ,
    equip_class varchar(20) not null ,
    details jsonb,
    operator_code varchar(10) ,
    created timestamp default current_timestamp,
    modified timestamp ,
primary key(id));
comment on table equip_books is '機器台帳';
comment on column equip_books.id is '管理番号';
comment on column equip_books.name is '名称';
comment on column equip_books.model_name is 'モデル名';
comment on column equip_books.maker is 'メーカ';
comment on column equip_books.equip_class is '機器分類';
comment on column equip_books.details is '明細';
comment on column equip_books.operator_code is '操作者コード';
comment on column equip_books.created is '初回登録日';
comment on column equip_books.modified is '最終更新日';

テストのためにデータを登録してみます。

insert into equip_books (name,maker,model_name,equip_class,details,operator_code,created)
 values ('PC001','NEC','Mate J','デスクトップPC','{
"nics":[
{"ip_address":"172.168.1.1","mac_address":"21:22:23:24:25:26","wifi":false,"dhcp":"static"}],
"allocations":[{"alloc_date":"2021-09-20","section":"総務課","room":"総務1"}
]}','system',now());
INSERT 0 1

某メーカーのデスクトップPCを総務課に導入したケースです。detailsの内容はJSON形式で記述します。ここでは、ネットワークが複数登録される可能性があるのでnicsというキーの中にネストしてIPアドレスMACアドレスWiFiの別、DHCPの区分を登録しています。IPアドレスの設定では手動か自動(DHCP)かの区分になりますが、DHCPでスタティックなIPアドレスを配布する場合もありますので、DHCPに'dynamic'とある以外は手動でも自動でもIPアドレスはip_addressの値が割り当てられるとルールで記述しています。dynamicと記述されていれば、ip_addressに設定されているIPアドレス含むセグメントの重複しないIPアドレス(場合によっては取得できないかも)が割り当てられる想定です。

ほとんどの場合、パソコンにはNIC1枚しかついてませんので、nicsへの登録も1件だけです。1件でも配列形式にしておかないと、参照時に苦労します。(というか、やらかしました)

allocationsには、配置日付、管理部署、配置位置を記述します。これも配置替えや部署の分割統合などで変化する可能性があるため、履歴として複数記述できるようにしておきます。

insert into equip_books (name,maker,model_name,equip_class,details,operator_code,created) 
values ('PC002','NEC','VersaPro J','ノートPC','{
"nics":[
{"ip_address":"192.168.1.103","mac_address":"11:12:13:14:15:17","wifi":false,"dhcp":"static"},{"ip_address":"192.168.10.1","mac_address":"21:22:23:24:25:27","wifi":true,"dhcp":"static"}],
"allocations":[{"alloc_date":"2021-09-20","section":"総務課","room":"総務1"}]}','system',now());
INSERT 0 1

2つ目はノートPCでNICは有線用とWiFi用の二種を登録するという想定です。

SQLを使って登録の内容を見てみます。

select * from equip_books;
 id | name  | model_name | maker |  equip_class   |                                                                                                                                                  details                                                                                                                                                  | operator_code |          created           | modified 
----+-------+------------+-------+----------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+----------------------------+----------

 1 | PC001 | Mate J     | NEC   | デスクトップPC | {"nics": [{"dhcp": "static", "wifi": false, "ip_address": "192.168.1.102", "mac_address": "11:12:13:14:15:16"}], "allocations": [{"room": "総務1", "section": "総務課", "alloc_date": "2021-09-20"}]}                                                                                                     | system        | 2021-09-24 11:00:14.881681 | 
  2 | PC002 | VersaPro J | NEC   | ノートPC       | {"nics": [{"dhcp": "static", "wifi": false, "ip_address": "192.168.1.103", "mac_address": "11:12:13:14:15:17"}, {"dhcp": "static", "wifi": true, "ip_address": "192.168.10.1", "mac_address": "21:22:23:24:25:27"}], "allocations": [{"room": "総務1", "section": "総務課", "alloc_date": "2021-09-20"}]} | system        | 2021-09-24 11:05:22.834942 | 
(2 行)

IPアドレスのデータはdetailsの中のnicsというキーの中に入っています。

select details->'nics' from equip_books;

                 ?column?                                                                                                  
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 [{"dhcp": "static", "wifi": false, "ip_address": "192.168.1.102", "mac_address": "11:12:13:14:15:16"}]
 [{"dhcp": "static", "wifi": false, "ip_address": "192.168.1.103", "mac_address": "11:12:13:14:15:17"}, {"dhcp": "static", "wifi": true, "ip_address": "192.168.10.1", "mac_address": "21:22:23:24:25:27"}]
(2 行)

このnics配列データにアクセスするには、次のようにします。

select nic.ip_address, nic.mac_address
from equip_books,
   jsonb_to_recordset(details->'nics') AS nic(ip_address text, mac_address text);  
  ip_address   |    mac_address    
---------------+-------------------
 192.168.1.102 | 11:12:13:14:15:16
 192.168.1.103 | 11:12:13:14:15:17
 192.168.10.1  | 21:22:23:24:25:27
(3 行)

レコードとしては2件しか登録されていませんが、jsonの内容から3行を表示することになります。

ここまでくれば、端末をMACアドレスIPアドレスから楽に検索できるようになります。

select name, nic.ip_address, nic.mac_address
from equip_books,                                         
   jsonb_to_recordset(details->'nics') AS nic(ip_address text, mac_address text) where nic.ip_address = '192.168.1.102';
 name  |  ip_address   |    mac_address    
-------+---------------+-------------------
 PC001 | 192.168.1.102 | 11:12:13:14:15:16
(1 行)

select name, nic.ip_address, nic.mac_address
from equip_books,
   jsonb_to_recordset(details->'nics') AS nic(ip_address text, mac_address text) where nic.ip_address = '192.168.10.1';
 name  |  ip_address  |    mac_address    
-------+--------------+-------------------
 PC002 | 192.168.10.1 | 21:22:23:24:25:27
(1 行)

select name, nic.ip_address, nic.mac_address
from equip_books,
   jsonb_to_recordset(details->'nics') AS nic(ip_address text, mac_address text) where nic.ip_address like '192.168.1.%';
 name  |  ip_address   |    mac_address    
-------+---------------+-------------------
 PC001 | 192.168.1.102 | 11:12:13:14:15:16
 PC002 | 192.168.1.103 | 11:12:13:14:15:17
(2 行)

これなら複数テーブルをJOINしながら使うよりは簡単に利用できそうです。

レシートプリンタ停止

10連休に入ってすぐスマレジで使っているレシートプリンタが動作しなくなりました。bluetooth接続済みにも関わらす、プリンタを探せないとのメッセージ。すぐにスマレジのインフォメーションに気付いてタップすると、以下のようなお知らせでした。

スター精密社製プリンターをご利用のお客様へ(不具合発生のお知らせ) – スマレジ・ヘルプ

この場合、スマレジには直接関係なく、iOSスター精密の問題。

暫定復旧手順のご案内 | スター精密株式会社

この書き方だと、iOSのほうが悪いようですが、アメリカは10連休でもないでしょうから、とっとと解決してほしい。

折しも連休中、どうにもできずレシート発行は停止、iPAD画面を見せて金額の確認をしてもらうようにしました。

ゴールデンウィークで休業中だったショップでは、連休明けにはパニックになるかも。

ちいさすぎるお店の広過ぎる売場面積

娘のパン屋さんは「佐世保の小さなパン屋さん」として紹介されています。まあ、お客さんが3人も入ったら身動きとれませんので、反論はございません。

http://safee.wpblog.jp/2018/12/17/201812161006/

そんな小さなお店のパンを、このたび佐世保の観光施設である「海きらら」「森きらら」で商品として取り扱っていただけるとのことです。しかも、観光遊覧船の売店に置くようなので、売場は九十九島一帯ということになります。まあ、島の上では販売してないので、地図でいえば赤い点線のうえが売場と言えるでしょう。点線と言えども実際には幅と長さはありますので、売場面積にすることは可能と思われます。

ただ、営業中は常に移動しているのと、購入可能なのは乗船する人だけに限られますので、売り場面積の99.999%がデッドスペースですね。ついでに言えば、船の中の売店に置いてもらうだけなので、うちへの割当てはせいぜい50cm角のスペースなのでしょう。それでも、小さなパン屋さんにとっては願ってもないビッグチャンスです。

平成最後および令和最初のゴールデンウィークは休みはなさそうです。

スマレジの画像ファイル取得

パンのカタログに使用したデータはスマレジのバックアップデータから取得しました。データソースを一元化することで、実際とカタログの差を最小限度に縮めます。画像自体はこちらからアップロードしたものですが、商品名と価格と画像の整合性を保持するのにスマレジデータを利用すのが一番確実な方法でしょう。

まず、自分のデータベースに取り込むためのsqlスクリプトを作成します。

.mode csv
select productId,categoryId,productCode,productName,price,image,icon from product;

これを次のようなコマンドを実行すれば商品名や価格等の記載されているproduct.csvを得ることができます。

sqlite3 pos/pos.sqlite < products.sql > datas/product.csv 

このCSVファイルを取り込むプログラムを作成してデータベースに追加すればよいのですが、スマレジの有料会員になればデータへのAPIが公開されているようです。ただオープンしたばかりのパン屋さんでは体力が充分ではないため、すべてを自前で賄うことにしています。

image、iconフィールドはスマレジに表示される商品のイメージとアイコンへのURLです。お店の造作やチラシ配りの手伝いはしますが、私には商品知識はまったくないため、商品名と画像の一致は嬉しいデータです。ホームページから他所様への直リンクはマズいので、一旦ダウンロードさせてもらってから利用することにしました。

GO言語でproduct.csvを置いた場所にdownload_img.goというファイルを作成しました。まずは、csvファイルを読み込んで処理する部分のメイン処理です。

package main

import (
    "log"
    "flag"
    "os"
    "os/exec"
    "io"
    "encoding/csv"
    "path/filepath"
    "strings"
)

func main() {
    log.Println("Start")
    flag.Parse()

    args := flag.Args()
    if len(args) == 0 {
        return
    }
    fname := args[0]
    _, err := os.Stat(fname)

    if err != nil {
        log.Println("File:",err.Error())
        return
    }

    getDatas(fname)

    log.Println("End")
}

実行時の引数は先程作成したproduct.csvファイルの場所を指定します。

GO言語の場合(でなくても)、スクリプトが完全に出来上がっていないとエラーで動作しません。次にgetDatas関数を作成します。

func getDatas(fname string) {
    file1, err := os.Open(fname)
    if err != nil {
        return
    }
    defer file1.Close()

    reader := csv.NewReader(file1)
    reader.LazyQuotes = true

    for {
        rec, err := reader.Read()
        if err == io.EOF {
            break
        }
        if len(rec) > 5 {
            img_url := rec[5]

            if img_url != "" && strings.Contains(img_url, "https") {
                ExecDownload(img_url)
            }
            icon_url := rec[6]
            if icon_url != "" && strings.Contains(icon_url, "https") {
                ExecDownload(icon_url)
            }
        }
    }
}

csvのreaderを作成してEOFを読み込むまでループすれば順次処理することができます。読み込んだCSVデータの配列からimageファイル、iconファイルのURLをそれぞれ得ています。このURLにアクセスしてダウンロードします。

func ExecDownload(url string) {

    filename := filepath.Base(url)

    img, err := exec.Command("curl", url).Output()
    if err != nil {
        log.Println("Exec:", err.Error())
    }
    SaveImage(img, filename)
    cmd := exec.Command("sleep", "5s")
    cmd.Start()
    log.Println(filename)
    cmd.Wait()
}

URLアクセスは安直にcurlを呼び出すだけです。結果はimg変数に返されます。これをファイルとして保存していますが、次のアクセスまで5秒待機させています。これは、ダウンロードとファイル保存を同期させて動かすのが難しいことと、連続してアクセスするとサイトへのアタックと間違えてブロックされないようにするためです。何でも早ければいいと言うわけではありませんね。

func SaveImage(img []byte, fname string) {
    file, err := os.Create("./smaregi/" + fname)

    if err != nil {
        log.Println("SaveImage", err.Error())
        return
    }
    defer file.Close()

    _, err = file.Write(img)

    if err != nil {
        log.Println("SaveImage:Write:", err.Error())
    }
}

ファイルは後でまとめやすいように、実行ディレクトリにsmaregiディレクトリを作成してください。上記までに作成したプログラムは次のように実行します。

go run download_img.go product.csv

Palpanではそれほど商品数も多くないので、5秒間隔で動いてもせいぜい10分から15分でダウンロード完了です。

NGINX+Beego+GroundworkCSS

Palpan開発部として、本当はパン屋さんの公式ホームページを作成するつもりだったけど、ついでに学習する予定のGO言語の開発手法が自分の中でなかなか確立せず、試行錯誤していました。

パン屋さんサイトは、フロントエンドをNGINXにしてGO言語のフレームワークであるBeegoを使い、WebデザインにはGroundworkCSSを利用しています。スピーディでエッジの効いた動きをする最強トリオでしょう。

これまで、Apache+CakePHP+Bootstrapの伝統的トリオに固執して来ましたが、さすがに時代遅れの感が否めず、まるごと開発環境を移行してしまいました。

Cake bakeのお手軽感は捨て難いものがありますのでこれを真似して、GO言語にて、データベースからCRUDのスケルトンとなるコードを自動生成できるようBeegoに合わせて作成。完成度は10%ぐらいなので公開はできませんが、テーブルの入出力に利用する構造体は自動的に生成できますので、簡単で面倒なところはかなり働き方改革しています。

Pandasでスマレジ解析

前回、スマレジからCSV形式でデータを取り出すことができました。このデータを眺めていたら、OpenWeatherMapのデータと組み合わせて需要予測が可能ではないかと思いつきました。つまり、ここで書いてあることを実地で検証できるかも、というところです。

Palpanの様子を見ていると、1日以外は営業日の夕方には殆んど商品が残っておらず、効率がよいと言うより「済みません、本日は売り切れました!」っと、商機を逸している状況が見受けられるようです。そこで、需要予測を行い、予測成績がよければこれに従って製造プランを提示しようというわけです。ただし、作れば売れるのを目指すのではなく、ブーランジェでもある娘の製造能力を超えずに最低限度の売り上げを確保することが必要です。売上だけで見ると、売れなかったものが何だったのかが判然としませんので、様々な要因を組み合わせながら統計的手法を用いようというものです。

私自身には統計学的素養は全くもって持ち合わせていませんが、近年のAIブームのおかげでWebには用語の解説やアプリケーション、学習済みデータ、サンプルコードが溢れています。素人の私にとって、なかなか理解し難いものですが、無期限ハンズオンの精神で体をはって学習していくことにします。まず、入り口を乗り越えるための一番低い壁は、と探していたら、データ解析の定番はPython+Pandasのようでした。

pandasを利用するにはpythonとpipのインストールが必要です。私の現在の環境は次のとおりです。

$ python -V
Python 3.6.2

$ pip -V
pip 9.0.1 from /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages (python 3.6)

pipが古いので先に更新を行います。

$ pip install --upgrade pip
Cache entry deserialization failed, entry ignored
Collecting pip
  Using cached https://files.pythonhosted.org/packages/c2/d7/90f34cb0d83a6c5631cf71dfe64cc1054598c843a92b400e55675cc2ac37/pip-18.1-py2.py3-none-any.whl
Installing collected packages: pip
  Found existing installation: pip 9.0.1
    Uninstalling pip-9.0.1:
      Successfully uninstalled pip-9.0.1
Successfully installed pip-18.1

$ pip -V
pip 18.1 from /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages/pip (python 3.6)

さらに、pandasのインストールを行います。

$ pip install pandas
Collecting pandas
  Using cached https://files.pythonhosted.org/packages/e1/d8/feeb346d41f181e83fba45224ab14a8d8af019b48af742e047f3845d8cff/pandas-0.23.4-cp36-cp36m-manylinux1_x86_64.whl
Requirement already satisfied: numpy>=1.9.0 in /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages (from pandas) (1.15.4)
Requirement already satisfied: pytz>=2011k in /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages (from pandas) (2018.7)
Requirement already satisfied: python-dateutil>=2.5.0 in /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages (from pandas) (2.7.5)
Requirement already satisfied: six>=1.5 in /home/shige/.pyenv/versions/3.6.2/lib/python3.6/site-packages (from python-dateutil>=2.5.0->pandas) (1.11.0)
Installing collected packages: pandas
Successfully installed pandas-0.23.4

データ解析の前にpandasの機能を知らなければ使いこなしもできませんが、そこは習うより慣れろです。python自体もこの十数年、触ったこともありません。まずは、手になじませることが重要ですので、チュートリアルを参考にしながら、自分なりに解釈して行きます。pythonの良いところは1ステップずつ実行しながら確認ができるので、学習するにはぴったりです。

pythonのコンソールに入ってpandasを読み込みます。

$ python
Python 3.6.2 (default, Dec 18 2018, 12:58:14) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-36)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pandas as pd
>>> 

チュートリアルではlunch_box.csvを読むようになっていますので、これを先日作成したhead.csvに変えれば良いでしょう。

df = pd.read_csv('./head.csv', sep=',')
df.head(3)
   1  2018-12-02 10:36:26   920.0  Unnamed: 3  920.0.1   68.0
0  2  2018-12-02 10:42:14   630.0         NaN    630.0   46.0
1  3  2018-12-02 10:43:30   700.0         NaN    700.0   51.0
2  4  2018-12-02 10:46:12  1660.0       166.0   1494.0  110.0

むむむ、sqliteで抽出したデータにはヘッダが付いていませんでしたので、先頭行がヘッダ行として解釈されています。これは失敗。pandasにヘッダが無いことを教えてあげます。

>>>  df = pd.read_csv('./head.csv', sep=',',header=None)
>>>  df.head(3)
   0                    1      2   3      4     5
0  1  2018-12-02 10:36:26  920.0 NaN  920.0  68.0
1  2  2018-12-02 10:42:14  630.0 NaN  630.0  46.0
2  3  2018-12-02 10:43:30  700.0 NaN  700.0  51.0

これでは何のデータか判りませんので、カラム名をつけます。

>>> df.columns=['No','販売時刻','合計','値引','金額','税額']
>>> df.head()
   No                 販売時刻      合計     値引      金額     税額
0   1  2018-12-02 10:36:26   920.0    NaN   920.0   68.0
1   2  2018-12-02 10:42:14   630.0    NaN   630.0   46.0
2   3  2018-12-02 10:43:30   700.0    NaN   700.0   51.0
3   4  2018-12-02 10:46:12  1660.0  166.0  1494.0  110.0
4   5  2018-12-02 10:47:44   900.0    NaN   900.0   66.0

開店用のチラシに10%割引券を付けておきましたので、値引フィールドに数値のある人は割引券を使った人たちです。

 >>> df.count()
No      415
販売時刻    415
合計      415
値引      130
金額      415
税額      415
dtype: int64

count関数ではNanの行を集計しませんので、値引行だけが少くカウントされます。1/3は割引券を使っていますので、チラシ作戦はうまくいったようです。割引券を使ったお客さんの平均購入金額はつぎのようにして出せます。

>>> df_disc = df.dropna()
>>> print(df_disc['金額'].sum() / df_disc['金額'].count())
858.3

意外と沢山購入していただいているようで、有り難いことです。値引きしなかったお客さんについても、平均購入金額を出してみます。

>>> df_non_disc = df[df.isnull().any(axis=1)]
>>> print(df_non_disc['金額'].sum() / df_non_disc['金額'].count())
852.8315789473684

うん、殆ど変化がありません。割引券を使ったお客さんは多めに買っていただいたようですね。この結果をどう見るかが今後の運営の方針となります。つまり、値引きしてもしなくても同じなら値引きする必要はなかったのか、1回目で割引券を使ったので、2回目以降の割引券を使わないお客さんが多かったのか、それとも、、、

開店して何日かのデータですので、全然見えてきませんが、とりあえずpandasの威力はすごいものだとわかりました。次は、OpenWeatherMapとの組み合わせを考えてみます。