0
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?

青色申告のGAS自作アプリに、Gemini APIで「AI仕訳自動作成」「AI税理士に相談」を追加してみた

0
Posted at

はじめに

以前、Slack × Google Apps Scriptで「スマホだけで請求書を作成・送付できる仕組み」を作ってみた という記事を書きました。

上記記事では、

  • Queueへの登録
  • 時間主導トリガーでの非同期処理
  • LockServiceによる二重実行防止

という構成にしたことで、請求書業務をSlackから手軽に回せるようになりました。

今回はこの構成をベースに、もう一つ抱えていた悩みに手をつけました。

私は青色申告のために、請求書作成・仕訳帳登録・帳票出力(損益計算書/貸借対照表)をGASの自作Webアプリで行っています。

ただ、

  • 領収書を1件1件手で仕訳に入力するのが地味に手間
  • 「この経費、勘定科目何にすればいいんだっけ」と毎回止まる
  • 数字は出せても、それが税務的にどういう意味を持つのかは結局自分で調べ直す

という状態が続いていました。

「領収書を投げたら仕訳まで終わってて、数字の相談もその場でできたら、経理がだいぶ楽になるのでは」

と思ったのが今回の開発のきっかけです。

前回のQueue構成を土台にしつつ、Gemini APIを組み込んで「AI仕訳自動作成」と「AI税理士に相談」の2機能を追加しました。

完成イメージ

image.png

領収書をアップロードすると、

領収書アップロード
↓
Gemini APIが画像を解析
↓
仕訳(日付・借方/貸方科目・金額・取引先)を自動生成
↓
仕訳帳へ自動登録

という流れで仕訳が完成します。

image.png
image.png

システム構成

前回の記事で作ったQueue構成を、そのままこのアプリのベースにしています。

利用者
↓
GAS Webアプリ(doGet)
↓
ジョブキュー(スプレッドシート)
↓
時間主導トリガー(dispatchJobs)
↓
各業務のhandler(Invoice / Journal / AiJournal / ...)
↓
仕訳帳・マスタ等のスプレッドシート

AI機能(AI仕訳自動作成・AI税理士相談)だけ、handlerの先にGemini APIへの呼び出しがぶら下がる形になっています。

業務の追加は JOB_REGISTRY というオブジェクトにエントリを1つ足すだけで済むようにしています。

const JOB_REGISTRY = {
  INVOICE: { title: '📄 請求書作成', formAction: 'invoice', handler: ... },
  JOURNAL: { title: '📒 仕訳帳登録', children: [手動, AI], handler: ... },
  REPORT:  { title: '📊 帳票出力', formAction: 'report' },
  AI_JOURNAL: { title: 'AI仕訳自動作成', handler: ... },
  AI_CONSULT: { title: '🤖 AI税理士に相談', formAction: 'ai-consult' }
};

前回の記事でも書きましたが、業務ロジックを1箇所に寄せておくと、機能を増やしても土台側はほとんど触らずに済みます。

AI仕訳自動作成:できること

領収書・レシートの画像を1〜複数枚アップロードすると、Gemini APIが画像を読み取り、複式簿記の仕訳を1件組み立てて仕訳帳に自動登録します。

複数枚の領収書を「まとめて1仕訳にする」か「1枚ずつ別の仕訳にする」かも、アップロード時に選べるようにしています。

領収書アップロード(1〜複数枚)
↓
ジョブキューへ登録(PENDING)
↓
時間主導トリガーが検知
↓
Gemini APIへ画像+勘定科目候補を送信
↓
仕訳案(JSON)を受け取る
↓
候補と一致すれば仕訳帳へ登録 / しなければFAILEDにしてエラーカード表示

💡 複式簿記の仕訳について
1つのお金の動きを「借方」「貸方」という2つの側面で記録するのが複式簿記のルールです。例えば現金で1,000円の文房具を買った場合、借方は「消耗品費 1,000円」、貸方は「現金 1,000円」という形で1行に記録します。今回はこの両方の科目をAIに選ばせています。

勘定科目は候補から一字一句そのまま選ばせる

AIの自由記述に任せると、勘定科目マスタに存在しない科目名を勝手に作ってしまうことがあります。

そこで、勘定科目マスタの候補リストをプロンプトに埋め込み、「この中から一字一句そのまま1つ選ぶ」というルールにしました。

// gemini.gs 抜粋: レスポンススキーマで型を縛る
const schema = {
  type: 'OBJECT',
  properties: {
    readable: { type: 'BOOLEAN' },
    debitCategory: { type: 'STRING', description: '候補の中から一字一句そのまま選ぶ' },
    creditCategory: { type: 'STRING' },
    amount: { type: 'NUMBER' },
    company: { type: 'STRING' }
  },
  required: ['readable']
};

受け取った後もコード側で候補リストと照合し、候補外の値が返ってきたらエラーにしてジョブをFAILEDにしています。

AIの出力をそのまま信用せず、コード側でも必ず検証する、という二段構えです。

取引先(会社名)も同じ考え方ですが、こちらは「候補になければ空文字でよい」という少し緩いルールにしています。

会社名は必須項目ではないため、無理に一致させる必要がないという判断です。

読み取れなければ、何もしないを選ぶ

領収書の画像が不鮮明だったり、日付や金額が読み取れないケースもあります。

このとき、プロンプトには「無理に推測して登録しないこと」と明記しています。

readable: false が返ってきた場合は仕訳登録を行わず、エラーカードでユーザーに手動入力を促す形にしました。

会計データは、それっぽい推測値がいちばん怖いというのが、この機能を作りながら実感したポイントです。

image.png

エラーカードで「却下」を選ぶと、ジョブキューの行だけでなく、紐づく領収書ファイルもDriveから削除するようにしています。

