skip to content
barorin&

Jevで仕訳の起票候補を作る|証憑から科目・税区分・金額を決める

/ 22 min read

Table of Contents

はじめに

経費精算の起票は、1件ずつ見れば難しくありません。領収書を見て、科目を決めて、税区分を決めて、金額を転記する。それだけです。

問題は件数です。月末に数百件が積み上がり、同じ判断を繰り返すことになります。そして**転記ミスが一番痛い。**科目の付け間違いは後から振り替えられますが、桁を間違えた金額は、誰かが気づくまで残ります。

ここで生成AIに「この領収書から仕訳を作って」と頼むと、別の問題が起きます。金額を書き換えてしまうことがあるからです。132,000円が13,200円になっていても、出力されたJSONはそれらしい形をしています。検算を通しても貸借は一致します。

Jevはこの用途に向いています。理由は逆説的で、Jevは文章を生成できないからです。

この記事では、証憑テキストから仕訳の起票候補を組み立てます。Jev入門で確認した3つの質問型と、会計・監査での使い方で決めた線引きをそのまま使います。

Jevは仕訳を「生成」しない

先に前提を確認します。Jevが返せるのは Choice / Score / Noul の3つだけです。公式ドキュメントにも「文章生成用に訓練されていない」と明記されています。

つまり、こういう出力は構造的に返ってきません。

{ "借方科目": "交際費", "金額": 132000 }

ところが、仕訳を分解してみるとほとんどが閉じた選択肢です。

項目 担当 方法
借方・貸方の勘定科目 Jev Choice(科目マスタが選択肢)
補助科目・部門・取引先 Jev Choice
税区分 Jev Choice
金額 正規表現 + Jev 候補を拾い、どれが総額かを選ばせる
日付 正規表現 + Jev 同上
消費税額・本体価額 コード Jevは計算しない
貸借一致の検算 コード 同上
摘要文 定型か別モデル Jevは生成しない

仕訳を組み立てるのはコードで、Jevは各欄の値を選ぶだけという役割分担になります。

金額はモデルに書かせない

ここがこの記事の中心です。公式クックブックに「pre-parsed value extraction」というパターンがあります。手順は3段階です。

  1. 正規表現で金額らしき文字列を多めに拾う
  2. Jevの Choice に「どれが支払総額か」を聞く
  3. コードが選ばれた文字列をそのままコピーして数値化する

ドキュメントの表現を借りると、「返ってくる値は正規表現が見つけたスパンのひとつが、変更されずにコピーされたもの」です。Jevは候補の中からしか選べないので、存在しない金額が出てくることがありません。

領収書のテキストを用意します。

RECEIPT = """領収書
2026年3月31日
株式会社大和田商事 御中
ご飲食代として
小計 120,000円
消費税(10%) 12,000円
合計 132,000円
上記正に領収いたしました
銀座あけぼの店 TEL 03-1234-5678
登録番号 T1234567890123
"""

候補を拾います。

import re
AMOUNT_RE = re.compile(r"[¥¥]?\d[\d,]*\s*円|[¥¥]\s*\d[\d,]*")
def find_amounts(text: str) -> list[str]:
"""金額らしき文字列を多めに拾う。絞り込みはしない。"""
return list(dict.fromkeys(m.group().strip() for m in AMOUNT_RE.finditer(text)))
def to_int(amount: str) -> int:
return int(re.sub(r"[^\d]", "", amount))
>>> find_amounts(RECEIPT)
['120,000円', '12,000円', '132,000円']

電話番号の 03-1234-5678、消費税(10%) の 10、登録番号の数字はすべて除外されています。円 か ¥ を必須にしているだけですが、これで大半のノイズは落ちます。

勘定科目は二段階で選ばせる

Choice の選択肢は255件までです。勘定科目マスタは、補助科目まで含めると普通に超えます。

しかもSDKは件数をチェックしません。300件渡しても、クライアント側ではそのままリクエストが組み立てられます。自分でガードを入れておきます。

MAX_CHOICE_OPTIONS = 255
def ensure_choice_size(criteria: dict) -> None:
if len(criteria) > MAX_CHOICE_OPTIONS:
raise ValueError(f"選択肢が{len(criteria)}件。Choiceは{MAX_CHOICE_OPTIONS}件まで。段階を分ける")

超える場合は、公式も推奨している二段階選択にします。大分類を選ばせてから、その中の科目を選ばせます。

ACCOUNT_GROUPS = {
"entertainment_group": "取引先との飲食・贈答・供応に関する費用",
"travel_group": "出張・移動にかかる交通費と宿泊費",
"supplies_group": "文具・備品などの消耗品の購入",
"other_group": "上記のいずれにも当てはまらない",
}
ACCOUNTS_BY_GROUP = {
"entertainment_group": {
"交際費": "取引先の接待・供応・贈答",
"会議費": "社内または取引先との打合せにかかる飲食",
"福利厚生費": "役員・従業員のみを対象とする慰安・懇親",
},
"travel_group": {
"旅費交通費": "出張・移動の実費",
"通勤費": "従業員の通勤にかかる定期代",
},
"supplies_group": {
"消耗品費": "文具・少額の備品",
"事務用品費": "事務用の消耗品",
},
"other_group": {"雑費": "他の科目に当てはまらない少額の費用"},
}

