経費精算で領収書やレシートの情報を手入力していませんか?
本記事では、OpenAI GPT-5.2 APIを活用して領収書・レシートのOCR解析と勘定科目の自動分類を実現する方法を紹介します。
不正検知も実装し、
より堅牢な経費管理システムを構築できます。
注意点
・ 画像解析のAPIコストは通常のテキストより高めです
・ 手書きの領収書は認識精度が下がる場合があります
・ 最終的な経費承認は人間が確認してください
・ PDFの場合は画像変換が必要です
実装の概要
今回のコードでは、以下の流れで処理を行います。
2. フォーム完了画面でOpenAI APIを呼び出し、画像を解析
3. 不正検知(重複・金額異常チェック)と勘定科目の自動判定
4. 解析結果をDBに保存
事前準備
1. 経費申請DBを作成します
2. フォーム完了画面にPHPコードを設定して実行環境を構築します
3. OpenAI APIキーを取得・設定します
経費申請DB
| フィールド名 | 識別名 | 型 | 説明 |
|---|---|---|---|
| 経費申請ID | expenses_id | 数字・記号・アルファベット(32 bytes) 自動採番 | 主キー |
| 申請者 | applicant | テキスト | 申請者名 |
| 申請日 | apply_date | 日付 | 申請日 |
| 領収書画像 | receipt_image | ファイル | 領収書/レシート画像 |
| 利用日 | use_date | 日付 | OCR抽出 |
| 店舗名 | store_name | テキスト | OCR抽出 |
| 金額(税込) | amount | 整数 | OCR抽出 |
| 税額 | tax_amount | 整数 | OCR抽出 |
| 税区分 | tax_type | セレクト | 課税10%/軽減8%/非課税 |
| 勘定科目 | account | セレクト | 交通費(1), 交際費(2), 消耗品費(3), 会議費(4), 通信費(5), 研修費(6), 宿泊費(7), 福利厚生費(8), その他(9)(会社ルールに合わせて任意にカスタマイズ可能) |
| 経費区分 | expense_type | セレクト | 交通費/交際費/消耗品費等 |
| インボイス番号 | invoice_no | テキスト | OCR抽出 |
| 摘要 | description | テキストエリア | AI生成 |
| AI解析結果 | ai_analysis | テキストエリア | JSON形式 |
| 信頼度スコア | confidence | 整数 | 0-100 |
| 警告フラグ | warning_flag | セレクト | なし/重複疑い/金額異常 |
| 解析ステータス | ocr_status | セレクト | 未解析/解析中/完了/失敗 |
| 承認ステータス | approval_status | セレクト | 申請中/承認/差戻し |
統合版PHP(使用例)
以下のPHPを1ファイルとして登録すれば、OCR解析〜経費区分判定〜不正検知〜レコード更新まで一通り動作します。
コピー
<?//<!-- SMP_DYNAMIC_PAGE DISPLAY_ERRORS=ON NAME=XXX -->?>
<?php
// =============================================================================
// 設定(あなたのSPIRAL環境に合わせて書き換えてください)
// =============================================================================
define('OPENAI_API_KEY', 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'); // OpenAI APIキー
define('OPENAI_MODEL', 'gpt-5.2'); // OpenAI モデル名
define('SPIRAL_API_TOKEN_TITLE', 'api_token'); // SPIRAL APIトークンのタイトル(database/get_file等で使用)
define('EXPENSE_DB_TITLE', '経費申請DB'); // 経費申請DBの識別名
define('RECEIPT_FILE_FIELD_NAME', 'receipt_image'); // 領収書ファイル項目の「識別名」
define('GET_FILE_KEY_FIELD_TITLE', 'id');
// 勘定科目フィールドの識別子
define('ACCOUNT_FIELD_NAME', 'account');
// =============================================================================
// SELECTフィールドのIDマッピング(SPIRALのDB設定に合わせて各種ID値を変更してください)
// =============================================================================
define('SELECT_ID_MAP', [
'tax_type' => ['課税10%' => '1', '軽減8%' => '2', '非課税' => '3'],
'expense_type' => [
'交通費' => '1', '交際費' => '2', '消耗品費' => '3', '会議費' => '4', '通信費' => '5',
'研修費' => '6', '宿泊費' => '7', '福利厚生費' => '8', 'その他' => '9'
],
'account' => [
'旅費交通費' => '1', '交際費' => '2', '消耗品費' => '3', '会議費' => '4', '通信費' => '5',
'研修費' => '6', '福利厚生費' => '7', '雑費' => '8'
],
'warning_flag' => ['なし' => '1', '重複疑い' => '2', '金額異常' => '3'],
'ocr_status' => ['未解析' => '1', '解析中' => '2', '完了' => '3', '失敗' => '4']
]);
// =============================================================================
// 勘定科目分類のデフォルトルール
// =============================================================================
define('EXPENSE_ACCOUNT_MAPPING', [
'交通費' => ['account' => '旅費交通費', 'keywords' => ['タクシー', 'TAXI', '電車', 'JR', 'バス', '駐車場', '高速', 'ETC', 'ガソリン']],
'交際費' => ['account' => '交際費', 'keywords' => ['居酒屋', 'レストラン', '焼肉', '寿司', '料亭', 'バー']],
'消耗品費' => ['account' => '消耗品費', 'keywords' => ['文具', '事務用品', 'コピー', 'アスクル']],
'会議費' => ['account' => '会議費', 'keywords' => ['カフェ', 'CAFE', 'コーヒー', 'スターバックス', 'ドトール']],
'通信費' => ['account' => '通信費', 'keywords' => ['携帯', 'docomo', 'au', 'softbank', '郵便']],
'研修費' => ['account' => '研修費', 'keywords' => ['書籍', 'Amazon', 'セミナー', '研修']],
'宿泊費' => ['account' => '旅費交通費', 'keywords' => ['ホテル', 'HOTEL', '旅館', '宿泊']],
'福利厚生費' => ['account' => '福利厚生費', 'keywords' => ['コンビニ', 'セブンイレブン', 'ファミリーマート', 'ローソン']],
'その他' => ['account' => '雑費', 'keywords' => []]
]);
// 飲食系の判定ルール
define('EXPENSE_ACCOUNT_AMOUNT_RULES', [
'meeting_threshold' => 5000
]);
// 不正検知(高額)判定の閾値
define('EXPENSE_FRAUD_AMOUNT_THRESHOLDS', [
'交通費' => 50000,
'交際費' => 100000,
'会議費' => 10000,
'消耗品費' => 30000,
'通信費' => 20000,
'研修費' => 50000,
'宿泊費' => 50000,
'福利厚生費' => 30000,
'その他' => 30000
]);
class ReceiptOcrAnalyzer
{
private $apiKey;
private $model;
private $apiUrl = 'https://api.openai.com/v1/responses';
private $expenseTypeOptions = null;
private $lastError = null;
public function __construct($apiKey)
{
$this->apiKey = $apiKey;
$this->model = defined('OPENAI_MODEL') ? OPENAI_MODEL : 'gpt-5.2';
}
public function setExpenseTypeOptions($options)
{
$this->expenseTypeOptions = $options;
}
public function analyze($imageData, $mimeType = 'image/jpeg')
{
$this->lastError = null;
$prompt = $this->buildPrompt();
$response = $this->callResponsesAPI($prompt, $imageData, $mimeType);
if ($response === false) {
$result = $this->getDefaultResult();
$result['_debug_error'] = $this->lastError ?? 'Vision API呼び出し失敗(詳細不明)';
return $result;
}
$parsed = $this->parseResponse($response);
if (($parsed['confidence'] ?? 0) === 0 && ($parsed['store_name'] ?? null) === null) {
$parsed['_debug_error'] = 'JSONパース失敗または空の解析結果';
$parsed['_debug_raw_response'] = mb_substr((string)$response, 0, 1000);
}
return $parsed;
}
private function buildPrompt()
{
$expenseTypeGuidance = "会社のルールに沿った経費区分名(文字列)";
if (is_array($this->expenseTypeOptions) && count($this->expenseTypeOptions) > 0) {
$lines = [];
foreach ($this->expenseTypeOptions as $opt) {
$id = isset($opt['id']) ? trim((string)$opt['id']) : '';
$label = isset($opt['label']) ? trim((string)$opt['label']) : '';
if ($id === '' && $label === '')
continue;
if ($label === '')
$label = $id;
$lines[] = " - {$id}: {$label}";
}
if (count($lines) > 0) {
$expenseTypeGuidance = "以下の候補から選択し、出力はidで返してください。\n" . implode("\n", $lines);
}
}
return <<<EOT
あなたは経費精算システムのOCR解析AIです。
添付された領収書・レシート画像を解析し、以下の情報をJSON形式で抽出してください。
## 抽出項目
1. store_name(店舗名): 店舗・会社名
2. store_address(住所): 店舗の住所(あれば)
3. store_phone(電話番号): 店舗の電話番号(あれば)
4. date(利用日): YYYY-MM-DD形式
5. amount(税込金額): 整数(円)
6. tax_amount(税額): 整数(円)
7. subtotal(税抜金額): 整数(円)
8. tax_rate(税率): 8 or 10(%)
9. tax_type(税区分): "課税10%", "軽減8%", "非課税" のいずれか
10. invoice_no(インボイス番号): T+13桁の番号(あれば)
11. items(明細): 購入品目の配列 [{name, quantity, unit_price, price, tax_rate}]
12. payment_method(支払方法): 現金, クレジットカード, 電子マネー等
13. card_last4(カード下4桁): クレジットカードの場合
14. expense_type(経費区分): {$expenseTypeGuidance}
15. account(勘定科目): 以下から選択
- "旅費交通費", "交際費", "会議費", "消耗品費", "通信費", "研修費", "福利厚生費", "雑費"
16. description(摘要): 経費精算用の摘要文(30文字以内)
17. confidence(信頼度): 0-100の整数
18. warnings(警告): 解析上の注意点があれば配列で
19. receipt_type(領収書種別): "領収書", "レシート", "請求書", "その他"
20. is_valid_receipt(有効な領収書か): true/false
## 出力形式
必ずJSON形式で出力してください。
EOT;
}
private function callResponsesAPI($prompt, $fileDataBase64, $mimeType)
{
$content = [
['type' => 'input_text', 'text' => $prompt]
];
$lowerMime = strtolower((string)$mimeType);
if ($lowerMime === 'application/pdf') {
$content[] = [
'type' => 'input_file',
'filename' => 'receipt.pdf',
'file_data' => "data:application/pdf;base64,{$fileDataBase64}"
];
}
else {
$content[] = [
'type' => 'input_image',
'image_url' => "data:{$mimeType};base64,{$fileDataBase64}"
];
}
$data = [
'model' => $this->model,
'input' => [
[
'role' => 'user',
'content' => $content
]
],
'text' => [
'format' => ['type' => 'json_object']
],
'max_output_tokens' => 2000
];
$ch = curl_init($this->apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey
],
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_TIMEOUT => 60
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->lastError = "OpenAI APIエラー: HTTP {$httpCode}";
return false;
}
$result = json_decode($response, true);
if (isset($result['output_text']) && is_string($result['output_text']))
return $result['output_text'];
$text = $this->extractOutputText($result);
return $text !== '' ? $text : false;
}
private function extractOutputText($responseJson)
{
$output = $responseJson['output'] ?? null;
if (!is_array($output))
return '';
foreach ($output as $item) {
foreach ($item['content'] ?? [] as $part) {
if (isset($part['text']))
return $part['text'];
}
}
return '';
}
private function parseResponse($response)
{
$cleaned = trim($response);
if (preg_match('/^```(?:json)?\s*\n?(.*?)\n?```$/s', $cleaned, $m))
$cleaned = trim($m[1]);
$data = json_decode($cleaned, true) ?: [];
return [
'store_name' => $data['store_name'] ?? null,
'date' => $data['date'] ?? date('Y-m-d'),
'amount' => max(0, intval($data['amount'] ?? 0)),
'tax_amount' => max(0, intval($data['tax_amount'] ?? 0)),
'tax_type' => $data['tax_type'] ?? '課税10%',
'invoice_no' => $data['invoice_no'] ?? null,
'items' => $data['items'] ?? [],
'expense_type' => $data['expense_type'] ?? 'その他',
'account' => $data['account'] ?? '雑費',
'description' => mb_substr($data['description'] ?? '', 0, 50),
'confidence' => max(0, min(100, intval($data['confidence'] ?? 0))),
'warnings' => $data['warnings'] ?? [],
'raw_response' => $response
];
}
private function getDefaultResult()
{
return [
'store_name' => null, 'date' => date('Y-m-d'), 'amount' => 0,
'tax_amount' => 0, 'tax_type' => '課税10%', 'invoice_no' => null,
'expense_type' => 'その他', 'account' => '雑費',
'description' => 'OCR解析に失敗しました', 'confidence' => 0,
'warnings' => ['OCR解析に失敗しました']
];
}
}
class ExpenseAccountClassifier
{
private $accountMapping;
private $amountRules;
public function __construct($config = [])
{
$this->accountMapping = $config['accountMapping'] ?? (defined('EXPENSE_ACCOUNT_MAPPING') ? EXPENSE_ACCOUNT_MAPPING : []);
$this->amountRules = $config['amountRules'] ?? (defined('EXPENSE_ACCOUNT_AMOUNT_RULES') ? EXPENSE_ACCOUNT_AMOUNT_RULES : []);
}
public function classify($storeName, $items, $aiExpenseType, $amount = 0)
{
$matchedCategory = null;
$matchedKeywords = [];
$confidence = 0;
if ($storeName) {
foreach ($this->accountMapping as $category => $config) {
foreach ($config['keywords'] as $keyword) {
if (mb_stripos($storeName, $keyword) !== false) {
$matchedCategory = $category;
$matchedKeywords[] = $keyword;
$confidence = 80;
break 2;
}
}
}
}
if (!$matchedCategory && !empty($items)) {
foreach ($items as $item) {
$itemName = $item['name'] ?? '';
foreach ($this->accountMapping as $category => $config) {
foreach ($config['keywords'] as $keyword) {
if (mb_stripos($itemName, $keyword) !== false) {
$matchedCategory = $category;
$matchedKeywords[] = $keyword;
$confidence = 60;
break 3;
}
}
}
}
}
if (!$matchedCategory) {
$matchedCategory = $aiExpenseType;
$confidence = 50;
}
$account = $this->accountMapping[$matchedCategory]['account'] ?? '雑費';
if (in_array($matchedCategory, ['交際費', '会議費']) && $amount > 0) {
if ($amount <= ($this->amountRules['meeting_threshold'] ?? 5000)) {
$account = '会議費';
$matchedCategory = '会議費';
}
else {
$account = '交際費';
$matchedCategory = '交際費';
}
}
return [
'expense_type' => $matchedCategory,
'account' => $account,
'matched_keywords' => $matchedKeywords,
'confidence' => $confidence,
'reason' => $this->buildReason($matchedCategory, $matchedKeywords, $confidence)
];
}
private function buildReason($category, $keywords, $confidence)
{
if (empty($keywords)) {
return "AIの判定に基づき「{$category}」に分類(信頼度: {$confidence}%)";
}
return "キーワード「" . implode('、', $keywords) . "」に基づき「{$category}」に分類(信頼度: {$confidence}%)";
}
}
class ExpenseFraudDetector
{
private $amountThresholds;
public function __construct($config = [])
{
$this->amountThresholds = $config['amountThresholds'] ?? (defined('EXPENSE_FRAUD_AMOUNT_THRESHOLDS') ? EXPENSE_FRAUD_AMOUNT_THRESHOLDS : []);
}
public function check($storeName, $amount, $date, $applicant, $excludeRecordId = null, $expenseTypeForAmount = null)
{
$result = [
'is_duplicate' => false,
'is_amount_anomaly' => false,
'is_frequency_anomaly' => false,
'duplicate_records' => [],
'warnings' => [],
'risk_score' => 0
];
$duplicateResult = $this->checkDuplicate($storeName, $amount, $date, $excludeRecordId);
if ($duplicateResult['is_duplicate']) {
$result['is_duplicate'] = true;
$result['duplicate_records'] = $duplicateResult['records'];
$result['warnings'][] = '同一店舗・同一金額・同一日付の申請が既に存在します';
$result['risk_score'] += 50;
}
$expenseKey = $expenseTypeForAmount !== null && $expenseTypeForAmount !== '' ? $expenseTypeForAmount : 'その他';
$amountResult = $this->checkAmountAnomaly($amount, $expenseKey);
if ($amountResult['is_anomaly']) {
$result['is_amount_anomaly'] = true;
$result['warnings'][] = $amountResult['message'];
$result['risk_score'] += 30;
}
$frequencyResult = $this->checkFrequency($applicant);
if ($frequencyResult['is_anomaly']) {
$result['is_frequency_anomaly'] = true;
$result['warnings'][] = $frequencyResult['message'];
$result['risk_score'] += 20;
}
return $result;
}
private function checkDuplicate($storeName, $amount, $date, $excludeRecordId)
{
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addSelectFields('expenses_id');
$db->addEqualCondition('store_name', $storeName);
$db->addEqualCondition('amount', (string)$amount);
$db->addEqualCondition('use_date', $date);
$db->setLinesPerPage(10);
$result = $db->doSelect();
$records = $result['data'] ?? [];
if ($excludeRecordId !== null && $excludeRecordId !== '') {
$records = array_filter($records, function ($r) use ($excludeRecordId) {
return (string)($r['expenses_id'] ?? '') !== (string)$excludeRecordId;
});
}
return [
'is_duplicate' => count($records) > 0,
'records' => array_values($records)
];
}
private function checkAmountAnomaly($amount, $expenseType)
{
$threshold = $this->amountThresholds[$expenseType] ?? $this->amountThresholds['その他'];
if ($amount > $threshold) {
return [
'is_anomaly' => true,
'message' => "金額が通常の上限({$threshold}円)を超えています"
];
}
return ['is_anomaly' => false];
}
private function checkFrequency($applicant)
{
if ($applicant === null || $applicant === '') {
return ['is_anomaly' => false];
}
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addSelectFields('expenses_id', 'apply_date');
$db->addEqualCondition('applicant', $applicant);
$weekAgo = date('Y-m-d H:i:s', strtotime('-7 days'));
$db->addSortField('apply_date', false);
$db->setLinesPerPage(100);
$result = $db->doSelect();
$records = $result['data'] ?? [];
$weekAgoTime = strtotime($weekAgo);
$count = 0;
foreach ($records as $r) {
$rd = strtotime($r['apply_date'] ?? 'now');
if ($rd >= $weekAgoTime) {
$count++;
}
}
if ($count >= 20) {
return [
'is_anomaly' => true,
'message' => "直近7日間で{$count}件の申請があります(通常より多い)"
];
}
return ['is_anomaly' => false];
}
}
/**
* 領収書ファイルデータ取得処理(SPIRAL v1)
*/
function getReceiptImage($recordId)
{
global $SPIRAL;
if (!$SPIRAL)
return false;
// SPIRALの内部API Communicator(database/get_file)を使用
$SPIRAL->setApiTokenTitle(SPIRAL_API_TOKEN_TITLE);
$communicator = $SPIRAL->getSpiralApiCommunicator();
$request = new SpiralApiRequest();
$request->put('db_title', EXPENSE_DB_TITLE);
$request->put('file_field_title', RECEIPT_FILE_FIELD_NAME);
$request->put('key_field_title', GET_FILE_KEY_FIELD_TITLE);
$request->put('key_field_value', (string)$recordId);
$response = $communicator->request('database', 'get_file', $request);
$code = $response->get('code');
if ($code == 0 || $code === '0') {
$data = $response->get('data');
if (empty($data)) {
return [
'error' => true,
'message' => 'ファイルが空、または添付されていません',
'code' => $code
];
}
return [
'data' => $data, // Base64エンコードのまま保持
'mime_type' => $response->get('content_type') ?: 'application/octet-stream'
];
}
// エラー時はエラーメッセージを含めた配列を返す
return [
'error' => true,
'message' => $response->get('message'),
'code' => $code
];
}
/**
* DB更新処置(SPIRAL v1)
*/
function updateExpenseRecord($recordId, $updateData)
{
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addEqualCondition('expenses_id', $recordId);
return $db->doUpdate($updateData) !== false;
}
/**
* メイン処理
*/
function main()
{
global $SPIRAL;
$recordId = $SPIRAL->getContextByFieldTitle('expenses_id');
if (!$recordId) {
$recordId = $SPIRAL->getParam('expenses_id');
}
if (!$recordId) {
$recordId = $SPIRAL->getContextByFieldTitle('id');
}
if (!$recordId) {
$recordId = $SPIRAL->getParam('id');
}
if (!$recordId) {
return; // 対象レコードがない
}
$applicant = $SPIRAL->getParam('applicant') ?? '';
// 解析ステータスを「解析中」に変更
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['解析中'] ?? '2'
]);
// ファイルの実体を取得
$imageData = getReceiptImage($recordId);
if (isset($imageData['error']) || $imageData === false) {
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['失敗'] ?? '4',
'ai_analysis' => json_encode([
'error' => '画像取得に失敗しました',
'api_message' => $imageData['message'] ?? 'Unknown error',
'api_code' => $imageData['code'] ?? '',
'debug_id' => $recordId,
'debug_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE)
]);
return;
}
// OCR解析
$analyzer = new ReceiptOcrAnalyzer(OPENAI_API_KEY);
// 経費区分の候補をセット
$expenseLabels = array_keys(SELECT_ID_MAP['expense_type']);
$optionsForAnalyzer = [];
foreach ($expenseLabels as $label) {
$optionsForAnalyzer[] = ['id' => $label, 'label' => $label];
}
$analyzer->setExpenseTypeOptions($optionsForAnalyzer);
$ocrResult = $analyzer->analyze($imageData['data'], $imageData['mime_type']);
if (isset($ocrResult['_debug_error'])) {
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['失敗'] ?? '4',
'ai_analysis' => json_encode([
'error' => 'OCR解析失敗',
'debug_detail' => $ocrResult['_debug_error'],
'debug_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE)
]);
return;
}
// 勘定科目再分類
$classifier = new ExpenseAccountClassifier();
$classification = $classifier->classify(
$ocrResult['store_name'] ?? '',
$ocrResult['items'] ?? [],
$ocrResult['expense_type'] ?? 'その他',
$ocrResult['amount'] ?? 0
);
// 不正検知(重複・金額閾値・頻度)
$fraudDetector = new ExpenseFraudDetector();
$fraudResult = $fraudDetector->check(
$ocrResult['store_name'] ?? '',
$ocrResult['amount'] ?? 0,
$ocrResult['date'] ?? date('Y-m-d'),
$applicant,
$recordId,
$classification['expense_type'] ?? 'その他'
);
$warningFlag = 'なし';
if ($fraudResult['is_duplicate'])
$warningFlag = '重複疑い';
elseif ($fraudResult['is_amount_anomaly'])
$warningFlag = '金額異常';
// 最終データの構築(セレクト項目はすべてIDに変換)
$updateData = [
'use_date' => $ocrResult['date'] ?? '',
'store_name' => $ocrResult['store_name'] ?? '',
'amount' => $ocrResult['amount'] ?? 0,
'tax_amount' => $ocrResult['tax_amount'] ?? 0,
'tax_type' => SELECT_ID_MAP['tax_type'][$ocrResult['tax_type']] ?? '1',
'expense_type' => SELECT_ID_MAP['expense_type'][$classification['expense_type']] ?? '9',
'invoice_no' => $ocrResult['invoice_no'] ?? '',
'description' => $ocrResult['description'] ?? '',
'confidence' => $ocrResult['confidence'] ?? 0,
'warning_flag' => SELECT_ID_MAP['warning_flag'][$warningFlag] ?? '1',
'ai_analysis' => json_encode([
'ocr_result' => $ocrResult,
'classification' => $classification,
'fraud_check' => $fraudResult,
'analyzed_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE),
'ocr_status' => SELECT_ID_MAP['ocr_status']['完了'] ?? '3'
];
if (defined('ACCOUNT_FIELD_NAME') && ACCOUNT_FIELD_NAME !== '') {
$updateData[ACCOUNT_FIELD_NAME] = SELECT_ID_MAP['account'][$classification['account']] ?? '8';
}
// 更新
updateExpenseRecord($recordId, $updateData);
}
// 実行
main();
?>
OCR解析モジュール(GPT-5.2):PHPコード
領収書画像を解析して情報を抽出する共通モジュールです。
コピー
<?php
class ReceiptOcrAnalyzer
{
private $apiKey;
private $model;
private $apiUrl = 'https://api.openai.com/v1/responses';
private $expenseTypeOptions = null;
private $lastError = null;
public function __construct($apiKey)
{
$this->apiKey = $apiKey;
$this->model = defined('OPENAI_MODEL') ? OPENAI_MODEL : 'gpt-5.2';
}
public function setExpenseTypeOptions($options)
{
$this->expenseTypeOptions = $options;
}
public function analyze($imageData, $mimeType = 'image/jpeg')
{
$this->lastError = null;
$prompt = $this->buildPrompt();
$response = $this->callResponsesAPI($prompt, $imageData, $mimeType);
if ($response === false) {
$result = $this->getDefaultResult();
$result['_debug_error'] = $this->lastError ?? 'Vision API呼び出し失敗(詳細不明)';
return $result;
}
$parsed = $this->parseResponse($response);
if (($parsed['confidence'] ?? 0) === 0 && ($parsed['store_name'] ?? null) === null) {
$parsed['_debug_error'] = 'JSONパース失敗または空の解析結果';
$parsed['_debug_raw_response'] = mb_substr((string)$response, 0, 1000);
}
return $parsed;
}
private function buildPrompt()
{
$expenseTypeGuidance = "会社のルールに沿った経費区分名(文字列)";
if (is_array($this->expenseTypeOptions) && count($this->expenseTypeOptions) > 0) {
$lines = [];
foreach ($this->expenseTypeOptions as $opt) {
$id = isset($opt['id']) ? trim((string)$opt['id']) : '';
$label = isset($opt['label']) ? trim((string)$opt['label']) : '';
if ($id === '' && $label === '')
continue;
if ($label === '')
$label = $id;
$lines[] = " - {$id}: {$label}";
}
if (count($lines) > 0) {
$expenseTypeGuidance = "以下の候補から選択し、出力はidで返してください。\n" . implode("\n", $lines);
}
}
return <<<EOT
あなたは経費精算システムのOCR解析AIです。
添付された領収書・レシート画像を解析し、以下の情報をJSON形式で抽出してください。
## 抽出項目
1. store_name(店舗名): 店舗・会社名
2. store_address(住所): 店舗の住所(あれば)
3. store_phone(電話番号): 店舗の電話番号(あれば)
4. date(利用日): YYYY-MM-DD形式
5. amount(税込金額): 整数(円)
6. tax_amount(税額): 整数(円)
7. subtotal(税抜金額): 整数(円)
8. tax_rate(税率): 8 or 10(%)
9. tax_type(税区分): "課税10%", "軽減8%", "非課税" のいずれか
10. invoice_no(インボイス番号): T+13桁の番号(あれば)
11. items(明細): 購入品目の配列 [{name, quantity, unit_price, price, tax_rate}]
12. payment_method(支払方法): 現金, クレジットカード, 電子マネー等
13. card_last4(カード下4桁): クレジットカードの場合
14. expense_type(経費区分): {$expenseTypeGuidance}
15. account(勘定科目): 以下から選択
- "旅費交通費", "交際費", "会議費", "消耗品費", "通信費", "研修費", "福利厚生費", "雑費"
16. description(摘要): 経費精算用の摘要文(30文字以内)
17. confidence(信頼度): 0-100の整数
18. warnings(警告): 解析上の注意点があれば配列で
19. receipt_type(領収書種別): "領収書", "レシート", "請求書", "その他"
20. is_valid_receipt(有効な領収書か): true/false
## 出力形式
必ずJSON形式で出力してください。
EOT;
}
private function callResponsesAPI($prompt, $fileDataBase64, $mimeType)
{
$content = [
['type' => 'input_text', 'text' => $prompt]
];
$lowerMime = strtolower((string)$mimeType);
if ($lowerMime === 'application/pdf') {
$content[] = [
'type' => 'input_file',
'filename' => 'receipt.pdf',
'file_data' => "data:application/pdf;base64,{$fileDataBase64}"
];
}
else {
$content[] = [
'type' => 'input_image',
'image_url' => "data:{$mimeType};base64,{$fileDataBase64}"
];
}
$data = [
'model' => $this->model,
'input' => [
[
'role' => 'user',
'content' => $content
]
],
'text' => [
'format' => ['type' => 'json_object']
],
'max_output_tokens' => 2000
];
$ch = curl_init($this->apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey
],
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_TIMEOUT => 60
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->lastError = "OpenAI APIエラー: HTTP {$httpCode}";
return false;
}
$result = json_decode($response, true);
if (isset($result['output_text']) && is_string($result['output_text']))
return $result['output_text'];
$text = $this->extractOutputText($result);
return $text !== '' ? $text : false;
}
private function extractOutputText($responseJson)
{
$output = $responseJson['output'] ?? null;
if (!is_array($output))
return '';
foreach ($output as $item) {
foreach ($item['content'] ?? [] as $part) {
if (isset($part['text']))
return $part['text'];
}
}
return '';
}
private function parseResponse($response)
{
$cleaned = trim($response);
if (preg_match('/^```(?:json)?\s*\n?(.*?)\n?```$/s', $cleaned, $m))
$cleaned = trim($m[1]);
$data = json_decode($cleaned, true) ?: [];
return [
'store_name' => $data['store_name'] ?? null,
'date' => $data['date'] ?? date('Y-m-d'),
'amount' => max(0, intval($data['amount'] ?? 0)),
'tax_amount' => max(0, intval($data['tax_amount'] ?? 0)),
'tax_type' => $data['tax_type'] ?? '課税10%',
'invoice_no' => $data['invoice_no'] ?? null,
'items' => $data['items'] ?? [],
'expense_type' => $data['expense_type'] ?? 'その他',
'account' => $data['account'] ?? '雑費',
'description' => mb_substr($data['description'] ?? '', 0, 50),
'confidence' => max(0, min(100, intval($data['confidence'] ?? 0))),
'warnings' => $data['warnings'] ?? [],
'raw_response' => $response
];
}
private function getDefaultResult()
{
return [
'store_name' => null, 'date' => date('Y-m-d'), 'amount' => 0,
'tax_amount' => 0, 'tax_type' => '課税10%', 'invoice_no' => null,
'expense_type' => 'その他', 'account' => '雑費',
'description' => 'OCR解析に失敗しました', 'confidence' => 0,
'warnings' => ['OCR解析に失敗しました']
];
}
}
フォーム完了画面用PHP(メイン処理)
フォーム送信完了後のサンキューページで実行されるPHPです(フォーム登録完了時にOCR処理が走ります)。
コピー
<?//<!-- SMP_DYNAMIC_PAGE DISPLAY_ERRORS=ON NAME=XXX -->?>
<?php
// =============================================================================
// 設定(あなたのSPIRAL環境に合わせて書き換えてください)
// =============================================================================
define('OPENAI_API_KEY', 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'); // OpenAI APIキー
define('OPENAI_MODEL', 'gpt-5.2'); // OpenAI モデル名
define('SPIRAL_API_TOKEN_TITLE', 'api_token'); // SPIRAL APIトークンのタイトル(database/get_file等で使用)
define('EXPENSE_DB_TITLE', '経費申請DB'); // 経費申請DBの識別名
define('RECEIPT_FILE_FIELD_NAME', 'receipt_image'); // 領収書ファイル項目の「識別名」
define('GET_FILE_KEY_FIELD_TITLE', 'id');
// 勘定科目フィールドの識別子
define('ACCOUNT_FIELD_NAME', 'account');
// =============================================================================
// SELECTフィールドのIDマッピング(SPIRALのDB設定に合わせて各種ID値を変更してください)
// =============================================================================
define('SELECT_ID_MAP', [
'tax_type' => ['課税10%' => '1', '軽減8%' => '2', '非課税' => '3'],
'expense_type' => [
'交通費' => '1', '交際費' => '2', '消耗品費' => '3', '会議費' => '4', '通信費' => '5',
'研修費' => '6', '宿泊費' => '7', '福利厚生費' => '8', 'その他' => '9'
],
'account' => [
'旅費交通費' => '1', '交際費' => '2', '消耗品費' => '3', '会議費' => '4', '通信費' => '5',
'研修費' => '6', '福利厚生費' => '7', '雑費' => '8'
],
'warning_flag' => ['なし' => '1', '重複疑い' => '2', '金額異常' => '3'],
'ocr_status' => ['未解析' => '1', '解析中' => '2', '完了' => '3', '失敗' => '4']
]);
// =============================================================================
// 勘定科目分類のデフォルトルール
// =============================================================================
define('EXPENSE_ACCOUNT_MAPPING', [
'交通費' => ['account' => '旅費交通費', 'keywords' => ['タクシー', 'TAXI', '電車', 'JR', 'バス', '駐車場', '高速', 'ETC', 'ガソリン']],
'交際費' => ['account' => '交際費', 'keywords' => ['居酒屋', 'レストラン', '焼肉', '寿司', '料亭', 'バー']],
'消耗品費' => ['account' => '消耗品費', 'keywords' => ['文具', '事務用品', 'コピー', 'アスクル']],
'会議費' => ['account' => '会議費', 'keywords' => ['カフェ', 'CAFE', 'コーヒー', 'スターバックス', 'ドトール']],
'通信費' => ['account' => '通信費', 'keywords' => ['携帯', 'docomo', 'au', 'softbank', '郵便']],
'研修費' => ['account' => '研修費', 'keywords' => ['書籍', 'Amazon', 'セミナー', '研修']],
'宿泊費' => ['account' => '旅費交通費', 'keywords' => ['ホテル', 'HOTEL', '旅館', '宿泊']],
'福利厚生費' => ['account' => '福利厚生費', 'keywords' => ['コンビニ', 'セブンイレブン', 'ファミリーマート', 'ローソン']],
'その他' => ['account' => '雑費', 'keywords' => []]
]);
// 飲食系の判定ルール
define('EXPENSE_ACCOUNT_AMOUNT_RULES', [
'meeting_threshold' => 5000
]);
// 不正検知(高額)判定の閾値
define('EXPENSE_FRAUD_AMOUNT_THRESHOLDS', [
'交通費' => 50000,
'交際費' => 100000,
'会議費' => 10000,
'消耗品費' => 30000,
'通信費' => 20000,
'研修費' => 50000,
'宿泊費' => 50000,
'福利厚生費' => 30000,
'その他' => 30000
]);
// この下に code1 / code3 / code4 のクラスを順に貼る(2つ目以降は先頭の <?php を除く)で code5 と同一
/**
* 領収書ファイルデータ取得処理(SPIRAL v1)
*/
function getReceiptImage($recordId)
{
global $SPIRAL;
if (!$SPIRAL)
return false;
// SPIRALの内部API Communicator(database/get_file)を使用
$SPIRAL->setApiTokenTitle(SPIRAL_API_TOKEN_TITLE);
$communicator = $SPIRAL->getSpiralApiCommunicator();
$request = new SpiralApiRequest();
$request->put('db_title', EXPENSE_DB_TITLE);
$request->put('file_field_title', RECEIPT_FILE_FIELD_NAME);
$request->put('key_field_title', GET_FILE_KEY_FIELD_TITLE);
$request->put('key_field_value', (string)$recordId);
$response = $communicator->request('database', 'get_file', $request);
$code = $response->get('code');
if ($code == 0 || $code === '0') {
$data = $response->get('data');
if (empty($data)) {
return [
'error' => true,
'message' => 'ファイルが空、または添付されていません',
'code' => $code
];
}
return [
'data' => $data, // Base64エンコードのまま保持
'mime_type' => $response->get('content_type') ?: 'application/octet-stream'
];
}
// エラー時はエラーメッセージを含めた配列を返す
return [
'error' => true,
'message' => $response->get('message'),
'code' => $code
];
}
/**
* DB更新処置(SPIRAL v1)
*/
function updateExpenseRecord($recordId, $updateData)
{
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addEqualCondition('expenses_id', $recordId);
return $db->doUpdate($updateData) !== false;
}
/**
* メイン処理
*/
function main()
{
global $SPIRAL;
$recordId = $SPIRAL->getContextByFieldTitle('expenses_id');
if (!$recordId) {
$recordId = $SPIRAL->getParam('expenses_id');
}
if (!$recordId) {
$recordId = $SPIRAL->getContextByFieldTitle('id');
}
if (!$recordId) {
$recordId = $SPIRAL->getParam('id');
}
if (!$recordId) {
return; // 対象レコードがない
}
$applicant = $SPIRAL->getParam('applicant') ?? '';
// 解析ステータスを「解析中」に変更
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['解析中'] ?? '2'
]);
// ファイルの実体を取得
$imageData = getReceiptImage($recordId);
if (isset($imageData['error']) || $imageData === false) {
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['失敗'] ?? '4',
'ai_analysis' => json_encode([
'error' => '画像取得に失敗しました',
'api_message' => $imageData['message'] ?? 'Unknown error',
'api_code' => $imageData['code'] ?? '',
'debug_id' => $recordId,
'debug_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE)
]);
return;
}
// OCR解析
$analyzer = new ReceiptOcrAnalyzer(OPENAI_API_KEY);
// 経費区分の候補をセット
$expenseLabels = array_keys(SELECT_ID_MAP['expense_type']);
$optionsForAnalyzer = [];
foreach ($expenseLabels as $label) {
$optionsForAnalyzer[] = ['id' => $label, 'label' => $label];
}
$analyzer->setExpenseTypeOptions($optionsForAnalyzer);
$ocrResult = $analyzer->analyze($imageData['data'], $imageData['mime_type']);
if (isset($ocrResult['_debug_error'])) {
updateExpenseRecord($recordId, [
'ocr_status' => SELECT_ID_MAP['ocr_status']['失敗'] ?? '4',
'ai_analysis' => json_encode([
'error' => 'OCR解析失敗',
'debug_detail' => $ocrResult['_debug_error'],
'debug_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE)
]);
return;
}
// 勘定科目再分類
$classifier = new ExpenseAccountClassifier();
$classification = $classifier->classify(
$ocrResult['store_name'] ?? '',
$ocrResult['items'] ?? [],
$ocrResult['expense_type'] ?? 'その他',
$ocrResult['amount'] ?? 0
);
// 不正検知(重複・金額閾値・頻度)
$fraudDetector = new ExpenseFraudDetector();
$fraudResult = $fraudDetector->check(
$ocrResult['store_name'] ?? '',
$ocrResult['amount'] ?? 0,
$ocrResult['date'] ?? date('Y-m-d'),
$applicant,
$recordId,
$classification['expense_type'] ?? 'その他'
);
$warningFlag = 'なし';
if ($fraudResult['is_duplicate'])
$warningFlag = '重複疑い';
elseif ($fraudResult['is_amount_anomaly'])
$warningFlag = '金額異常';
// 最終データの構築(セレクト項目はすべてIDに変換)
$updateData = [
'use_date' => $ocrResult['date'] ?? '',
'store_name' => $ocrResult['store_name'] ?? '',
'amount' => $ocrResult['amount'] ?? 0,
'tax_amount' => $ocrResult['tax_amount'] ?? 0,
'tax_type' => SELECT_ID_MAP['tax_type'][$ocrResult['tax_type']] ?? '1',
'expense_type' => SELECT_ID_MAP['expense_type'][$classification['expense_type']] ?? '9',
'invoice_no' => $ocrResult['invoice_no'] ?? '',
'description' => $ocrResult['description'] ?? '',
'confidence' => $ocrResult['confidence'] ?? 0,
'warning_flag' => SELECT_ID_MAP['warning_flag'][$warningFlag] ?? '1',
'ai_analysis' => json_encode([
'ocr_result' => $ocrResult,
'classification' => $classification,
'fraud_check' => $fraudResult,
'analyzed_at' => date('Y-m-d H:i:s')
], JSON_UNESCAPED_UNICODE),
'ocr_status' => SELECT_ID_MAP['ocr_status']['完了'] ?? '3'
];
if (defined('ACCOUNT_FIELD_NAME') && ACCOUNT_FIELD_NAME !== '') {
$updateData[ACCOUNT_FIELD_NAME] = SELECT_ID_MAP['account'][$classification['account']] ?? '8';
}
// 更新
updateExpenseRecord($recordId, $updateData);
}
// 実行
main();
?>
勘定科目分類ロジック:PHPコード
抽出した情報から勘定科目を判定するロジックです。
コピー
<?php
class ExpenseAccountClassifier
{
private $accountMapping;
private $amountRules;
public function __construct($config = [])
{
$this->accountMapping = $config['accountMapping'] ?? (defined('EXPENSE_ACCOUNT_MAPPING') ? EXPENSE_ACCOUNT_MAPPING : []);
$this->amountRules = $config['amountRules'] ?? (defined('EXPENSE_ACCOUNT_AMOUNT_RULES') ? EXPENSE_ACCOUNT_AMOUNT_RULES : []);
}
public function classify($storeName, $items, $aiExpenseType, $amount = 0)
{
$matchedCategory = null;
$matchedKeywords = [];
$confidence = 0;
if ($storeName) {
foreach ($this->accountMapping as $category => $config) {
foreach ($config['keywords'] as $keyword) {
if (mb_stripos($storeName, $keyword) !== false) {
$matchedCategory = $category;
$matchedKeywords[] = $keyword;
$confidence = 80;
break 2;
}
}
}
}
if (!$matchedCategory && !empty($items)) {
foreach ($items as $item) {
$itemName = $item['name'] ?? '';
foreach ($this->accountMapping as $category => $config) {
foreach ($config['keywords'] as $keyword) {
if (mb_stripos($itemName, $keyword) !== false) {
$matchedCategory = $category;
$matchedKeywords[] = $keyword;
$confidence = 60;
break 3;
}
}
}
}
}
if (!$matchedCategory) {
$matchedCategory = $aiExpenseType;
$confidence = 50;
}
$account = $this->accountMapping[$matchedCategory]['account'] ?? '雑費';
if (in_array($matchedCategory, ['交際費', '会議費']) && $amount > 0) {
if ($amount <= ($this->amountRules['meeting_threshold'] ?? 5000)) {
$account = '会議費';
$matchedCategory = '会議費';
}
else {
$account = '交際費';
$matchedCategory = '交際費';
}
}
return [
'expense_type' => $matchedCategory,
'account' => $account,
'matched_keywords' => $matchedKeywords,
'confidence' => $confidence,
'reason' => $this->buildReason($matchedCategory, $matchedKeywords, $confidence)
];
}
private function buildReason($category, $keywords, $confidence)
{
if (empty($keywords)) {
return "AIの判定に基づき「{$category}」に分類(信頼度: {$confidence}%)";
}
return "キーワード「" . implode('、', $keywords) . "」に基づき「{$category}」に分類(信頼度: {$confidence}%)";
}
}
不正検知・重複チェック:PHPコード
重複申請や金額異常を検出するモジュールです。
コピー
<?php
class ExpenseFraudDetector
{
private $amountThresholds;
public function __construct($config = [])
{
$this->amountThresholds = $config['amountThresholds'] ?? (defined('EXPENSE_FRAUD_AMOUNT_THRESHOLDS') ? EXPENSE_FRAUD_AMOUNT_THRESHOLDS : []);
}
public function check($storeName, $amount, $date, $applicant, $excludeRecordId = null, $expenseTypeForAmount = null)
{
$result = [
'is_duplicate' => false,
'is_amount_anomaly' => false,
'is_frequency_anomaly' => false,
'duplicate_records' => [],
'warnings' => [],
'risk_score' => 0
];
$duplicateResult = $this->checkDuplicate($storeName, $amount, $date, $excludeRecordId);
if ($duplicateResult['is_duplicate']) {
$result['is_duplicate'] = true;
$result['duplicate_records'] = $duplicateResult['records'];
$result['warnings'][] = '同一店舗・同一金額・同一日付の申請が既に存在します';
$result['risk_score'] += 50;
}
$expenseKey = $expenseTypeForAmount !== null && $expenseTypeForAmount !== '' ? $expenseTypeForAmount : 'その他';
$amountResult = $this->checkAmountAnomaly($amount, $expenseKey);
if ($amountResult['is_anomaly']) {
$result['is_amount_anomaly'] = true;
$result['warnings'][] = $amountResult['message'];
$result['risk_score'] += 30;
}
$frequencyResult = $this->checkFrequency($applicant);
if ($frequencyResult['is_anomaly']) {
$result['is_frequency_anomaly'] = true;
$result['warnings'][] = $frequencyResult['message'];
$result['risk_score'] += 20;
}
return $result;
}
private function checkDuplicate($storeName, $amount, $date, $excludeRecordId)
{
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addSelectFields('expenses_id');
$db->addEqualCondition('store_name', $storeName);
$db->addEqualCondition('amount', (string)$amount);
$db->addEqualCondition('use_date', $date);
$db->setLinesPerPage(10);
$result = $db->doSelect();
$records = $result['data'] ?? [];
if ($excludeRecordId !== null && $excludeRecordId !== '') {
$records = array_filter($records, function ($r) use ($excludeRecordId) {
return (string)($r['expenses_id'] ?? '') !== (string)$excludeRecordId;
});
}
return [
'is_duplicate' => count($records) > 0,
'records' => array_values($records)
];
}
private function checkAmountAnomaly($amount, $expenseType)
{
$threshold = $this->amountThresholds[$expenseType] ?? $this->amountThresholds['その他'];
if ($amount > $threshold) {
return [
'is_anomaly' => true,
'message' => "金額が通常の上限({$threshold}円)を超えています"
];
}
return ['is_anomaly' => false];
}
private function checkFrequency($applicant)
{
if ($applicant === null || $applicant === '') {
return ['is_anomaly' => false];
}
global $SPIRAL;
$db = $SPIRAL->getDataBase(EXPENSE_DB_TITLE);
$db->addSelectFields('expenses_id', 'apply_date');
$db->addEqualCondition('applicant', $applicant);
$weekAgo = date('Y-m-d H:i:s', strtotime('-7 days'));
$db->addSortField('apply_date', false);
$db->setLinesPerPage(100);
$result = $db->doSelect();
$records = $result['data'] ?? [];
$weekAgoTime = strtotime($weekAgo);
$count = 0;
foreach ($records as $r) {
$rd = strtotime($r['apply_date'] ?? 'now');
if ($rd >= $weekAgoTime) {
$count++;
}
}
if ($count >= 20) {
return [
'is_anomaly' => true,
'message' => "直近7日間で{$count}件の申請があります(通常より多い)"
];
}
return ['is_anomaly' => false];
}
}
不正検知機能
・ 金額異常チェック:経費区分ごとの上限を超える申請を検出
・ 申請頻度チェック:短期間での大量申請を検出
動作確認
テストケース1:コンビニレシート
店舗名: セブンイレブン ○○店 / 金額: 1,080円 / 税額: 80円(軽減税率8%)
勘定科目: 福利厚生費 / 経費区分: 消耗品費
テストケース2:タクシー領収書
店舗名: ○○タクシー / 金額: 3,200円 / 税額: 290円(税率10%)
勘定科目: 旅費交通費 / 経費区分: 交通費
テストケース3:飲食店領収書
店舗名: 居酒屋○○ / 金額: 25,000円 / 税額: 2,272円(税率10%)
勘定科目: 交際費 / 経費区分: 接待交際費 / インボイス番号: T1234567890123
勘定科目の自動分類ルール
| 経費区分 | 勘定科目 | 判定キーワード例 |
|---|---|---|
| 交通費 | 旅費交通費 | タクシー、電車、バス、駐車場、高速道路 |
| 接待交際費 | 交際費 | 居酒屋、レストラン、飲食、宴会 |
| 会議費 | 会議費 | カフェ、喫茶店、会議室 |
| 消耗品費 | 消耗品費 | 文具、事務用品、コンビニ |
| 通信費 | 通信費 | 携帯電話、インターネット、郵便 |
| 書籍・研修費 | 研修費 | 書籍、セミナー、研修 |
| 宿泊費 | 旅費交通費 | ホテル、旅館、宿泊 |
コスト試算
| 月間処理枚数 | 推定APIコスト |
|---|---|
| 100枚 | 約500〜1,000円 |
| 500枚 | 約2,500〜5,000円 |
| 1,000枚 | 約5,000〜10,000円 |
まとめ
さらにAPIを活用した不正検知等も実現し、より堅牢な経費管理システムを構築できます。
不具合やご質問がある場合は、下記の「コンテンツに関しての要望はこちら」からご連絡ください。