1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゴルフ場レストランのランチタイムを支援する

Posted at

ゴルフ場のレストラン厨房なんか、見る機会はほとんどないと思います。ゴルフ場のIT化はまず電動カートのGPS装備や協議スコア、会計システムから始まりますが、厨房は後回しのようです。そんな厨房の作業ミスを少しでも減らそうと企画しました。

ゴルフ場レストランの実態

 ゴルフ場レストランのランチタイムの厨房を想像してほしい。4人一組のプレーヤーが午前中のラウンドを終え1時欄のランチタイムに続々戻ってくる。もちろん事前に料理をオーダしておらず、注文したらすぐ料理を提供しないといけない。そのため、手早く作れ、ゴミもあまり出ない、半製品・完成品を利用したメニューがほとんどである。
 レストランホールスタッフは、各テーブルを回りオーダーをハンディターミナル(HT)に入力する。このHTは会計のレジと繋がってはいるが、その他のシステムと繋がっているのはオプション対応である。手書き用紙にオーダーを書いて厨房に提出するが、小さいレストランであれば、それをだれかが読み上げれば厨房全員に声が届く。しかし、大きいゴルフ場だと1日250人以上がランチタイム3時間に少しずつずれて押し寄せてくる。それをさばくための厨房だと場所も広くスタッフ多く雑然としている。筆者の知っている厨房では、料理長は拡声器で入ってくる注文を読み上げている。その情報は大型ディスプレイに表示されるわけでもないので聞き逃すと大クレームである。

ランチタイムを支援する

 HTの注文データを自動でスピーカーから流すと同時に大型ディスプレイも表示させる、というのができれば理想的だが、ゴルフ場とレストランはたいてい別組織であり、このようなシステムはゴルフ場側が準備する契約になっているため、しかもレジのシステムはゴルフ場売上システムと一体になっているため、システム変更はゴルフ場の理解が得られないと実現は困難である。
 そこでゴルフ場のシステムに影響することなく、料理長が読み上げた音声をスマホで自動認識し、大型ディスプレイに表示するシステムを提案する。こうすることで、聞き逃したオーダーディスプレイを目視確認できる。本企画のプロトタイプでは、スマホが拾った拡声器のオーダ情報を自動認識し、大型ディスプレイの代わりのPC画面に表示させるまでを行うものである。

プロトタイプのシステム構成

プロトタイプのシステム構成は以下である。
1.スマートフォン(Android)
 その役割は以下:
  ・料理長の読み上げたオーダ内容を流す拡声器の音声を拾う。
  ・その読み上げ情報は、
「テーブルxxx、料理名yyy、追加zzz、料理名xxx、追加無し、料理名xzx、追加1zzz、追加2xxx」
のように1テーブル1名から4名のオーダーとなる。
   料理名や追加はあらかじめメニューにあるものが選ばれ、ないものはスタッフが受け付けない。
  ・認識されたオーダー情報は、予め決められGoogle Spreadsheetに記入される。
 ・料理の提供が済んだら「テーブルXX、終了」と音声が流れる。
・スマホ側の開発は、プロトタイプであるため、正式にアプリ申請をするわけではないのでPWAにて開発とする。

2.Google Spreadsheet
   スマホからのオーダー情報を待ち受け、記録する。

3.PC
   画面にGoogle Spreadsheetの情報をWEB表示する。
   オーダー表示が追加されたら、最新の情報を表示する。
   料理提供が終わったら、そのオーダーの処理終了も表示する。

開発内容

 ChatGPTに以下のプロンプトで指示を出した。

・厨房で料理長がスマホに向かって話す料理オーダーを、スマホ側で音声認識し、Google Spreadsheet に書き込みます。
・別のPCの画面ではブラウザでGoogle Spreadsheetを表示しており、Google Spreadsheetの内容が変更されたらブラウザ表示を更新します。
・料理長が話す料理オーダーは次のフォーマットです。
 「テーブル1番、一人目:料理:定食A、追加:XとY、二人目:料理:カレー、追加:無し、三人目:料理:定食B、追加:ドリンクA、四人目:料理C:、追加無し」。
