Laravel + QRコードで飲食店のメニュー注文機能をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、日頃から目が悪くなりながらも(笑)スマホを使っていろいろと情報収集をしているのですが、やはり我々の目につくニュースというのは時代の流れを反映したものが多かったりします。

特に今は、コロナウィルスの影響で今までの生活が一変してしまったわけですが、IT業界でも同じくコロナに関連した情報がたくさん出てきます。

そして、今回開発する内容は「接触をしないために」考えられた機能でした。

それは・・・・・・

飲食店のメニューをスマホで注文する

という機能です。

つまり、通常の飲食店に置いてあるメニューはいろんな人が触ったりするため、コロナウイルス対策としては毎回消毒するなどの手間がかかってしまうわけですが、QRコードを「ピッ!」と読み取って欲しいものを選ぶというスタイルなら、より衛生的になるわけですね。

そこで❗

今回はこの機能をLaravelで作ってみることにします。

また、(これは個人的な話ですが)こういった開発ではだいたい同じような流れになってしまい少しモチベーションが保ちにくいので、今回は「あまり使わない書き方、機能」も使って開発していきたいと思います。(👉チャレンジ・コーディングと大げさに呼びます😂)

ぜひそのあたりも楽しんでいただけたら嬉しいです。😊✨

「Go言語とか Rust
にも興味あります」

開発環境: Laravel 8.x、Vue 3、Bootstrap 5

マイグレーション&モデルをつくる

では、まずはデータベースまわりから準備していきます。
以下のコマンドを実行してください。

php artisan make:model MenuItem -m
php artisan make:model MenuOrder -m

すると、DBの設計図になる「マイグレーション」とDBのデータ入出力をする「モデル」のファイルが作成されるので、それぞれ中身を次のようにします。

