欲しいアプリは自分で作る!

Power Platform や Azure などを利用して作成した業務アプリや趣味アプリなどをご紹介します。

買い物メモをかごに入れる順番に並び替えてくれる LINE ボットを作ってみた

皆さんは買い物される時、事前にメモなど書いて買い物されますか?

私は頭では覚えられないので、メモ帳アプリを使って事前にメモし、スーパーでかごに入れたものから消しています。
(メモなしで買い物に行くとほぼ買い忘れる)


で、このメモが結構煩わしいんですよね。
思い付いた順にメモするとこうなるんですが、

  • ビール
  • ネギ
  • 豚バラ
  • 牛乳
  • アイス
  • 鶏むね
  • ほうれん草

この順番で買い物しようとすると、同じところを何度も行ったり来たりしなきゃいけないんですよね。
で、売り場にあるものをメモから探そうとすると「野菜は~え~っとネギと~アイス…ぁあ違うっ!あとほうれん草と~」 ってなりますよね(私だけ?)


できるなら、スーパーのルート順や冷凍食品が溶けない順に並べたいですよね。
※下記はあくまで一例ですが、元スーパーの社員だった私の両親曰くスーパーにおける商品の配置には法則があるようなので、おおよそこんな感じなのかなと思います。

  • ネギ
  • ほうれん草
  • 豚バラ
  • 鶏むね
  • 牛乳
  • ビール
  • アイス


ということで、買い物メモを並び替えてくれる LINE ボットを作ってみました。


目次


1. 作成した LINE ボット

こんな感じで動きます。


2. アーキテクチャ

本ボットのアーキテクチャ図は以下の通りです。

並び替えの処理は、Logic Apps で行います。


Google Sheets には、ソートID(SortId)と品目名(GoodsName)を格納しています。
ソートIDには、いわゆる「買う順」を番号で指定してあり、後のソート処理で使用します。
(メンテナンス性の高い DB の作り方ではありませんが、何卒ご了承くださいm(__)m)


3. 処理方法

おおよそ以下のように処理することを考えます。


フローの全体像はこんな感じになりました。
スコープ(茶色い塊)毎に簡単に解説していきます。


3-1. トリガ、応答、変数の初期化

この部分です。


トリガは LINE からの Webhook イベントです。
Logic Apps の HTTP 要求の受信時トリガの HTTP POST の URL を LINE 側に設定しておきます。
リファレンス:https://developers.line.biz/ja/reference/messaging-api/#message-event

リファレンスに従い、ひとまず応答コード200を返しておきます。

変数は文字列型の Inputed と、アレイ型の Sorted を用意しておきます。


3-2. LINE から受信した品目をテーブル化

続いてこの部分です。 ここでは、LINE から受信したメッセージ内容を改行コードで分割してテーブル化します。


作成で、改行コードで split し、

split(first(triggerBody()?['events'])?['message']?['text'], decodeUriComponent('%0A'))

一応分かりやすくするために、選択で Name 列を持つテーブルにしておきます。


ここまでで、こうなりました。


3-3. 品目にソート順を付与

続いて、この部分です。
ここでは、品目名を Google Sheets から検索し、ソート順を取得します。


Google Sheets の複数行取得アクションにはフィルタリング機能がないため、最初に全行を取得します。
既定だと、上から256レコードしか取得できないようなので、適当に1000くらいにしておきました。

LINE から受信した品目ごとに処理するので、3-2. の最後のアクション「選択」の結果を For each のパラメータとして設定し、

Google Sheets から取得したデータに対してフィルタ処理を行います。

そして、フィルタ処理した結果、レコード数が0かどうか(0、つまり見つからなかったら true)を判定します。

length(body('アレイのフィルター処理_5'))

true、つまり見つからなかった場合はIDを99として、
false、つまり見つかった場合はそのソート順をIDとして、ひとまず文字列変数にJSONっぽく追加します。

first(body('アレイのフィルター処理_5'))?['SortId']