ジョブキューの落とし穴(前回の設計だけでは足りなかった)

前回の記事のQueue構成は、1件のジョブがそこまで時間のかからない処理を想定していました。

しかし今回は、領収書を複数枚まとめてアップロードすると、ファイルごとに1件ずつ「1秒後に実行」のワンタイムトリガーが仕掛けられます。

ほぼ同時に複数のトリガーが起動すると、

  • 1件がGemini API呼び出しという時間のかかる処理でロックを長く握ってしまう
  • 他のトリガーがロック取得に失敗する
  • そのジョブが永久にPENDINGのまま取りこぼされる

という不具合が実際に起きました。

最初はロックの取得時間(tryLockの待機ミリ秒)を伸ばすことも検討しました。

しかし、それでは「時間のかかるジョブが増えるほど、他のジョブがより長く待たされる」という問題自体は解決しません。

そこで、ロックを握る範囲そのものを見直しました。

【修正前】
ロック取得
↓
PENDING→PROCESSINGに更新
↓
Gemini API呼び出し(数秒〜数十秒)
↓
結果を保存
↓
ロック解放

【修正後】
ロック取得
↓
PENDING→PROCESSINGに更新
↓
ロック解放
↓
Gemini API呼び出し(数秒〜数十秒)
↓
結果を保存(ここだけ再度短くロック)
// common.gs 抜粋: ロックは"claimの瞬間だけ"
function claimNextPendingJob_() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) { scheduleJobDispatcher(); return null; }
  try {
    // PENDING行を1件探してPROCESSINGに更新するところだけロック
  } finally {
    lock.releaseLock();
  }
}

ロックは「PENDING→PROCESSINGへの更新」の瞬間だけ短く握り、Gemini API呼び出しのような時間のかかる処理はロックの外に出しました。

これにより、複数の領収書を同時にアップロードしても、ジョブが取りこぼされることはなくなりました。

AI税理士に相談:できること

チャット風の1問1答フォームで、経理・税務についての質問を入力できます。

会話履歴は保持しない、単発質問の形にしました。

質問を送信
↓
今年の損益計算書・貸借対照表の集計値を取得
↓
質問+集計値をGemini APIへ送信
↓
回答を画面に表示

image.png

既存の帳票ロジックを、AIへの要約データ生成に再利用する

損益計算書・貸借対照表を作るReportモジュールの計算ロジック(勘定科目マップ・期首残高・仕訳集計など)は、すでに帳票出力機能の中で動いていました。

AI税理士相談のために新しい集計処理を書き起こすのではなく、この既存ロジックをそのまま使い回して「会計データ要約」を作っています。

// ai-consult.gs 抜粋
buildFinancialSummary: function() {
  const ctx = { accounts: Report.getAccountMap(), ... };
  const pl = Report.calculatePL(ctx);
  // ...
}

新しく集計処理を書かないことで、「AIに見せている数字」と「実際の帳票の数字」がズレる心配がなくなります。

プロンプト設計:AIに言い切らせない

税務は、断定してしまうと危ない領域です。

そこで、プロンプトには「断定できない税務判断(控除の可否など)は一般的な考え方を説明した上で、最終判断は税理士・税務署に確認するよう案内する」ことを明記しました。

AIに"わかったふり"をさせない、というのは、税務のような専門領域でAIを使う上でかなり重要だと感じています。

Gemini呼び出しの共通化(AiEngineモジュール)

AI仕訳自動作成とAI税理士相談は、どちらもGemini API呼び出しの共通レイヤー AiEngine を経由しています。

ポイントは3つです。

1. 構造化出力(responseSchema)を使い分ける

仕訳抽出はJSONスキーマを指定し、税理士相談はプレーンテキストで受け取っています。

2. 429/503に対する指数バックオフ付きリトライ

💡 指数バックオフとジッター
失敗するたびに待つ時間を倍々に増やしていくリトライ方式です。混雑しているAPIに間隔を空けずに再送信すると余計に混雑させてしまうため、待ち時間を伸ばして相手の負荷が下がるのを待ちます。ジッター(ランダムな揺らぎ)を加えるのは、複数の処理が同時に再送信して再び衝突するのを防ぐためです。

3. 使えるGeminiモデルをコードに書かない

モデルID・表示名・既定フラグをスプレッドシート(マスタ)で管理し、AiEngine.getModelOptions() / getDefaultModel() が読みに行く形にしています。

image.png

// config.gs 抜粋
// 選択可能なGeminiモデルの一覧は、コード(定数)ではなく
// スプレッドシート(AIモデル一覧マスタ)で管理する。
// モデルの追加・変更・既定モデルの切り替えは、このシートを編集するだけで即座に反映され、
// Webアプリの再デプロイは不要。

モデルの追加・切り替えのたびにコードを直して再デプロイする必要がなくなり、運用がだいぶ楽になりました。

今後やりたいこと

現在はAI仕訳自動作成・AI税理士相談の2機能ですが、同じ構成で

  • 消費税区分への対応
  • freee/マネーフォワード等の外部サービスとの連携
  • 経費の異常検知(いつもと違う金額・カテゴリのアラート)

なども実装していきたいと考えています。

おわりに

前回のSlack×GAS記事では「Queue+時間主導トリガー+LockService」という土台を作りました。

今回はその土台の上に外部のAI API(Gemini)を乗せることで、領収書を投げるだけで仕訳が終わり、数字の相談もその場でできる、というところまで持っていけました。

GASというと「ちょっとした自動化」というイメージを持たれがちですが、外部APIと組み合わせることで、個人開発でも実務に耐える経理ツールを作れます。

同じように「日々の経理作業をもう少し楽にしたい」と考えている方の参考になれば幸いです。

0
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
0
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?