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_720thumb_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では、お客様の課題だけじゃなくて自分たちの業務も「もっと良くできないか」と考えながらやってます。興味ある方は採用ページも見てみてください。

https://visk.co.jp/

きっかけは「週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_720thumb_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では、お客様の課題だけじゃなくて自分たちの業務も「もっと良くできないか」と考えながらやってます。興味ある方は採用ページも見てみてください。

https://visk.co.jp/