database/migrations/****_**_**_******_create_menu_items_table.php

// 省略

public function up()
{
    Schema::create('menu_items', function (Blueprint $table) {
        $table->id();
        $table->string('name')->comment('料理名');
        $table->unsignedInteger('price')->comment('価格');
        $table->timestamps();
    });
}

// 省略

database/migrations/****_**_**_******_create_menu_orders_table.php

// 省略

public function up()
{
    Schema::create('menu_orders', function (Blueprint $table) {
        $table->id();
        $table->uuid('uuid')->comment('UUID');
        $table->unsignedBigInteger('menu_item_id')->comment('料理ID');
        $table->unsignedInteger('quantity')->comment('個数');
        $table->timestamps();

        $table->foreign('menu_item_id')->references('id')->on('menu_items');
    });
}

// 省略

そして、モデルです。

app/Models/MenuOrder.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class MenuOrder extends Model
{
    use HasFactory;
    
    // Relationship
    public function menu_item()
    {
        return $this->belongsTo(MenuItem::class, 'menu_item_id', 'id');
    }
}

テストデータをつくる

続いて、先ほど作成したテーブルにメニューのテストデータをSeeder機能をつかって追加していきましょう。

php artisan make:seed MenuItemSeeder

するとファイルが作成されるので、中身を次のようにします。

database/seeders/MenuItemSeeder.php

<?php

namespace Database\Seeders;

use App\Models\MenuItem;
use Illuminate\Database\Seeder;

class MenuItemSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $menu_data = [
            ['name' => 'ハンバーガー', 'price' => 1000],
            ['name' => 'パスタ', 'price' => 800],
            ['name' => 'ラーメン', 'price' => 900],
            ['name' => 'ピザ', 'price' => 1000],
            ['name' => '寿司', 'price' => 1500],
        ];

        foreach ($menu_data as $menu_values) {

            $menu_item = new MenuItem();
            $menu_item->name = $menu_values['name'];
            $menu_item->price = $menu_values['price'];
            $menu_item->save();

        }
    }
}

Seederファイルはつくっただけでは有効にはなりませんので、DatabaseSeederに登録しましょう。

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    // 省略

    public function run()
    {
        // 省略

        $this->call(MenuItemSeeder::class); // 👈 ここを追加しました
    }
}

では、この状態でDBを再構築します。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

実行が完了するとテーブルは以下のようになります。

※ なお、本来は1回の注文ごとにmenu_ordersテーブルと、その詳細のmenu_order_detailsのような親子関係のテーブルをつくるべきですが、今回はテストなのでシンプルに作成しています。

Resource をつくる

今回は「チャレンジ・コーディング」ですので、私も普段あまり使わないResourceを使って実装してみます。

ここで言うResourceとは、簡単にデータ加工ができるLaravelの機能のことを指しています(本来はAPI用なので、API Resourceとして紹介されています)

今回なぜこのリソースを使うかというと、複数の注文内容から必要なデータだけを取得でき、さらに元々は存在しない合計金額や合計件数を一緒に用意することができるからです。

実際に見ていきましょう。
以下のコマンドを実行してください。

php artisan make:resource MenuOrderCollection

すると、ファイルが作成されるので中身を以下のように変更します。

app/Http/Resources/MenuOrderCollection.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class MenuOrderCollection extends ResourceCollection
{
    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request = null)
    {
        $total_price = $this->collection->sum(fn($item) => $item->menu_item->price * $item->quantity); // 合計金額
        $total_quantity = $this->collection->sum(fn($item) => $item->quantity); // 合計注文数

        return [
            'data' => $this->collection,
            'total_price' => $total_price,
            'total_quantity' => $total_quantity
        ];
    }

}

これで、以下のようにすることで必要なデータだけを取得できるようになります。

use App\Models\MenuOrder;
use App\Http\Resources\MenuOrderCollection;

$menu_orders = MenuOrder::get();
$collection = new MenuOrderCollection($menu_orders);

foreach($collection as $order) { // 👈 data 部分をループできます。

    dump($order->toArray());

}

echo json_encode($collection->toArray()); // 👈 全データを取得できます

イベントをつくる

そして、「注文が完了したとき」に実行されるイベントをつくっていきましょう。

以下のコマンドを実行してください。

php artisan make:event MenuOrdered

イベント用のファイルが作成されるので、中身を以下のようにします。

app/Events/MenuOrdered.php

<?php

namespace App\Events;

use App\Http\Resources\MenuOrderCollection;
use App\Models\MenuOrder;

// 省略

class MenuOrdered
{
    // 省略

    public function __construct($uuid)
    {
        $menu_orders = MenuOrder::with('menu_item')->where('uuid', $uuid)->get();
        $menu_order_collection = new MenuOrderCollection($menu_orders);

        logger($menu_order_collection->toArray()); // 👈 ログに出力する

        // 実際の開発では、ここでメールやプリントアウトの実行をする
    }

// 省略

コントローラーをつくる

では、ここまでで作ったResourceとイベントを使い、コントローラーを作成します。以下のコマンドを実行してください。

php artisan make:controller MenuOrderController

するとファイルが作成されるので、中身を次のようにします。

app/Http/Controllers/MenuOrderController.php

<?php

namespace App\Http\Controllers;

use App\Events\MenuOrdered;
use App\Models\MenuItem;
use App\Models\MenuOrder;

// 省略

class MenuOrderController extends Controller
{
    public function create($seat_id)
    {
        $menu_items = MenuItem::select('id', 'name', 'price')->get();

        return view('menu_order.create')->with([
            'menu_items' => $menu_items,
            'seat_id' => $seat_id
        ]);
    }

    public function store(Request $request)
    {
        // ⚠ご注意: バリデーションは省略してます

        $uuid = Str::uuid(); // このUUIDを使って各注文をひも付ける
        $orders = $request->orders;

        DB::transaction(function () use($uuid, $orders) {

            foreach ($orders as $order) {

                $menu_order = new MenuOrder();
                $menu_order->uuid = $uuid;
                $menu_order->menu_item_id = $order['id'];
                $menu_order->quantity = $order['quantity'];
                $menu_order->save();

            }

        });
        event(new MenuOrdered($uuid));

        return ['result' => true];
    }
}

ビューをつくる

続いて、コントローラーで指定したビューです。
以下のファイルを作成してください。

resources/views/menu_order/create.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-4">
    <h4 class="mb-4 fw-bold">席番号: {{ $seat_id }}番</h4>
    <div style="padding-bottom:50px;" v-if="isStatusInput">
        <div class="card mb-4" style="width: 18rem;" v-for="menuItem in menuItems">
            <img src="https://via.placeholder.com/640x360/F7F7F7?text=TEST%20IMAGE" class="card-img-top">
            <div class="card-body">
                <h5 class="card-title" v-text="menuItem.name"></h5>
                <div class="text-end">
                    <span v-text="menuItem.price"></span>円
                </div>
            </div>
            <div class="card-footer">
                <h5 class="float-start" v-if="getMenuOrder(menuItem.id)">
                    <strong v-text="getMenuOrder(menuItem.id).quantityText"></strong>
                </h5>
                <div class="float-end">
                    <a href="#" class="btn btn-sm btn-outline-secondary me-2" @click.prevent="changeOrder(menuItem, -1)">ー</a>
                    <a href="#" class="btn btn-sm btn-primary" @click.prevent="changeOrder(menuItem, 1)">+</a>
                </div>
            </div>
        </div>
        <div style="position:fixed;left:0;bottom:0;right:0;" class="bg-light p-3 text-center" v-if="hasActualOrder">
            <button type="button" class="btn btn-info btn-lg text-black" @click="setStatus('confirmation')">確認する</button>
        </div>
    </div>
    <div v-if="isStatusConfirmation">
        <div v-if="menuOrders.length">
            <table class="table">
                <thead>
                <tr>
                    <th>商品名</th>
                    <th>価格</th>
                    <th>個数</th>
                    <th>小計</th>
                </tr>
                </thead>
                <tbody>
                <template v-for="order in menuOrders">
                    <tr v-if="order.hasOrder">
                        <td v-text="order.menuItem.name"></td>
                        <td v-text="order.menuItem.price"></td>
                        <td v-text="order.quantity"></td>
                        <td v-text="order.subtotal"></td>
                    </tr>
                </template>
                </tbody>
            </table>
            <button type="button" class="btn btn-link me-3" @click="setStatus('input')">戻る</button>
            <button type="button" class="btn btn-info text-black" @click="onSubmit">注文する</button>
        </div>
        <div v-else>
            <div class="mb-3">
                注文データが存在しません。
            </div>
            <button type="button" class="btn btn-secondary me-3" @click="setStatus('input')">戻る</button>
        </div>
    </div>

</div>
<script src="https://unpkg.com/vue@3.1.1/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>

    // 注文内容を管理する JavaScript クラス
    class MenuOrder {

        constructor(menuItem, quantity) {

            this.order = {
                menuItem: menuItem,
                quantity: this.getCorrectedQuantity(quantity)
            };

        }

        // Setter
        set quantity(quantity) {

            this.order.quantity = this.getCorrectedQuantity(quantity);

        }

        // Getter
        get quantity() {

            return parseInt(this.order.quantity);

        }

        get quantityText() {

            return (this.hasOrder) ? this.order.quantity +' 件' : '';

        }

        get menuItem() {

            return this.order.menuItem;

        }

        get subtotal() {

            return this.menuItem.price * this.quantity;

        }

        get hasOrder() {

            return (this.quantity > 0);

        }

        get data() {

            return {
                id: this.menuItem.id,
                quantity: this.quantity
            };

        }

        // Others
        getCorrectedQuantity(quantity) {

            return Math.max(0, quantity); // // 最低値はゼロにする

        }

        isSame(id) {

            return (parseInt(this.menuItem.id) === parseInt(id));

        }

    }

    Vue.createApp({
        data() {
            return {
                status: 'input',
                seatId: '{{ $seat_id }}',
                menuOrders: [],
                menuItems: @json($menu_items)
            }
        },
        methods: {
            changeOrder(menuItem, additionalQuantity) {

                let menuOrder = this.getMenuOrder(menuItem.id);

                if(!menuOrder) {

                    this.menuOrders.push(
                        new MenuOrder(menuItem, additionalQuantity)
                    );

                } else {

                    menuOrder.quantity += additionalQuantity;

                }

            },
            getMenuOrder(id) {

                return this.menuOrders.find(order => order.isSame(id));

            },
            setStatus(status) {

                this.status = status;

            },
            onSubmit() {

                const url = '{{ route('menu_order.store') }}';
                const params = {
                    orders: this.menuOrders.map(order => order.data)
                };
                axios.post(url, params)
                    .then(response => {

                        if(response.data.result === true) {

                            this.menuOrders = [];
                            this.setStatus('input');
                            alert('注文が完了しました');

                        }

                    });

            }
        },
        computed: {
            isStatusInput() {

                return (this.status === 'input');

            },
            isStatusConfirmation() {

                return (this.status === 'confirmation');

            },
            hasActualOrder() {

                if(this.menuOrders.length > 0) {

                    const totalQuantity = this.menuOrders
                        .map(order => order.quantity) // 注文件数だけの配列をつくる
                        .reduce((sum, x) => sum + x); // 注文件数の合計を取得
                    return (totalQuantity > 0);

                }

                return false;

            }
        }
    }).mount('#app');

</script>
</body>
</html>

なお、ここでも「チャレンジ・コーディング」ということで普段あまり使わないJavaScriptのクラスを使って実装しています。

このクラスの中には注文データが格納されていて、さらに小計の金額などを取得するGetterなどを追加しています。

また、find()map()なども1行で書くことはあまりしませんが、今回はあえてコード行が少なくなるようにしてみました👍

ルートをつくる

最後にルートです。
今回必要なのは2つです。

use App\Http\Controllers\MenuOrderController;

// 省略

Route::get('menu_order/create/{seat_id}', [MenuOrderController::class, 'create'])
    ->where('seat_id', '[0-9]+')
    ->name('menu_order.create');
Route::post('menu_order', [MenuOrderController::class, 'store'])->name('menu_order.store');

なお、1つ目のルートにはwhere()が入っていますが、これは、{seat_id}に入ってくるデータを数字だけに限定するために使っています。(席番号は今回数字のみを想定しています)

テストしてみる

では、実際に試してみましょう❗
まず、「http://******/menu_order/create/1」にアクセスしてください。