・テーブル番号は1番から100版があります。
・一つのテーブルには1人、二人、三人、四人の場合があります。
・提供済みなどの状態管理もしたい。その場合、音声では「テーブル3番 終了」の音声が流れる。
・テーブル1番の次にテーブル5番のオーダーが流れることもある。
・3人しかいないテーブルのある。
・スマホ側のソフトウエアはPWAでCodePenで作成します。Google側はGASとします。
以上でコードを作り直しなさい。

その結果、以下の回答があった。コードは長いので折りたたんである。

以下がGAS側のコード。

SpreadsheetGAS

/*** ▼ 必要に応じて変更可 ▼ ***/
const HEADER = [
  '料理オーダーが入った時刻','テーブル番号','人数',
  '料理名','追加1','追加2','追加3','状態'
];
/*** ▲ 必要に応じて変更可 ▲ ***/

function doPost(e) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  ensureHeader_(sheet);

  const now = new Date();
  const data = JSON.parse(e.postData.contents || '{}');

  // 完了(=提供済) 更新
  if (data.type === 'complete' && Number.isFinite(data.table)) {
    markCompleted_(sheet, data.table);
    SpreadsheetApp.flush();
    return json_({status: 'ok', action: 'complete'});
  }

  // 新規挿入
  if (data.type === 'insert' && Array.isArray(data.orders) && Number.isFinite(data.table)) {
    const table = data.table;
    const total = Number.isFinite(data.totalPersons) ? data.totalPersons : (data.orders.length || 0);

    const rows = data.orders.map(o => ([
      now,                           // A: 時刻
      table,                         // B: テーブル番号
      total,                         // C: 人数
      (o.dish || ''),                // D: 料理名
      (o.add1 || ''),                // E: 追加1
      (o.add2 || ''),                // F: 追加2
      (o.add3 || ''),                // G: 追加3
      '提供前'                       // H: 状態
    ]));

    if (rows.length) {
      sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, HEADER.length).setValues(rows);
    }
    SpreadsheetApp.flush();
    return json_({status: 'ok', action: 'insert', count: rows.length});
  }

  return json_({status: 'ng', reason: 'bad payload'});
}

/* —— ヘルパー —— */

function ensureHeader_(sheet) {
  const firstRow = sheet.getRange(1, 1, 1, HEADER.length).getValues()[0];
  const needs = firstRow.some((v, i) => String(v || '') !== HEADER[i]);
  if (needs) sheet.getRange(1, 1, 1, HEADER.length).setValues([HEADER]);
}

function markCompleted_(sheet, tableNumber) {
  const last = sheet.getLastRow();
  if (last < 2) return;

  // B列=テーブル番号、H列=状態
  const tables = sheet.getRange(2, 2, last - 1, 1).getValues(); // B2:B
  const states = sheet.getRange(2, 8, last - 1, 1).getValues(); // H2:H
  const toWrite = [];

  for (let i = 0; i < tables.length; i++) {
    const tbl = Number(tables[i][0]);
    if (tbl === tableNumber && states[i][0] !== '提供済') {
      toWrite.push(i + 2); // 行番号
    }
  }
  toWrite.forEach(r => sheet.getRange(r, 8).setValue('提供済'));
}

