Slackにレシート投稿 → AI読み取り → 自動記録。経費精算Botを作った話
Slackにレシート投稿 → AI読み取り → 自動記録。経費精算Botを作った話


きっかけは「週1ランチ補助」の申請が面倒だったこと
うちの会社(VISK)には、CBLという福利厚生があります。Communication Boost Lunchの略で、週1回、社員同士でランチ行ったら1,200円まで出すよという制度。
申請がちょっと手間だったので自動化しようと思ったんですが、管理担当者にヒアリングしたら、CBLだけじゃなくて交通費・備品購入など経費精算全般を同じシートで管理してることがわかりました。月4時間くらいかかってると。
CBLだけ自動化しても同じ作業が残るので、経費精算全体を対象にすることにしました。
システム構成
最終的な構成はこうなりました。
┌─────────────────────────────────────────────────────────────────────┐
│ Slack │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ボタンクリック │ → │ 勘定科目選択 │ → │ スレッドに │ │
│ │ (経費申請開始) │ │ (Block Kit) │ │ レシート投稿 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ n8n (23ノード) │
│ │
│ [Webhook] ボタン選択受信 │
│ │ │
│ ├─→ [Code] ペイロード解析 → 勘定科目・ユーザー情報抽出 │
│ │ │
│ ├─→ [HTTP] response_url でボタンメッセージ更新 │
│ │ │
│ └─→ [Slack] スレッド作成(勘定科目情報を含む) │
│ │
│ [Slack Trigger] スレッド画像監視 │
│ │ │
│ ├─→ [IF] files存在 AND thread_ts存在 を判定 │
│ │ │
│ ├─→ [HTTP] conversations.replies で親メッセージ取得 │
│ │ │
│ ├─→ [Code] 親メッセージから勘定科目を抽出 │
│ │ │
│ ├─→ [HTTP] url_private_download で画像バイナリ取得 │
│ │ │
│ ├─→ [Google Drive] 画像をバックアップ保存 │
│ │ │
│ ├─→ [HTTP] dify /v1/files/upload でファイルアップロード │
│ │ │
│ ├─→ [HTTP] dify /v1/workflows/run でOCR実行 │
│ │ │
│ ├─→ [Code] OCR結果を整形(null対策込み) │
│ │ │
│ ├─→ [Google Sheets] 経費精算書に追記 │
│ │ │
│ └─→ [Slack] 完了通知 + 次回用ボタン送信 │
│ │
│ [Error Flow] 6ノードで onError: continueErrorOutput 設定 │
│ └─→ [Slack] エラー通知 → [HTTP] リトライ用ボタン送信 │
└─────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ dify + Gemini │ │ Google Sheets │
│ 2.0 Flash │ │ │
│ │ │ [集計シート] │
│ ・金額抽出 │ │ └─ 生データ蓄積 │
│ ・日付抽出 │ │ │
│ ・店名抽出 │ │ [経費精算書]
ノード数は23個。トリガーが2つ(Webhook + Slack Trigger)、エラーハンドリング用が6つ。外部連携はSlack、Google Drive、Google Sheets、difyの4サービスです。
技術的なポイント
1. Slack Block Kitで勘定科目選択UI
最初は「AIに勘定科目も判断させればいいのでは」と思ったんですが、やめました。交通費なのかCBLなのか、レシートだけだと判断しづらいケースがあるので、ユーザーが明示的に選ぶ方が確実です。
Block KitのJSONはこんな構造です。
{ "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "勘定科目を選択:" }, "accessory": { "type": "static_select", "action_id": "category_select", "options": [ { "text": { "type": "plain_text", "text": "CBL(福利厚生費)" }, "value": "cbl" }, { "text": { "type": "plain_text", "text": "交通費" }, "value": "transportation" } ] } } ] }
ボタンがクリックされると、n8nのWebhookにPOSTが飛んできます。response_urlを使って元のメッセージを「選択済み」表示に更新しつつ、選ばれた勘定科目を含むスレッドを作成します。
2. スレッドから勘定科目を復元する仕組み
ここが少し工夫したところです。
ユーザーがスレッドに画像を投稿すると、Slack Triggerが発火します。でもこの時点では「どの勘定科目が選ばれたか」の情報がイベントに含まれていません。
そこで、conversations.replies APIを叩いて親メッセージを取得し、そこから勘定科目を抽出しています。
// n8nでの流れ [Slack Trigger] → [Set] thread_ts保持 → [HTTP] conversations.replies → [Code]
n8nでは $() 記法で他ノードのデータを参照できます。
// Codeノードでの実装例 const contextData = $('カテゴリ抽出').item.json; const category = contextData.category; const driveUrl = $('Google Drive保存').item.json.webViewLink;
3. dify APIは2段階
difyでOCRワークフローを実行するには、まずファイルをアップロードして、返ってきたIDを使ってワークフローを実行する、という2段階の処理が必要です。
Step 1: ファイルアップロード
Step 2: ワークフロー実行
Gemini 2.0 Flashで金額・日付・店名を抽出し、構造化されたJSONで返ってきます。
4. 大きい画像で502エラー → Slackサムネイルで解決
iPhoneのHEIC画像(6MB超)を処理しようとすると、Gemini APIがタイムアウトして502が返ってきました。
difyのログを見ると、こんなエラー。
Slackは画像アップロード時に複数サイズのサムネイルを自動生成しています。url_private_download の代わりに thumb_720 や thumb_1024 を使うことで、ファイルサイズを大幅に削減できました。
// オリジナル(6MB超でタイムアウト) const imageUrl = $json.event.files[0].url_private_download; // サムネイル使用(数百KB、OCR精度も問題なし) const imageUrl = $json.event.files[0].thumb_720;
5. OCR失敗時のフォールバック
レシートによっては読み取れないケースがあります。difyが空のレスポンスを返すと、後続のGoogle Sheets書き込みでJSONパースエラーが連鎖します。
OCR結果整形のCodeノードでデフォルト値を設定しました。
// OCR結果整形ノード const result = $input.first().json; return { amount: result?.amount ?? 0, date: result?.date ?? '', storeName: result?.store_name ?? '不明' };
Google Sheets書き込み時にもフォールバック演算子を使っています。
// n8n式 {{ $json.amount ?? 0 }} {{ $json.storeName ?? "不明" }}
6. エラーハンドリング:continueErrorOutput
n8nでは、ノード設定で onError: continueErrorOutput を指定すると、エラー発生時に専用の出力ポート(main[1])からデータが流れます。
// connections構造 "Google Drive保存": { "main": [ [{ "node": "バイナリ転送", ... }], // main[0]: 成功時 [{ "node": "エラー通知", ... }] // main[1]: エラー時 ] }
Google Drive保存、difyアップロード、OCR処理、Sheets書き込みなど、外部連携する6ノードすべてにこの設定を入れています。エラーが起きてもワークフロー全体は止まらず、ユーザーにエラー通知が飛ぶ仕組みです。
AIを使わなかった部分
ここまでAI(Gemini)を使ってきましたが、実は 手動で実装した部分 もあります。
個人別に経費を表示するビュー →
FILTER関数CBL上限1,200円の計算 →
MIN関数SlackユーザーID → 実名の変換 → 対応表 +
VLOOKUP
「AIにスプレッドシート整形させればいけるかな」と思って試したんですが、プロンプト通りにいかない。シートの構造を正確に伝えるのが難しくて、直接関数書いた方が早いな、となりました。
AIは手段の一つ。目的は業務効率化であって、AI活用ではないので、使えるところは使う、使えないところは普通に書く、という判断です。
結果
Before:
申請者:Slack投稿 + チェック表 + 領収書提出(10分 × 14人 = 140分)
管理者:転記 + 確認(90〜120分)
月間合計:約4時間
After:
申請者:ボタン押してレシート画像投げるだけ(1分)
管理者:自動記録された内容を確認(数分)
月間合計:約14分
94%削減。領収書原本の提出は残ってますが(税理士対応で必要)、それ以外はほぼ自動化できました。
おわりに
技術的には、n8nの $() 参照やエラーハンドリング、difyの2段階API、Slackサムネイルの活用あたりがポイントでした。
それ以上に大事だったのは、最初のヒアリング。CBLだけのつもりが、話を聞いたら経費精算全体の話だった。スコープ間違えてたら効果半減でした。
VISKでは、お客様の課題だけじゃなくて自分たちの業務も「もっと良くできないか」と考えながらやってます。興味ある方は採用ページも見てみてください。
きっかけは「週1ランチ補助」の申請が面倒だったこと
うちの会社(VISK)には、CBLという福利厚生があります。Communication Boost Lunchの略で、週1回、社員同士でランチ行ったら1,200円まで出すよという制度。
申請がちょっと手間だったので自動化しようと思ったんですが、管理担当者にヒアリングしたら、CBLだけじゃなくて交通費・備品購入など経費精算全般を同じシートで管理してることがわかりました。月4時間くらいかかってると。
CBLだけ自動化しても同じ作業が残るので、経費精算全体を対象にすることにしました。
システム構成
最終的な構成はこうなりました。
┌─────────────────────────────────────────────────────────────────────┐
│ Slack │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ ボタンクリック │ → │ 勘定科目選択 │ → │ スレッドに │ │
│ │ (経費申請開始) │ │ (Block Kit) │ │ レシート投稿 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ n8n (23ノード) │
│ │
│ [Webhook] ボタン選択受信 │
│ │ │
│ ├─→ [Code] ペイロード解析 → 勘定科目・ユーザー情報抽出 │
│ │ │
│ ├─→ [HTTP] response_url でボタンメッセージ更新 │
│ │ │
│ └─→ [Slack] スレッド作成(勘定科目情報を含む) │
│ │
│ [Slack Trigger] スレッド画像監視 │
│ │ │
│ ├─→ [IF] files存在 AND thread_ts存在 を判定 │
│ │ │
│ ├─→ [HTTP] conversations.replies で親メッセージ取得 │
│ │ │
│ ├─→ [Code] 親メッセージから勘定科目を抽出 │
│ │ │
│ ├─→ [HTTP] url_private_download で画像バイナリ取得 │
│ │ │
│ ├─→ [Google Drive] 画像をバックアップ保存 │
│ │ │
│ ├─→ [HTTP] dify /v1/files/upload でファイルアップロード │
│ │ │
│ ├─→ [HTTP] dify /v1/workflows/run でOCR実行 │
│ │ │
│ ├─→ [Code] OCR結果を整形(null対策込み) │
│ │ │
│ ├─→ [Google Sheets] 経費精算書に追記 │
│ │ │
│ └─→ [Slack] 完了通知 + 次回用ボタン送信 │
│ │
│ [Error Flow] 6ノードで onError: continueErrorOutput 設定 │
│ └─→ [Slack] エラー通知 → [HTTP] リトライ用ボタン送信 │
└─────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ dify + Gemini │ │ Google Sheets │
│ 2.0 Flash │ │ │
│ │ │ [集計シート] │
│ ・金額抽出 │ │ └─ 生データ蓄積 │
│ ・日付抽出 │ │ │
│ ・店名抽出 │ │ [経費精算書]
ノード数は23個。トリガーが2つ(Webhook + Slack Trigger)、エラーハンドリング用が6つ。外部連携はSlack、Google Drive、Google Sheets、difyの4サービスです。
技術的なポイント
1. Slack Block Kitで勘定科目選択UI
最初は「AIに勘定科目も判断させればいいのでは」と思ったんですが、やめました。交通費なのかCBLなのか、レシートだけだと判断しづらいケースがあるので、ユーザーが明示的に選ぶ方が確実です。
Block KitのJSONはこんな構造です。
{ "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "勘定科目を選択:" }, "accessory": { "type": "static_select", "action_id": "category_select", "options": [ { "text": { "type": "plain_text", "text": "CBL(福利厚生費)" }, "value": "cbl" }, { "text": { "type": "plain_text", "text": "交通費" }, "value": "transportation" } ] } } ] }
ボタンがクリックされると、n8nのWebhookにPOSTが飛んできます。response_urlを使って元のメッセージを「選択済み」表示に更新しつつ、選ばれた勘定科目を含むスレッドを作成します。
2. スレッドから勘定科目を復元する仕組み
ここが少し工夫したところです。
ユーザーがスレッドに画像を投稿すると、Slack Triggerが発火します。でもこの時点では「どの勘定科目が選ばれたか」の情報がイベントに含まれていません。
そこで、conversations.replies APIを叩いて親メッセージを取得し、そこから勘定科目を抽出しています。
// n8nでの流れ [Slack Trigger] → [Set] thread_ts保持 → [HTTP] conversations.replies → [Code]
n8nでは $() 記法で他ノードのデータを参照できます。
// Codeノードでの実装例 const contextData = $('カテゴリ抽出').item.json; const category = contextData.category; const driveUrl = $('Google Drive保存').item.json.webViewLink;
3. dify APIは2段階
difyでOCRワークフローを実行するには、まずファイルをアップロードして、返ってきたIDを使ってワークフローを実行する、という2段階の処理が必要です。
Step 1: ファイルアップロード
Step 2: ワークフロー実行
Gemini 2.0 Flashで金額・日付・店名を抽出し、構造化されたJSONで返ってきます。
4. 大きい画像で502エラー → Slackサムネイルで解決
iPhoneのHEIC画像(6MB超)を処理しようとすると、Gemini APIがタイムアウトして502が返ってきました。
difyのログを見ると、こんなエラー。
Slackは画像アップロード時に複数サイズのサムネイルを自動生成しています。url_private_download の代わりに thumb_720 や thumb_1024 を使うことで、ファイルサイズを大幅に削減できました。
// オリジナル(6MB超でタイムアウト) const imageUrl = $json.event.files[0].url_private_download; // サムネイル使用(数百KB、OCR精度も問題なし) const imageUrl = $json.event.files[0].thumb_720;
5. OCR失敗時のフォールバック
レシートによっては読み取れないケースがあります。difyが空のレスポンスを返すと、後続のGoogle Sheets書き込みでJSONパースエラーが連鎖します。
OCR結果整形のCodeノードでデフォルト値を設定しました。
// OCR結果整形ノード const result = $input.first().json; return { amount: result?.amount ?? 0, date: result?.date ?? '', storeName: result?.store_name ?? '不明' };
Google Sheets書き込み時にもフォールバック演算子を使っています。
// n8n式 {{ $json.amount ?? 0 }} {{ $json.storeName ?? "不明" }}
6. エラーハンドリング:continueErrorOutput
n8nでは、ノード設定で onError: continueErrorOutput を指定すると、エラー発生時に専用の出力ポート(main[1])からデータが流れます。
// connections構造 "Google Drive保存": { "main": [ [{ "node": "バイナリ転送", ... }], // main[0]: 成功時 [{ "node": "エラー通知", ... }] // main[1]: エラー時 ] }
Google Drive保存、difyアップロード、OCR処理、Sheets書き込みなど、外部連携する6ノードすべてにこの設定を入れています。エラーが起きてもワークフロー全体は止まらず、ユーザーにエラー通知が飛ぶ仕組みです。
AIを使わなかった部分
ここまでAI(Gemini)を使ってきましたが、実は 手動で実装した部分 もあります。
個人別に経費を表示するビュー →
FILTER関数CBL上限1,200円の計算 →
MIN関数SlackユーザーID → 実名の変換 → 対応表 +
VLOOKUP
「AIにスプレッドシート整形させればいけるかな」と思って試したんですが、プロンプト通りにいかない。シートの構造を正確に伝えるのが難しくて、直接関数書いた方が早いな、となりました。
AIは手段の一つ。目的は業務効率化であって、AI活用ではないので、使えるところは使う、使えないところは普通に書く、という判断です。
結果
Before:
申請者:Slack投稿 + チェック表 + 領収書提出(10分 × 14人 = 140分)
管理者:転記 + 確認(90〜120分)
月間合計:約4時間
After:
申請者:ボタン押してレシート画像投げるだけ(1分)
管理者:自動記録された内容を確認(数分)
月間合計:約14分
94%削減。領収書原本の提出は残ってますが(税理士対応で必要)、それ以外はほぼ自動化できました。
おわりに
技術的には、n8nの $() 参照やエラーハンドリング、difyの2段階API、Slackサムネイルの活用あたりがポイントでした。
それ以上に大事だったのは、最初のヒアリング。CBLだけのつもりが、話を聞いたら経費精算全体の話だった。スコープ間違えてたら効果半減でした。
VISKでは、お客様の課題だけじゃなくて自分たちの業務も「もっと良くできないか」と考えながらやってます。興味ある方は採用ページも見てみてください。
