どうも、横浜で清掃業をやりながらAIを勉強中の49歳・ヤスです。
今回は、過去記事(第92話)で作った「写真1枚で見積書を自動作成するアプリ」を、思いっきりパワーアップさせました。しかも、その制作過程で踏んだ地雷を、一つも隠さず全部書きます。😇
📌 この記事でわかること
今回作ったのは、こんなアプリです。
- 清掃業・外壁塗装・リフォーム・造園の4業種に対応
- 業種を選んで、現場写真をアップするだけで見積もりが出る
- 単価はGoogleスプレッドシートで管理(値上げもシート修正だけ)
- 採寸した数値を入れれば、面積などの精度もアップ
- 最終的に、罫線つきの本格的な見積書PDFが自動で生成される
- お客様に見せられるキレイな画面+PDFダウンロードリンクで完成
そして何より——この記事の本当の価値は、成功手順より「失敗の記録」です。スペース1個でハマった話、変数が見つからずエラーで止まった話、Googleの権限承認でつまずいた話、カッコ1個ズレて構文エラーになった話…全部書きました。同じところでハマる人の助けになれば嬉しいです。
🎯 まずは完成形を見てください
最終的に、こういう流れのアプリになりました。
| ユーザーが業種を選ぶ(清掃業など4業種) ↓ 現場写真をアップ + 採寸メモを入力(任意) ↓ 業種ごとの専門AIが写真を分析(数量を出す) ↓ 4業種の出力を1本にまとめる(変数集約器) ↓ スプレッドシートから単価を自動取得(GAS①) ↓ AIが「数量 × 単価」で見積書を生成 ↓ GASが本格的な見積書PDFを自動生成(GAS②) ↓ 整形AIがキレイにまとめる ↓ お客様向けの見積もり画面+PDFリンクを表示 |
ノード(ブロック)の数は10個オーバー。最初は「写真1枚で見積もり」だけだったのに、ずいぶん本格的になりました。
使ったブロック一覧
| ブロック | 役割 |
| ユーザー入力(スタート) | 業種・写真・採寸メモを受け取る |
| IF/ELSE | 選んだ業種でルートを分ける |
| LLM × 4(業種別) | 写真を分析して数量を出す |
| 変数集約器 | 4業種の出力を1本にまとめる |
| HTTPリクエスト①(GET) | スプレッドシートから単価を取得 |
| LLM5 | 見積書+機械用JSONを生成 |
| HTTPリクエスト②(POST) | GASにデータを送りPDF生成 |
| LLM6(整形用) | JSONを消してキレイにまとめる |
| 出力 | 最終結果を表示 |
🏗️ 改良①:4業種に対応させる(IF/ELSEで分岐)
元のアプリは清掃業専用でした。これを「業種を選んだら、その業種に合った見積もりが出る」形にします。
やったことはシンプルで、最初に業種を選ばせて、IF/ELSEブロックで業種ごとに処理を分けるだけです。
| IF/ELSE ├ CASE1:業種が「清掃業」を含む → 清掃業のAIへ ├ CASE2:業種が「外壁塗装」を含む → 外壁塗装のAIへ ├ CASE3:業種が「リフォーム」を含む → リフォームのAIへ └ CASE4:業種が「造園」を含む → 造園のAIへ |
そして業種ごとに専用のAI(LLMブロック)を用意します。プロンプトはプロンプトエンジニアリングっぽく、しっかり作り込みました。たとえば清掃業の写真分析AIはこんなプロンプトです。👇
清掃業の写真分析プロンプト(最終版)
| # ロール定義 あなたは清掃業の現場調査の専門家です。 # タスク 添付された現場写真と採寸メモを分析し、見積もりに必要な情報を抽出してください。 ※ここでは金額の計算はしません。状況の整理だけ行います。 # 採寸メモ(人が実際に測った数値) {{採寸メモ}} # 写真分析の手順 写真を見て以下を確認し、項目ごとに数量を推定してください。 1. 床清掃が必要な面積(㎡) 2. 窓清掃が必要な枚数(枚) 3. トイレ清掃が必要な箇所数(箇所) 4. エアコン清掃が必要な台数(台) # 出力形式 ## 現場状況 写真から読み取った状況を3〜5行で説明する。 ## 数量リスト – 床清掃:約〇〇㎡ – 窓清掃:〇〇枚 – トイレ清掃:〇〇箇所 – エアコン清掃:〇〇台 # 制約 – 金額や単価には触れないこと(単価は後の工程で計算するため) – 採寸メモに数値がある項目は、必ずその数値を優先して使用すること – 採寸メモにない項目のみ、写真から推定すること – 数量は推定値であるため「約〇〇」と表記すること |
💡 ポイント:このAIには「金額の計算はさせない」ことが超重要。あえて「数量を出すだけ」に役割を絞っています。なぜそうしたかは、次の章で分かります。
🏗️ 改良②:単価をスプレッドシートで管理する(GAS連携)
元のアプリは、プロンプトの中に単価を直書きしていました。これだと値上げのたびにDifyを開いて編集しなきゃいけません。面倒です。
そこで、単価をGoogleスプレッドシートに外出しして、アプリが自動で読みに行く形にしました。
GASって何?という方向けに記事を書いてますのでよければこちらを読んでみてください
「GASって結局何?」が10分で分かる!未経験の僕がDify連携で使い倒した基本とメリット
スプレッドシートの構成
業種ごとにシートを4枚作って、こんな感じで単価を入れます(清掃業の例)。
| 工種 | 単価 | 単位 |
| 床清掃 | 500 | ㎡ |
| 窓清掃 | 800 | 枚 |
| トイレ清掃 | 3000 | 箇所 |
| エアコン清掃 | 8000 | 台 |
⚠️ ここの注意:単価は数字だけ。「円」や「,(カンマ)」は入れません。あとシート名は業種名と完全一致させること。これ、あとで地獄を見ます…(後述)
単価を返すGAS(その1)
スプレッドシートの「拡張機能 → Apps Script」から、業種名を受け取って単価を返すプログラムを書きます。
| function doGet(e) { // 業種パラメータを受け取る(前後の空白を自動で削除) var gyoshu = e.parameter.gyoshu.trim(); var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName(gyoshu); if (!sheet) { return ContentService .createTextOutput(JSON.stringify({error: “業種が見つかりません: ” + gyoshu})) .setMimeType(ContentService.MimeType.JSON); } var data = sheet.getDataRange().getValues(); var result = []; for (var i = 1; i < data.length; i++) { result.push({ kosu: data[i][0], tanka: data[i][1], tani: data[i][2] }); } return ContentService .createTextOutput(JSON.stringify({gyoshu: gyoshu, tankaList: result})) .setMimeType(ContentService.MimeType.JSON); } |
📝 豆知識:スプレッドシートのメニューからApps Scriptを開いた場合、getActiveSpreadsheet() が自動でそのシートにつながるので、スプレッドシートIDを書く必要はありません。最初ここで「IDどこに入れるの?」と悩みました(笑)
Difyから単価を取りに行く
GASをデプロイ(アクセスできるユーザーは必ず「全員」!)したら、DifyにHTTPリクエストブロックを追加して、こんなURLで呼び出します。
| https://script.google.com/macros/s/XXXXX/exec?gyoshu={{gyoshu}} |
URLの末尾の ?gyoshu={{gyoshu}} で、ユーザーが選んだ業種をGASに渡します。GETメソッドなので、ボディは「none」でOKです。
🏗️ ブロックの組み方:合流方式にした理由
ここで設計の分かれ道がありました。HTTPリクエスト(単価取得)を、各業種のAIの前に1個ずつ置くのか、それとも合流させて1個だけにするのか。
結論、合流方式(1個だけ)にしました。理由は、このHTTPリクエストは「業種名を渡せば単価を返す」作りなので、業種名さえ分かれば1個で全業種に対応できるからです。3個も作ったら、URL修正のたびに3箇所直すハメになります。
| IF/ELSE(業種判定) ↓ 各業種のAI(写真分析)← 4つに分岐 ↓ 4つの出力をまとめる 変数集約器 ← ここで1本化! ↓ HTTPリクエスト(単価取得)← 1個だけ ↓ LLM5(見積もり生成) |
💡 「変数集約器」というブロックが大活躍。4つのAIのうち、実際に動いた1つだけを自動で拾って、1本の変数にまとめてくれます。これがないと後でエラー地獄になります(実体験)。
😱 ここからが本番:踏んだ地雷5連発
ここまで読むと「順調そう」に見えますが、実際は全然そんなことなくて。テスト実行のたびにエラーが出て、その都度ヤスは画面の前でうなっていました。リアルな失敗を順番に記録します。
地雷①:半角スペース1個で、何も起きない
最初のテスト。ステータスは「SUCCESS」なのに、見積もりが何も出てこない。トークン数は0。「成功してるのに空っぽ?」と混乱しました。
入力データを見たら、犯人はこれでした。
| “gyoshu”: “清掃業 ” ↑ 末尾に半角スペースが入っている! |
IF/ELSEの条件が「清掃業」なのに、入力が「清掃業 」(スペース付き)。完全一致しないので、どのルートにも入らず素通りしていたんです。SUCCESSなのに空っぽ、の正体でした。

▲ ステータスは「SUCCESS」なのにトークン総数0・出力は空っぽ({})。処理ステップ数も2しかなく、AIが1つも動いていないのが分かる。
🔧 対処:とりあえずスペースなしで入力し直して動作確認。さらにGAS側に .trim()(前後の空白を消す命令)を追加して、根本対策しました。
地雷②:実行されないブロックの変数が「見つからない」
スペース問題を直したら、今度は別のエラー。
| Variable #xxxxx.text# not found (変数 .text が見つかりません) |
原因は、見積もり生成AI(LLM5)に、4業種すべてのAI出力を直接つないでいたこと。合流方式では、清掃業を選ぶと清掃業のAIだけが動いて、残り3つは動きません。動かなかったAIの出力を参照しようとして「変数がない」と落ちていたんです。
🔧 対処:ここで「変数集約器」ブロックの出番。4つのAI出力を集約器に入れて1本化し、LLM5はその1本だけを見るように変更。これでエラー解消!実は前のステップで『4つ全部入れてOK』と思い込んでいたのが間違いでした。
地雷③:採寸って、写真だけじゃ分からなくない?
動くようになって喜んでいたんですが、ふと冷静になりました。「写真を撮るだけで30秒!」とか言ってたけど、床が何㎡かなんて、写真だけじゃ正確に分からないですよね。
AIは「数えるもの」(トイレ、エアコン、窓の枚数)は得意ですが、「測るもの」(床面積、外壁面積)はあてずっぽうに近い。ここは正直に認めるべきでした。
🔧 対処:入力フォームに「採寸メモ」欄(任意)を追加。メジャーで測った数値を入れたら、AIはそれを優先する仕組みにしました。『数える系は写真でOK、測る系は採寸値を入れる』というハイブリッドが正解でした。
これでテストすると、採寸メモの「床40㎡、窓5枚」がちゃんと反映されて、合計が11,000円から35,000円に。より実態に近い見積もりになりました。
地雷④:PDF生成で「ドライブにアクセスできない」
見積書をPDFにする段階。PDF生成用のGAS(2個目)を書いてテストしたら、こんなエラーが。
| Exception: Unexpected error while getting the method or property getFileById on object DriveApp. |
これは「Googleドライブへのアクセス権限が承認されていない」という意味。単価取得GASはスプレッドシートを読むだけだったので軽い権限で済みましたが、PDF生成はドライブにファイルを作る強い権限が必要で、その承認がまだだったんです。
🔧 対処:GASエディタでテスト関数を手動実行 → 「承認が必要です」→ Googleドライブへのアクセスを「許可」。一度承認すればOK。『このアプリは確認されていません』の警告は自作アプリでは普通に出るので、ビビらず進めて大丈夫。
地雷⑤:カッコ1個ズレて構文エラー
テスト用の関数を追加したら、関数メニューに出てこない。よく見たら、前の関数を閉じる「}」の前に新しい関数を貼ってしまい、関数の中に関数が入り込んでいました。
それを直したら、今度は「}」が1個多くて構文エラー。プログラミング未経験あるあるの、カッコの数が合わない問題です。

