経費精算のレシート照合をAI-OCRで自動化|分割計上も検知

読了 約10分 たびすけ
AIで領収書とカード明細を突合する記事のアイキャッチ

「要確認(金額不一致)」。経費精算のレシート照合を自動化して、最初に引っかかった申請に付いた判定がこれです。中身を開くと、1枚のレシートが複数の経費明細に分かれて計上されていました。いわゆる分割計上です。証憑として添付されたレシートに書かれているのは合計金額だけ。分割したあとの明細と同じ金額は、どこを探しても出てきません。

合計そのものは合っています。レシートの合計と申請全体を見比べるだけの確認なら、まず素通りする組み合わせです。ただ、この仕組みが突合するのは合計ではなく、明細ごとの金額です。明細を1行ずつレシート内の文字から探しに行くので、「金額が見つからない」という根拠付きで、申請は自動的に要確認へ回ってきました。

で、この照合の仕組みなんですが、レシートを全部AI-OCRに読ませているわけではありません。文字を取り出せるPDFはPythonで照合して、画像やスキャンだけをClaudeに読ませています。分割計上や税率の混在、適格請求書に当たらない証憑が引っかかったら、最後は人が確認する流れです。

レシートを全部AI-OCRへ送らない

テキスト層のある証憑はPythonで金額を照合し、文字を取れない画像だけをClaudeのOCRへ回す。全件に同じ条件を当てながら、AIに読ませる範囲は絞る。承認と差し戻しを決めるのは、最後まで人。

連載「経理をAIに任せる」(全4回)

  1. 経理AIの内部統制をどう設計する?作業・責任・監査証跡の分け方
  2. 新規取引先チェックをAIで自動化|閉鎖・合併済み法人を検知した方法
  3. 経費精算のレシート照合をAI-OCRで自動化|分割計上も検知(この記事)
  4. 会計ソフトのAPI比較|AI経理で確認したい4つの選定基準

前回の新規取引先チェックでは、公式情報との照合が中心でした。全件を確認するのは今回も同じなんですが、経費精算では、文字を扱う機械処理と画像を読むAIを使い分けています。

経費精算の明細とレシートは何を突合するか

仕組みの中身は、聞いてしまえば単純です。申請明細と添付された証憑を1件ずつ対応させて、次の4項目を突合します。

  • 税込金額
  • 利用日または発行日
  • 税区分と税率
  • 適格請求書発行事業者の登録番号

件数が増えてくると、1枚ずつ「それらしい」と眺める確認では、この4つのどこかに不一致が紛れやすくなります。だったら、すべての明細に同じ条件を当てて、引っかかったものだけを人へ返すほうが確実だろうと考えて、この形にしました。

AI-OCRの前に、Pythonでテキスト層を読む

取り出したレシートを、いきなりAIへは渡しません。テキスト層が残っていればPythonで文字を取れるので、AI-OCRに回すのはそのあとです。

処理の入口はバクラクです。申請中の経費精算を取得して、明細ごとに金額、日付、税区分と、添付されたレシートを取り出します。ベンダーAPIに関わる部分なので、コードは擬似コードにしています。

# バクラクAPIで「申請中の経費精算」を取得し、明細ごとに
# 金額・日付・税区分・レシート(添付ファイル)を取り出してDLする
applications = bakuraku.fetch_requests(status="IN_PROGRESS", form="経費精算申請")
receipt_bytes = bakuraku.download(f"/workflow/user_upload_files/{file_id}/file")

PDFにテキスト層が残っていれば、PyMuPDFで文字をそのまま取れます。文字を取得できない画像やスキャンは空文字を返しておいて、後段のAI-OCRへ回します。

import fitz  # PyMuPDF

def extract_text(path: str) -> str:
    """PDF/画像からテキストを抽出する。テキスト層が無ければ空文字。"""
    try:
        with fitz.open(path) as doc:
            return "".join(page.get_text() for page in doc)
    except Exception:
        return ""

文字が取れたら、申請明細の税込金額がその中にあるかを探します。ここで効いてくるのが、レシートの数字の癖です。桁の間に空白や改行が入っていることがあるんです。なので、空白とカンマを除いてから突合しています。

import re