function json_(obj) {
  return ContentService
    .createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

以下がCodePen側のHTMLのコード。

CodePenHTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>厨房オーダー音声送信(PWA)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="manifest" href="manifest.json" />
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; }
    button { font-size: 18px; padding: 10px 14px; }
    .log { white-space: pre-wrap; border: 1px solid #ccc; padding: 8px; margin-top: 12px; }
  </style>
</head>
<body>
  <h1>🎤 厨房オーダー音声送信</h1>
  <p>ボタンを押して話してください。例:「テーブル1、人数は4、一人目、料理は…、終了」</p>
  <button id="startBtn">音声認識開始</button>
  <div class="log" id="log"></div>

  <script>
    // ★★★ ここを書き換え:GASのウェブアプリURL ★★★
    const GAS_URL = "https://script.google.com/macros/s/XXXXXXXX/exec";

    // PWA: Service Worker登録(任意)
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('sw.js').catch(console.warn);
    }

    const logEl = document.getElementById('log');
    function log(s){ logEl.textContent = (s + "\n" + logEl.textContent).slice(0, 8000); }

    // 音声認識(Chrome/Android向け)
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) {
      log("このブラウザは音声認識に対応していません。Chrome/Androidをお使いください。");
    }
    const rec = SR ? new SR() : null;
    if (rec) {
      rec.lang = "ja-JP";
      rec.continuous = false;
      rec.interimResults = false;

      rec.onresult = (ev) => {
        const text = ev.results[0][0].transcript.trim();
        log("認識: " + text);
        handleTranscript(text);
      };
      rec.onerror = (ev) => log("音声認識エラー: " + ev.error);
      document.getElementById('startBtn').onclick = () => rec.start();
    }

    // —— 文字→数値(全角/漢数字対応)——
    function normalizeNumber(s) {
      if (!s) return NaN;
      const mapFull = {'':'0','':'1','':'2','':'3','':'4','':'5','':'6','':'7','':'8','':'9'};
      s = s.replace(/[0-9]/g, ch => mapFull[ch] || ch);

      const kanji = {'':1,'':2,'':3,'':4,'':5,'':6,'':7,'':8,'':9,'':10,'':100};
      if (/^[一二三四五六七八九十百]+$/.test(s)) {
        // 簡易(最大100まで)
        let total = 0, temp = 0;
        for (const ch of s) {
          if (ch === '') { temp = (temp || 1) * 100; total += temp; temp = 0; }
          else if (ch === '') { temp = (temp || 1) * 10; total += temp; temp = 0; }
          else { temp += kanji[ch]; }
        }
        total += temp;
        return total;
      }
      const n = parseInt(s, 10);
      return Number.isFinite(n) ? n : NaN;
    }

    // —— 解析 → GAS送信 ——
    function handleTranscript(raw) {
      const text = raw.replace(/\s+/g, ''); // 空白除去(読点は残す)

      // テーブル番号
      const tableMatch = text.match(/(?:テーブル\s*|テーブルの\s*|)(\d+|[0-9]+|[一二三四五六七八九十百]+)(?:番|テーブル)?/);
      if (!tableMatch) { log("テーブル番号が認識できません"); return; }
      const table = normalizeNumber(tableMatch[1]);
      if (!Number.isFinite(table)) { log("テーブル番号が数値化できません"); return; }

      // 完了(提供済)コマンド
      if (text.includes('完了') || (text.includes('終了') && !text.match(/料理/))) {
        sendComplete(table);
        return;
      }

      // 人数
      let totalPersons = 0;
      const pplMatch = text.match(/人数は?(\d+|[0-9]+|[一二三四])([名人])/);
      if (pplMatch) totalPersons = normalizeNumber(pplMatch[1]);
      if (!Number.isFinite(totalPersons) || totalPersons < 1 || totalPersons > 4) {
        // 話し忘れ・認識抜けでも、後段で人別に拾えるので「仮」に0許容
        totalPersons = 0;
      }

      // 人別セクション抽出
      const marks = [
        {lab:'一人目', re: /(?:一|1|1)人目/},
        {lab:'二人目', re: /(?:二|2|2)人目/},
        {lab:'三人目', re: /(?:三|3|3)人目/},
        {lab:'四人目', re: /(?:四|4|4)人目/}
      ];
      // テーブル部分以降を対象
      const afterTable = text.slice(tableMatch.index + tableMatch[0].length);

      // インデックスを見つける
      const idx = [];
      marks.forEach((m, i) => {
        const found = m.re.exec(afterTable);
        if (found) idx.push({p:i+1, start: found.index, label:m.lab});
      });
      // 範囲化
      idx.sort((a,b)=>a.start-b.start);
      if (idx.length === 0) { log("人別セクションが見つかりません(「一人目…」などの言い回しにしてください)"); return; }

      // セグメント切り出し
      const segments = [];
      for (let k=0; k<idx.length; k++) {
        const segStart = idx[k].start;
        const segEnd = (k < idx.length-1) ? idx[k+1].start : afterTable.length;
        segments.push({person: idx[k].p, text: afterTable.slice(segStart, segEnd)});
      }

      // 各人の料理・追加を抽出
      const orders = segments.map(seg => {
        const s = seg.text;
        // 料理名
        let dish = '';
        const mDish = s.match(/料理(?:は|:|:)?([^、,。]+)/);
        if (mDish) dish = mDish[1].replace(/^()/,'').trim();

        // 追加(無し判定)
        let add1='', add2='', add3='';
        if (!/追加は?[]|追加無[]/.test(s)) {
          // 追加1〜3
          const m1 = s.match(/追加\s*1|追加\s*1|追加1|追加1/)? s.match(/追加(?:\s*1|1|1)(?:は|:|:)?([^、,。]+)/) : null;
          const m2 = s.match(/追加\s*2|追加\s*2|追加2|追加2/)? s.match(/追加(?:\s*2|2|2)(?:は|:|:)?([^、,。]+)/) : null;
          const m3 = s.match(/追加\s*3|追加\s*3|追加3|追加3/)? s.match(/追加(?:\s*3|3|3)(?:は|:|:)?([^、,。]+)/) : null;
          if (m1) add1 = (m1[1]||'').trim();
          if (m2) add2 = (m2[1]||'').trim();
          if (m3) add3 = (m3[1]||'').trim();

          // 「追加:サラダ」(番号省略)を1つだけ言うケースを救済
          if (!add1 && !add2 && !add3) {
            const mAny = s.match(/追加(?:は|:|:)?([^、,。]+)/);
            if (mAny) add1 = (mAny[1]||'').trim();
          }
        }

        return { person: seg.person, dish, add1, add2, add3 };
      }).filter(o => o.dish); // 料理が取れた行のみ送る

      if (!orders.length) { log("料理名が抽出できませんでした。言い直してください。"); return; }

      // 人数が取れていなければ推定
      if (!totalPersons) totalPersons = Math.min(orders.length, 4);

      // 「終了」が末尾にある通常オーダー
      sendInsert({ table, totalPersons, orders });
    }

    async function sendInsert(payload) {
      try {
        const body = JSON.stringify({ type: 'insert', ...payload });
        await fetch(GAS_URL, {
          method: 'POST',
          mode: 'no-cors',
          headers: { 'Content-Type': 'application/json' },
          body
        });
        log('送信: 新規オーダー → テーブル' + payload.table + ' / ' + payload.totalPersons + '名 / ' + payload.orders.length + '');
      } catch (e) {
        log('送信エラー: ' + e.message);
      }
    }

    async function sendComplete(table) {
      try {
        const body = JSON.stringify({ type: 'complete', table });
        await fetch(GAS_URL, {
          method: 'POST',
          mode: 'no-cors',
          headers: { 'Content-Type': 'application/json' },
          body
        });
        log('送信: 完了(提供済) → テーブル' + table);
      } catch (e) {
        log('送信エラー: ' + e.message);
      }
    }
  </script>

  <!-- (任意)manifest と SW を同じPen内に作る場合は「Assets」か別ファイルとして追加してください -->
</body>
</html>

以下がCodePen側のAssetにおくPWA用のmanifest.jsonのコード。

CodePen-manifest.json

{
  "name": "厨房オーダー音声送信",
  "short_name": "音声オーダー",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#ffffff",
  "icons": []
}

以下がCodePen側のAssetにおくPWA用のsw.jsのコード。

CodePen-sw.js

self.addEventListener('install', e => self.skipWaiting());
self.addEventListener('activate', e => clients.claim());


尚、PC画面に最新のSpreadsheetを更新するのは今回は行わないので、画面表示が切り替わるには数秒かかる。

なんとか動き出した

こちらがiPhoneの画面。動画でとれなくて残念。

こちらがオーダーを音声で入力したものをSpreadsheetに飛ばした画面。

音声認識はむずかしい

こんなに音声認識が不安定なものだとは思わなかった。「一人目」を”ひとりめ”と発音するよりも”いちにんめ”のほうが理解しやすいとか。実用化にはまだまだチューニングが必要である。

努力は続く。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?