大分類の説明文は、科目の区別がつく書き方にします。「交際費」と「会議費」と「福利厚生費」は、実務では誰が参加したかで分かれます。その基準を criteria の説明に書いておかないと、Jevは摘要の字面だけで選びます。

1段階目:金額・大分類・税区分をまとめて聞く

from typesafe_sdk import Choice, TypeSafeClient
TAX_CLASSES = {
"課税仕入10": "標準税率10%の課税仕入れ",
"課税仕入8": "軽減税率8%の課税仕入れ(飲食料品・新聞)",
"非課税仕入": "非課税とされる仕入れ",
"対象外": "消費税の課税対象外",
}
def first_pass(client: TypeSafeClient, text: str, amounts: list[str]):
return client.system_one(
state={"証憑": text, "金額候補": amounts},
questions={
"total": Choice(
instructions="候補のうち、当社が支払う税込の総額はどれか",
criteria={a: None for a in amounts},
),
"group": Choice(
instructions="この支出が属する勘定科目の大分類はどれか",
criteria=ACCOUNT_GROUPS,
),
"tax_class": Choice(
instructions="この仕入れに適用される消費税の税区分はどれか",
criteria=TAX_CLASSES,
),
},
)

criteria={a: None for a in amounts} としているのは、金額候補には説明が要らないからです。Choice の criteria は値に None を許容します。選択肢の名前そのものが情報なので、余計な説明を足さないほうが素直に選ばれます。

state に証憑の全文と候補リストの両方を入れているのもポイントです。候補だけ渡すと「合計」の行がどれか分かりません。文脈と候補をセットで渡します。

2段階目:大分類の中から科目を選ぶ

def second_pass(client: TypeSafeClient, text: str, group: str):
return client.system_one(
state={"証憑": text},
questions={
"account": Choice(
instructions="この支出の勘定科目はどれか",
criteria=ACCOUNTS_BY_GROUP[group],
)
},
)

税額はコードで計算する

Jevは計算しません。税込総額と税区分が決まったら、税額と本体価額はこちらで出します。

from decimal import ROUND_DOWN, Decimal
TAX_RATES = {
"課税仕入10": Decimal("0.10"),
"課税仕入8": Decimal("0.08"),
"非課税仕入": Decimal("0"),
"対象外": Decimal("0"),
}
rate = TAX_RATES[tax_class]
tax = int((Decimal(total) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN))
base = total - tax

float ではなく Decimal を使います。132000 * 0.1 / 1.1 を float でやると誤差が出ます。端数処理も、切捨てか四捨五入かを明示的に指定します。会社の処理方法に合わせてください。

組み立てる

ここまでをまとめます。

def build_entry(client: TypeSafeClient, text: str) -> dict:
amounts = find_amounts(text)
if not amounts:
return {"status": "金額候補なし", "confidence": 0.0}
r1 = first_pass(client, text, amounts)
group = r1.answers["group"].choice
r2 = second_pass(client, text, group)
total = to_int(r1.answers["total"].choice)
tax_class = r1.answers["tax_class"].choice
rate = TAX_RATES[tax_class]
# 税額はコードで計算する。Jevには計算させない
tax = int((Decimal(total) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN))
base = total - tax
confidences = {
"total": r1.answers["total"].confidence,
"group": r1.answers["group"].confidence,
"tax_class": r1.answers["tax_class"].confidence,
"account": r2.answers["account"].confidence,
}
return {
"debit_account": r2.answers["account"].choice,
"debit_amount": base,
"tax_account": "仮払消費税等",
"tax_amount": tax,
"credit_account": "未払金",
"credit_amount": total,
"tax_class": tax_class,
"confidence": min(confidences.values()),
"confidences": confidences,
"model": r1.model,
}

実行すると、こうなります。

=== 起票候補 ===
debit_account 交際費
debit_amount 120000
tax_account 仮払消費税等
tax_amount 12000
credit_account 未払金
credit_amount 132000
tax_class 課税仕入10
各項目のconfidence: {'total': 0.895, 'group': 0.907, 'tax_class': 0.907, 'account': 0.895}
採用するconfidence(最小値): 0.895

confidence は最小値を採る

confidence を項目ごとに持っておいて、採用するのは最小値にします。

def route(entry: dict) -> str:
if entry["confidence"] < 0.7:
return "人が起票"
if entry["confidence"] < 0.95:
return "起票候補(要承認)"
return "自動起票"

科目が0.98でも税区分が0.55なら、その仕訳は0.55の仕訳です。平均を取ると、弱い判断が強い判断に埋もれます。公式のfunction callingクックブックでも「confidenceは呼び出し全体で最も弱い判断を反映する」とされています。

confidences を辞書で残しておくと、どの項目で引っかかったのかが分かります。承認画面でその欄だけ強調すれば、確認する人の負担が減ります。