最後に、文字列変数に対して JSON の解析を行い、後の処理で値を抽出しやすいようにしておきます。

コンテンツ
(直前の For each 処理で余分に追記された末尾のカンマを取り除き、JSON で解析できるよう先頭と末尾に [ ] を付けています)

concat('[', substring(variables('Inputed'), 0, sub(length(variables('Inputed')), 1)), ']')

スキーマ

{
    "items": {
        "properties": {
            "ID": {
                "type": "integer"
            },
            "Name": {
                "type": "string"
            }
        },
        "required": [
            "ID",
            "Name"
        ],
        "type": "object"
    },
    "type": "array"
}


ここまでで、こうなりました。
(大トロはまだデータベースに存在しない想定)


3-4. 品目をソート順の昇順でソート

続いて、この部分です。
随分とシンプルです。


Power Automate でテーブルのソートを行う機能は標準では用意されていないはず…と思っていたら、ちょっと前に追加されていました。これは便利。
参考:Power Automate 2022年9月に追加された強力な新関数の紹介 - Qiita

ということで、Sort 関数で一発終了です。

sort(body('JSON_の解析'), 'ID')


ここまでで、こうなりました。


3-5. ソートできた品目を文字列化 / ソートできなかった品目を文字列化

ソートが終わったので、最終的に LINE で返信するために文字列化します。
データベースにあったもの(ID≠99)と、データベースになかったもの(ID=99)をそれぞれ文字列結合します。
各々依存関係はないので、処理時間短縮のために並列で実行させます。


まずは左側から。
先ほどソートしたデータから、データベースにあった品目を抽出します。

抽出した品目から Name 列のみを抽出します。

item()?['Name']

Name 列の値を改行コード区切りで結合し、ひとつの文字列にします。

decodeUriComponent('%0A')


右側は、「アレイのフィルター処理」の部分の以下の部分が左側と異なるのみで、他は左側と全く同じ処理です。


ここまでで、こうなりました。


3-6. ソート結果をユーザーに返信

文字列化したものを LINE で返信します。


まず、データベースになかった品目が存在していたかどうかを条件判定します。
3-5.の右側のフィルタ処理の結果を length 関数で括ってください。

length(body('アレイのフィルター処理_3'))

true の場合(=データベースになかった品目が存在しなかった場合)は、ソートした品目を返して終了です。
text の値には、3-5.の左側の結合処理の結果を設定してください。

replyToken は以下のように指定することで、トリガの本文から取得できます。

first(triggerBody()?['events'])?['replyToken']

リファレンス:https://developers.line.biz/ja/reference/messaging-api/#send-reply-message


false の場合は、ソートした品目と、見つからなかった品目を両方お知らせします。
text の値には、3-5.の左側の結合処理の結果を最初に、右側の結合処理の結果を次に設定します。


3-7. ソートできなかった品目をデータベースに登録し管理者に通知

最後の処理です。
ソートできなかった品目が存在しなかった場合は3-6.の True で終了しますので、この処理には遷移しません。


3-5.の右側のフィルタ処理の結果分(=ソートできなかった品目の数だけ)ループ処理をかけます。

Google Sheets の行の挿入アクションで、ソートできなかった品目の Name を GoodsName に指定します。
SortId は後で手動で設定するので、99 にしておきました。

items('For_each_3')?['Name']

最後に管理者(=自分)に「おい登録されてなかった品目を勝手に登録しといてやったからIDをメンテしとけな」と通知します。
Google Sheets をデータソースとしたキャンバスアプリを作成しておき、アプリの URL を本文内に記載しておくと、メンテする際の導線がスムーズかなと思います。
通知方法はお好みでどうぞ。私は通知も LINE にしました。

リファレンス:https://developers.line.biz/ja/reference/messaging-api/#send-push-message


これで完成です。
正常にソートされた結果が LINE ボットから返ってくることをご確認ください。

ちなみに Power Apps のキャンバスアプリは、データベースから自動生成してちょっと修正した程度のシンプルなものです。