▲ 99行目「return total;」の直後(100行目)にいきなり「function testPdf()」が来てしまっている。getTotal関数を閉じる「}」が抜けていて、testPdfが前の関数の中に入り込んでいた。
🔧 対処:結局、関数まるごとコピペし直して解決。{ と } は必ず同じ数になる、というのを身をもって学びました…。
🏗️ 改良③:本格的な見積書PDFを自動生成する
ここがこのアプリの目玉です。AIが出した見積もりを、罫線つきの本格的な見積書PDFにします。
ステップ1:AIにJSONも出させる
PDFを作るGASには「機械が読みやすいデータ」が必要です。そこでLLM5に、人間用の見積書とは別に、機械用のJSONも出させます。
| # 機械処理用データ(重要) 上記の見積書の内容を、以下のJSON形式でも出力してください。 JSONはコードブロックで囲まず、1行で出力すること。 {“gyoshu”:”業種名”,”genba_summary”:”現場状況”,”items”:[ {“komoku”:”項目名”,”kosu”:数量,”tani”:”単位”,”tanka”:単価,”kingaku”:金額} ],”gokei”:合計金額,”chui”:”注意事項”} – 数量・単価・金額は数字のみ(「円」などの文字を含めない) |
ステップ2:Googleドキュメントで雛形を作る
PDFの「型」になるGoogleドキュメントを1回だけ作ります。数字が入る場所に {{ }} の目印を置いておくのがコツです。
| 御見積書 {{date}} 御見積金額 {{gokei}}円(税別) 【現場状況】{{genba_summary}} {{items_table}} 【注意事項】{{chui}} (会社情報) |
⚠️ 目印は半角の {{ }} で。全角だとGASが認識しません。表の部分は {{items_table}} という目印1個だけ置いて、表自体はGASに作らせます。
ステップ3:PDF生成GAS(その2)
雛形をコピーして、目印を実際の数字に置き換え、PDFにして保存し、URLを返すGASです。ポイントは、Difyから届くテキストにはMarkdownの見積書も混ざっているので、正規表現でJSON部分だけを抜き出しているところ。
| function doPost(e) { try { var rawText = e.postData.contents; // テキストからJSON部分({…})だけを抜き出す var match = rawText.match(/\{[\s\S]*\}/); if (!match) { return ContentService .createTextOutput(“エラー:JSONが見つかりません”) .setMimeType(ContentService.MimeType.TEXT); } var data = JSON.parse(match[0]); var templateId = “見積書テンプレートのドキュメントID”; var folderId = “PDF保存先フォルダのID”; var folder = DriveApp.getFolderById(folderId); var copyFile = DriveApp.getFileById(templateId).makeCopy(“見積書_作業用”, folder); var doc = DocumentApp.openById(copyFile.getId()); var body = doc.getBody(); var today = new Date(); var dateStr = today.getFullYear() + “年” + (today.getMonth()+1) + “月” + today.getDate() + “日”; var gokeiStr = Number(data.gokei).toLocaleString(); body.replaceText(“{{date}}”, dateStr); body.replaceText(“{{gokei}}”, gokeiStr); body.replaceText(“{{genba_summary}}”, data.genba_summary); body.replaceText(“{{chui}}”, data.chui); insertItemsTable(body, data.items); doc.saveAndClose(); var pdfBlob = copyFile.getAs(“application/pdf”); var pdfFile = folder.createFile(pdfBlob).setName(“見積書_” + dateStr + “.pdf”); copyFile.setTrashed(true); // PDFのURLを「テキストだけ」で返す(JSONで包まない) return ContentService .createTextOutput(pdfFile.getUrl()) .setMimeType(ContentService.MimeType.TEXT); } catch (err) { return ContentService .createTextOutput(“エラー:” + String(err)) .setMimeType(ContentService.MimeType.TEXT); } } // 項目リストを表に変換して差し込む function insertItemsTable(body, items) { var found = body.findText(“{{items_table}}”); if (!found) return; var element = found.getElement().getParent(); var index = body.getChildIndex(element); var tableData = [[“項目”,”数量”,”単位”,”単価”,”金額”]]; for (var i = 0; i < items.length; i++) { tableData.push([ String(items[i].komoku), String(items[i].kosu), String(items[i].tani), Number(items[i].tanka).toLocaleString()+”円”, Number(items[i].kingaku).toLocaleString()+”円” ]); } tableData.push([“合計”,””,””,””, Number(getTotal(items)).toLocaleString()+”円”]); element.removeFromParent(); body.insertTable(index, tableData); } function getTotal(items) { var total = 0; for (var i = 0; i < items.length; i++) total += Number(items[i].kingaku); return total; } |
ステップ4:DifyからPDF生成GASを呼ぶ
LLM5の後ろにHTTPリクエストブロック(2個目)を追加します。今回は単価取得と違ってPOSTメソッド。ボディを「raw」にして、中にLLM5の出力をまるごと入れます。
| 項目 | 設定値 |
| メソッド | POST(前回のGETと違う!) |
| URL | PDF生成GASのデプロイURL |
| ボディ | raw を選んで {{LLM5のtext}} を入れる |
✨ 仕上げ:お客様に見せられる画面にする
ここで最後の問題。回答欄に、PDF生成に使った生のJSONがそのまま表示されてしまって、ゴチャゴチャしていました。お客様に見せるにはカッコ悪い。
最初はDifyの「パラメータ抽出」ブロックや「回答」ブロックで整えようとしたんですが、最新のDifyでは回答ブロックが出力ブロックに統一されていて、文章整形ができないことが判明。
🔧 最終解:整形専用のAI(LLM6)を1個足しました。『見積書データとPDFリンクを受け取って、JSONは出さずにキレイにまとめて』と指示するだけ。これが一番シンプルで確実でした。
整形用AI(LLM6)のプロンプト
| # タスク 以下の「見積書データ」と「PDFリンク」を使って、 お客様向けの見積もり結果メッセージを作成してください。 # 見積書データ {{LLM5のtext}} # PDFリンク {{HTTPリクエスト2のbody}} # 出力形式 ※JSONデータは出力に含めないこと。見積書の内容だけを使うこと。 📋 お見積もり結果 【現場状況】(サマリーをそのまま) 【お見積もり内訳】(項目・数量・単価・金額の表) 【合計金額】〇〇円(税別) 【注意事項】(注意事項をそのまま) ✅ 見積書PDF(正式版)はこちら ▼ (PDFリンクをそのまま) # 制約 – JSON部分は絶対に出力しないこと – 表は崩さずそのまま見やすく出力すること – PDFリンクのURLはそのまま正確に記載すること |
これで回答欄から生JSONが消えて、現場状況・内訳の表・合計・注意事項・PDFリンクが、見積書らしくキレイに並ぶようになりました。完成です!🎉
🔄 今までの見積もり作成と、何が変わったか
正直に書くと、「採寸ゼロで完結」は言い過ぎでした。測る項目は採寸が必要です。でも、それを差し引いても変化は大きいです。
| 今までの手作業 | このアプリ | |
| 計算 | 電卓で手計算(ミスのもと) | 数量×単価を自動計算 |
| 単価管理 | 頭の中 or 紙の単価表 | スプレッドシートで一元管理 |
| 単価変更 | 単価表を作り直し | シートを1か所直すだけ |
| 属人化 | ベテランしかできない | 誰が撮っても同じ基準 |
| 見積書 | 手書き or Word清書 | 罫線つきPDFが自動生成 |
| 提示 | 後日メールや郵送 | その場で概算を提示 |
⚠️ 大事なこと:AIの見積もりは「たたき台」です。特に面積などの推定は、最終的に現場確認で微調整する前提に。アプリも『エアコンは要現地確認』とちゃんと注意書きを出してくれるので、お客様とのトラブル防止にもなります。
📝 まとめ:失敗の数だけ、アプリは育つ
最初は「写真1枚で見積もり」だけだったアプリが、4業種対応・スプレッドシート単価管理・採寸メモ・PDF自動生成・お客様向け整形まで揃った、本格ツールになりました。
でも、ここまでの道のりは決して一直線じゃなかった。振り返るとこれだけハマっています。
- 半角スペース1個で見積もりが空っぽに
- 実行されないブロックの変数でエラー → 変数集約器で解決
- 写真だけじゃ採寸できない問題 → 採寸メモ欄を追加
- Googleドライブの権限承認でPDF生成が失敗
- カッコ1個ズレて構文エラー
IT未経験の49歳でも、一つずつ潰していけば、ここまで作れます。エラーは敵じゃなくて、アプリを育てる栄養みたいなものだなと、今回あらためて思いました。
🎁 「読んだけど、自分で組むのは大変そう…」という方へ
この記事で作ったアプリの完成版(DSLファイル+GAS2本+単価表+見積書テンプレート+セットアップガイド+トラブル対処集)を、インポートするだけで動くテンプレートとしてnoteで配布しています。今回ハマった地雷5つの対策も、すべて手順に組み込み済みです。
→ [こちらからどうぞ]
同じようにDifyやAIツールを触っている方の、何かのヒントになれば嬉しいです。最後まで読んでいただき、ありがとうございました!
次におすすめの記事
冷蔵庫の余り物が極上の一皿に!Difyで作る「深夜のおつまみAIソムリエ」
xもやってるので良かったら見に来てください
次回も是非お楽しみに!
※以下は広告(PR)を含みます
このブログの動作環境について
当ブログはレンタルサーバー「ConoHa WING」で運営しています。IT未経験の私でもWordPressの自動インストール機能で30分でブログを開設できました。GASやDifyと連携するブログ運営を考えている方には、表示速度が速くて管理画面がシンプルなのでおすすめです。


コメント