def amount_in_text(amount: int, text: str) -> bool:
    """金額がテキストに出現するか(空白・カンマ・改行を吸収)。"""
    if not amount:
        return None
    flat = re.sub(r"[\s,,]", "", text)      # 空白とカンマを全除去
    return str(abs(int(amount))) in flat

この突合で分かるのは、あくまで「金額の文字列があるか」までです。証憑全体が妥当だと保証してくれるわけではありません。それでも、取得できる文字を毎回AIに解釈させるより、同じ条件で速く再実行できます。

ClaudeのOCRに回すのは、文字を取れない画像だけ

肝心の分岐は、if文がひとつあるだけです。テキスト層があればPythonで金額を探して、なければ「要OCR」としてClaudeへ渡します。

if has_text_layer:
    verdict = "OK" if amount_in_text(amount, text) else "要確認(金額不一致)"
else:
    verdict = "要OCR(Claude)"     # ← 画像はここでAIに回す

AIの仕事として残したのは、画像から文字を読むことと、証憑の種類や税率の混在を文脈から整理することです。逆に、テキスト層のある証憑まで同じ流れに乗せると、判定がぶれやすくなるうえに、AIの利用量も増えます。読ませる範囲を絞ったのは、そのためです。

全件突合で引っかかった3つの要確認パターン

この仕組みを実際の申請に当てたところ、目視では見落としやすいパターンが3つ見つかりました。申請者や金額などの内容は伏せています。

合計は合っているのに、明細の金額がない分割計上

1つめが、冒頭のレシートです。1枚の合計を複数の明細に分けてあるので、合計額は正しいのに、明細単位で突合すると金額不一致になります。ただ、この不一致がそのまま不正や誤りを意味するわけではありません。機械にできるのは不一致を指摘するところまでで、按分の理由や申請方法が妥当かどうかは、人が確認します。

申請は10%、レシートには8%が混ざる税率

2つめは税率の混在です。申請明細は課税10%なのに、レシートには軽減8%や対象外が含まれているパターンです。レシートに何が書かれているかを文脈から読み取って整理する部分は、AIに任せられます。整理した税区分の候補は、要確認の根拠として申請に添えて返します。

登録番号のない予約確認書やフリマの画面

3つめは、適格請求書に当たらない証憑です。予約確認書やフリマの取引画面など、登録番号のない証憑が添付されていると、番号の有無で引っかかります。ただ、そういう証憑しかない申請をどう扱うかは、会社の規程の話です。なので仕組みの側は、読み取った証憑の種類と、登録番号がないという事実を添えて申請を返すところまでにしています。

Python、AI、人の担当と止める条件

担当の線引きでは、「誰が何をやるか」だけでなく、「どこで止めるか」もあわせて決めてあります。

担当処理する範囲止める条件
Pythonテキスト抽出、金額突合、形式検査文字なし、金額不一致、未定義形式
AI画像OCR、証憑種別、税率や記載内容の整理読取不明、判断材料不足、複数解釈
按分理由、税区分、承認・差し戻し会社の規程と責任に基づいて決定

機械とAIに任せたのは、すべての明細を同じ条件で確認して、要確認の根拠を添えて人へ返すところまでです。その先の承認と差し戻しは、人が会社の規程と責任に基づいて決めます。さっきの3パターンで最後の判断がどれも人に残っているのは、この線引きのとおりに動いた結果です。

同じ例外を2度書かないためのコード側の決めごと

経費精算と支払申請に共通する取得、文字抽出、正規化は、共通ヘルパーへ切り出して使い回しています。それから、同じ例外が繰り返されたときは、AI向けの注意書きを足して済ませず、判定コードを直してテストも追加するようにしています。

これから同じ照合を組むなら、最初からAI-OCRを全件に当てる必要はありません。まずはテキスト層のあるPDFだけを対象に、金額の突合をif文ひとつで動かしてみてください。それだけでも、明細単位の不一致は拾えます。文字を取れない証憑が実際にどれくらい混ざるかを見てから、AIに読ませる範囲を決めても遅くありません。

この実践で見えてきた会計ソフト選びの条件は、連載最終回のAPI比較で整理しています。

経理会計ソフトのAPI比較|AI経理で確認したい4つの選定基準会計ソフトをAPIで比較するときに見たいのは、利用条件、読み書きの範囲、制限と料金、検証と統制の4点です…