4. 改良:数量を入力されても並び替えられるようにする

これまでご紹介した方法で、最低限は並び替えはできるのですが、不満な点がひとつ。
それは、買うものの数量を入れると検索できない点です。


つまり、こういうことが発生しているんですね。


そこで、買い物メモに数量が含まれていても並び替えができるように改良してみました。
※ただし品目名と数量の間には空白が含まれている前提


先ほど作成したものを改良し、おおよそ以下のように処理してみます。
品目名と数量の間にスペースを入れてもらうことで、DB検索時に品目名だけで検索できるようにします。


4-1. 改良点① LINE から受信した品目をテーブル化

3-2. の処理を一部改良します。改良するのは「選択」アクションです。


Amount 列を追加し、Name 列と Amount 列の値をそれぞれ以下のように指定します。

Name 列

if(greater(indexof(item(), ' '), -1), first(split(item(), ' ')), if(greater(indexof(item(), ' '), -1), first(split(item(), ' ')), item()))

Amount 列

if(greater(indexof(item(), ' '), -1), last(split(item(), ' ')), if(greater(indexof(item(), ' '), -1), last(split(item(), ' ')), ''))

Name 列では、はじめに「全角の空白文字が含まれているか?」を indexof 関数で調べ、結果が -1 でない場合(全角の空白文字が見つかった場合)は「全角の空白文字で分割したやつの最初のほう」を取り出します。
同様に、次の条件判定で「半角の空白文字が含まれているか?」を調べ、見つかった場合は「半角の空白文字で分割したやつの最初のほう」を取り出します。
空白文字が含まれていない場合は、品目名のみが記載されていると判断し、値(item())をそのまま設定します。

Amount 列も同じような考え方で処理しています。
Name列と異なる点は、「空白文字で分割したやつの"最後"のほう」を取得することと、空白文字がない場合は数量がないということになるため何も設定しないことです。


この処理により、3-2.の結果はこうなります。


4-2. 改良点② 品目にソート順を付与

3-3. の処理を一部改良します。改良するのは2つある「文字列変数に追加」アクションと、「JSON の解析」アクションです。


「文字列変数に追加」アクションでは、値に Amount を加えます。


JSON の解析」アクションにも、スキーマに Amount を加えます。

{
    "items": {
        "properties": {
            "Amount": {
                "type": "string"
            },
            "ID": {
                "type": "integer"
            },
            "Name": {
                "type": "string"
            }
        },
        "required": [
            "ID",
            "Name"
        ],
        "type": "object"
    },
    "type": "array"
}


ここまでで、こうなりました。


4-3. 改良点③ ソートできた/できなかった品目を文字列化

3-5. の処理を一部改良します。改良するのは2つある「選択」アクションです。


これまで Name 列のみ抽出していたものを、Name 列と Amount 列をくっつけて抽出するように改良します。

マップの値はどちらも以下です。

concat(item()?['Name'], item()?['Amount'])

品目名と数量の間に空白を設けたい場合は、以下のようにするとよいです。

concat(item()?['Name'], ' ', item()?['Amount'])


ここまでで、こうなりました。


ということで、こんな感じに返答してくれれば成功です。


5. まとめ

実際に作成してから1ヶ月ほど経過しましたが、買い物で3回に1回くらいは活用できています。便利。
といっても、まだまだ甘いポイントがありそうな気がしますので、随時改良してみようと思います。


ということで、せっかくなのでご興味ある方いらっしゃいましたら、どうぞご自由に使ってみてください。
不具合報告など大歓迎です。

なお、データベースに登録されていない品目につきましては、小玉の方で手動でIDを付与したりしなかったりします。
また、「うちのスーパーは順番が違うから変えてほしい」というリクエストにはお答えできませんので、何卒ご了承ください。
さらに、このボットは事前の予告なしに変更・停止する場合がありますので、何卒ご了承ください。



欲しいアプリは欲しい人が作る時代へ。
何かのご参考になれば幸いです。