開発情報・ナレッジ

投稿者: ShiningStar株式会社 2026年4月10日 (金)

レシート撮るだけ!AI×SPIRALで経費精算を丸ごと自動化してみたサンプルプログラム

経費精算で領収書やレシートの情報を手入力していませんか?
本記事では、OpenAI GPT-5.2 APIを活用して領収書・レシートのOCR解析勘定科目の自動分類を実現する方法を紹介します。
不正検知も実装し、
より堅牢な経費管理システムを構築できます。

注意点

OpenAI APIキーが必要です(有料)
画像解析のAPIコストは通常のテキストより高めです
手書きの領収書は認識精度が下がる場合があります
最終的な経費承認は人間が確認してください
PDFの場合は画像変換が必要です

実装の概要

今回のコードでは、以下の流れで処理を行います。

1. 経費申請フォームから領収書画像をアップロード
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円
※GPT-5.2の画像解析コスト。画像サイズにより変動します。

まとめ

本記事では、AIによる領収書・レシートのOCR解析と勘定科目自動分類の実装方法を紹介しました。
さらにAPIを活用した不正検知等も実現し、より堅牢な経費管理システムを構築できます。
不具合やご質問がある場合は、下記の「コンテンツに関しての要望はこちら」からご連絡ください。
解決しない場合はこちら コンテンツに関しての
要望はこちら