検算で捕まるもの、捕まらないもの

起票前に必ず検算を通します。

def check(entry: dict) -> list[str]:
"""コード側の検算。ここを通らない仕訳は起票しない。"""
errors = []
debit = entry["debit_amount"] + entry["tax_amount"]
if debit != entry["credit_amount"]:
errors.append(f"貸借不一致: 借方 {debit} / 貸方 {entry['credit_amount']}")
if entry["debit_amount"] <= 0:
errors.append("本体価額が0以下")
rate = TAX_RATES[entry["tax_class"]]
expected = int((Decimal(entry["credit_amount"]) * rate / (1 + rate)).quantize(Decimal("1"), rounding=ROUND_DOWN))
if entry["tax_amount"] != expected:
errors.append(f"税額不一致: {entry['tax_amount']} / 期待値 {expected}")
return errors

税区分の取り違えは、これで捕まります。10%の取引を8%と判定した仕訳を通すと、こうなります。

税区分を8%と誤認した仕訳の検算: ['税額不一致: 10909 / 期待値 8888']

**問題は、捕まらないほうです。**Jevが「合計 132,000円」ではなく「小計 120,000円」を総額として選んだ場合、そこから計算した本体価額と税額は当然に整合します。

小計(120,000)を総額と誤認した仕訳の検算: OK ← 通ってしまう

対策として、**証憑の側で金額の関係を検算します。**領収書の候補に「a + b = c」を満たす組があれば、その c が総額である可能性が高い、という当たりの付け方ができます。

def sum_consistent_total(amounts: list[str]) -> int | None:
"""候補の中に a + b = c を満たす組があれば、その c が総額の可能性が高い。"""
vals = sorted({to_int(a) for a in amounts})
found = [c for i, a in enumerate(vals) for b in vals[i:] for c in vals if a + b == c]
return max(found) if found else None
候補: ['120,000円', '12,000円', '132,000円']
小計+税額=合計 を満たす値: 132000
Jevの選択 132,000円: OK
Jevの選択 120,000円: ★不一致 → 人が確認

小計と税額が併記されている証憑なら、これだけで取り違えを拾えます。モデルの判断を、モデルとは別の根拠で裏取りするという形です。合計しか書かれていない証憑では使えないので、None のときは素通しになります。

起票までの流れ

まとめるとこうなります。

段階 担当 落ちたときの行き先
金額候補の抽出 正規表現 候補ゼロ → 人が起票
総額・大分類・税区分の選択 Jev —
勘定科目の選択 Jev —
税額・本体価額の計算 コード —
貸借・税額の検算 コード エラー → 人が起票
総額の裏取り コード 不一致 → 人が確認
confidenceによる振り分け コード 0.7未満 → 人が起票
承認 人 —

Jevが関与するのは真ん中の2段階だけです。前後はすべてコードと人が持ちます。

注意点

税区分は税務判断です。 軽減税率の適用、交際費の損金算入、インボイスの要件。いずれも証憑の字面だけで決まるものではありません。tax_class の判定は入力の補助であって、判断ではありません。自動起票のしきい値を上げるより、税区分だけは常に人が見る運用のほうが安全だと思います。

証憑の文言に引きずられます。 Jevは入力データを疑いません。摘要や但し書きに指示のような文が入っていれば、それに従う可能性があります。

監査証跡を残します。 起票候補を作った時点の情報を、仕訳と一緒に保存しておきます。

from datetime import datetime
entry["judged_at"] = datetime.now().isoformat()
entry["approved_by"] = user_id # 承認した人
# entry["model"] にはモデル名(jev-1.13.0)が入っている
# entry["confidences"] には項目ごとの確信度が残っている

誰が承認したかが一番重要です。Jevが提案した仕訳であっても、帳簿に入った時点でそれは承認した人の仕訳です。提案と承認を1つの画面で済ませると、この区別が曖昧になります。

OCRの精度がそのまま効きます。 ここまでのコードは、証憑がテキストになっている前提です。紙をスキャンして読ませる場合、金額の桁が欠けた時点で候補からも消えます。Jevは画像を扱えないので、OCRは別の手段が必要です。

おわりに

生成AIで仕訳を作る話は以前からありますが、会計データで引っかかるのは決まって金額が書き換わることでした。

Jevの方式は、そこを構造で解いています。**モデルに「書かせる」のではなく「選ばせる」。**選択肢はこちらが用意したものだけ。だから、存在しない科目も、存在しない金額も出てきません。

代わりに、こちらの仕事が増えます。正規表現で候補を漏れなく拾う。科目マスタを255件以内の階層に整理する。税額の端数処理を決める。検算を書く。地味な準備がそのまま精度になる構造です。

ただ、会計の仕組みとしては、そのほうが筋が通っていると思います。何が根拠で、どこで人が判断したのかが、コードを読めば分かるからです。

この記事を書いた人

barorinのプロフィール画像

barorinCPA & Engineer

会計とITの二足のわらじで働く公認会計士です。Pythonを中心に、会計・監査の実務で使えるコードや、Ubuntu・Docker等の設定メモなどを書いています。