すると・・・・・・

席番号1が表示され、メニューごとに個数を選択できるようになっています。
では、ハンバーガーを3つにしてみます。

どうなるでしょうか・・・・・・

はい❗
クリックするたびに数字が変わって3件の注文になりました。

さらに、注文が追加されるとページ下部に確認ボタンが表示されます。

では、この状態でクリックしてみましょう。

すると・・・・・・

はい❗注文した内容のリストが表示されました。
では、そのまま「注文する」ボタンをクリックしてみましょう。

すると・・・・・・

ポップアップで完了が通知されました。
成功です😊✨

では、データベースの中にキチンと保存されているかチェックしてみましょう。

きちんと登録されていますね👍

では、最後にイベントの中に記述したログファイルがどうなっているか確認してみましょう。

[2021-07-02 03:39:58] local.DEBUG: array (
  'data' => 

  // 長かったので省略

  'total_price' => 3000,
  'total_quantity' => 3,
)

はい❗

こちらもうまくできました。
全て成功です😊✨

デモを用意しました

実際に触らないと分かりにくい部分もあると思いましたので、デモページを用意しました。

ぜひ試してみてください。

📝 デモページ

※ ただし、デモページでは保存はできません。

企業様へのご提案

今回の機能を使えば、コロナウイルス対策になりますが、さらに拡張すれば以下のような機能も実装することができます。

  • 注文データの統計をとって、「どの時間帯」「どの商品」「どのテーブル」の売上が多いのか、逆にあまり注文されていないデータをチェックして商品を入れかえるなどマーケティングに利用できます。
  • すでに注文データは揃っているので、レジ打ちをする必要がない(QRコード、RFID、もしくはNFCなどで伝票の識別IDを読み取るだけで会計の準備ができます)
  • システムがインターネット上に存在しているので、デリバリーの注文としても拡張しやすい(今回でいうとデリバリー専用の「席ID」を用意するだけでOKです)
  • もちろん、飲食店だけでなくさまざまな商品の販売にも使うことができます。

こういった機能をご希望の方はぜひお問い合わせからご連絡ください。m(_ _)m

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravelで「スマホでできる注文機能」をつくってみました。

なお、今回ははじめて本格的にJavaScriptのクラスを開発で使ってみました。

感想としては、「いろんなところで使うなら、省コード化としても使いやすいかも」というカンジで好感触でした。

しかも、Object.assign()を使えば、ミックスインのように共通部分を切り出すこともできるので、プロジェクト間を行き来する内容をつくれば、開発の効率化にもつながるんじゃないでしょうか。

変化が早い時代なので、長く同じ場所にいることはリスクになってきていると感じます。私もいろいろと試しながらスキルの維持&向上を目指したいと思います。

ぜひ皆さんもやってみてくださいね。

ではでは〜❗

「目が良くなる目薬に
期待しています👍」

このエントリーをはてなブックマークに追加       follow us in feedly