開発情報・ナレッジ

投稿者: ShiningStar株式会社 2026年3月2日 (月)

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

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

注意点

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

実装の概要

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

1. 経費申請フォームから領収書画像をアップロード
2. DBトリガでSPIRAL APIを使用して画像を取得
3. OpenAI APIで画像を解析
4. 不正検知(重複・金額異常チェック)

事前準備

1. アプリを作成し、経費申請DBを作成します
2. DBトリガ(非同期アクション>PHP実行)を設定し、PHPコードを登録します
3. APIトークンを発行し、レコード更新・ファイル取得権限を付与します

経費申請DB
フィールド名 識別名 説明
申請日apply_date日付申請日
領収書画像receipt_imageファイル領収書/レシート画像
利用日use_date日付OCR抽出
店舗名store_nameテキストOCR抽出
金額(税込)amount整数OCR抽出
税額tax_amount整数OCR抽出
税区分tax_typeセレクト課税10%/軽減8%/非課税
経費区分expense_typeセレクト交通費(1), 交際費(2), 消耗品費(3), 会議費(4), 通信費(5), 研修費(6), 宿泊費(7), 福利厚生費(8), その他(9)(会社ルールに合わせて任意にカスタマイズ可能)
インボイス番号invoice_noテキストOCR抽出
摘要descriptionテキストエリアAI生成
AI解析結果ai_analysisテキストエリアJSON形式
信頼度スコアconfidence整数0-100
警告フラグwarning_flagセレクトなし/重複疑い/金額異常
解析ステータスocr_statusセレクト未解析/解析中/完了/失敗

統合版PHP(使用例)

以下のPHPを1ファイルとして登録すれば、OCR解析〜経費区分判定〜不正検知〜レコード更新まで一通り動作します。

コピー
<?php

// =============================================================================
// 設定(あなたのSPIRAL環境に合わせて書き換えてください)
// =============================================================================
define('OPENAI_API_KEY', 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'); // OpenAI APIキー
define('OPENAI_MODEL', 'gpt-5.2');                    // OpenAI モデル名
define('SPIRAL_API_TOKEN', 'xxxxxxxxxxxxxxxxxxxxxxxx');  // SPIRAL APIトークン(Bearer)
define('SPIRAL_API_URL', 'https://api.spiral-platform.com/v1'); // SPIRAL APIのベースURL(通常は固定)
define('APP_ID', 'your_app_id'); // 対象アプリID
define('EXPENSE_DB_ID', 'your_expense_db_id'); // 経費申請DBのDB ID
define('RECEIPT_FILE_FIELD_ID', 'your_receipt_file_field_id'); // 領収書ファイル項目の「フィールドID」(downloadFile APIで必要)

// =============================================================================
// 勘定科目フィールドの設定
// - ACCOUNT_FIELD_NAME: 経費DB内の勘定科目フィールド名(識別子)。未設定('')なら勘定科目は更新しません
// - EXPENSE_ACCOUNT_ID_MAP: 勘定科目ラベル→セレクトID(value)の対応表
//   ※通常は$SPIRAL->getRecord()のoptionsから自動取得されるため不要ですが、フォールバック用に残してあります
// =============================================================================
define('ACCOUNT_FIELD_NAME', 'account'); // 勘定科目フィールドの識別子(例)
define('EXPENSE_ACCOUNT_ID_MAP', [
    // ※通常は自動取得されるため不要ですが、フォールバック用に設定可能
    // '旅費交通費' => '1',
    // '交際費' => '2',
    // '会議費' => '3',
    // '消耗品費' => '4',
    // '通信費' => '5',
    // '研修費' => '6',
    // '福利厚生費' => '7',
    // '雑費' => '8'
]);

// =============================================================================
// 勘定科目分類のデフォルトルール(会社の運用に合わせて自由に編集)
// - キー: 経費区分名(分類上のカテゴリ)
// - account: 勘定科目(ラベル文字列でOK。DBがセレクトでも自動でIDに変換されます)
// - keywords: 店舗名/明細に含まれるキーワードで簡易分類
//
// ※ expense_typeセレクトの選択肢と対応させてください
//   - 交通費(1), 交際費(2), 消耗品費(3), 会議費(4), 通信費(5), 研修費(6), 宿泊費(7), 福利厚生費(8), その他(9)
//
// ※ 他のセレクトフィールドのID対応(上から順に1,2,3...):
//   - tax_type: 課税10%(1), 軽減8%(2), 非課税(3)
//   - 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' => []]
]);

// =============================================================================
// 飲食系の判定ルール(会議費/交際費の分岐など)
// - meeting_threshold: これ以下なら会議費、それより大きければ交際費
// =============================================================================
define('EXPENSE_ACCOUNT_AMOUNT_RULES', [
    'meeting_threshold' => 5000
]);

// =============================================================================
// 不正検知(高額)判定の閾値(カテゴリ別の上限目安)
// - キー: 経費区分名(expense_typeのラベルと合わせる)
// - 値: 円(整数)
// =============================================================================
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;
    }

    public function getLastError() {
        return $this->lastError;
    }

    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} - " . mb_substr((string)$response, 0, 500);
            return false;
        }

        $result = json_decode($response, true);
        if (!is_array($result)) {
            $this->lastError = "OpenAI APIレスポンスのJSONパース失敗: " . mb_substr((string)$response, 0, 500);
            return false;
        }
        if (isset($result['output_text']) && is_string($result['output_text'])) return $result['output_text'];
        $text = $this->extractOutputText($result);
        if ($text === '') {
            $this->lastError = "OpenAI APIレスポンスからテキスト抽出失敗: " . mb_substr(json_encode($result, JSON_UNESCAPED_UNICODE), 0, 500);
            return false;
        }
        return $text;
    }

    private function extractOutputText($responseJson) {
        $output = $responseJson['output'] ?? null;
        if (!is_array($output) || count($output) === 0) return '';
        foreach ($output as $item) {
            $content = $item['content'] ?? null;
            if (!is_array($content)) continue;
            foreach ($content as $part) {
                if (isset($part['text']) && is_string($part['text'])) {
                    return $part['text'];
                }
                if (isset($part['type']) && $part['type'] === 'output_text' && isset($part['text']) && is_string($part['text'])) {
                    return $part['text'];
                }
            }
        }
        return '';
    }

    private function parseResponse($response) {
        $cleaned = trim($response);
        // markdownコードブロック(```json ... ```)を除去
        if (preg_match('/^```(?:json)?\s*\n?(.*?)\n?```$/s', $cleaned, $m)) {
            $cleaned = trim($m[1]);
        }
        $data = json_decode($cleaned, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            return $this->getDefaultResult();
        }

        $validAccounts = ['旅費交通費', '交際費', '会議費', '消耗品費', '通信費', '研修費', '福利厚生費', '雑費'];
        $validTaxTypes = ['課税10%', '軽減8%', '非課税'];
        $expenseType = isset($data['expense_type']) ? trim((string)$data['expense_type']) : '';

        if ($expenseType !== '' && is_array($this->expenseTypeOptions) && count($this->expenseTypeOptions) > 0) {
            $normalized = $this->normalizeExpenseTypeToId($expenseType);
            if ($normalized !== null) {
                $expenseType = $normalized;
            }
        }

        return [
            'store_name' => $data['store_name'] ?? null,
            'store_address' => $data['store_address'] ?? null,
            'store_phone' => $data['store_phone'] ?? null,
            'date' => $this->validateDate($data['date'] ?? null),
            'amount' => max(0, intval($data['amount'] ?? 0)),
            'tax_amount' => max(0, intval($data['tax_amount'] ?? 0)),
            'subtotal' => max(0, intval($data['subtotal'] ?? 0)),
            'tax_rate' => in_array($data['tax_rate'] ?? 10, [8, 10]) ? $data['tax_rate'] : 10,
            'tax_type' => in_array($data['tax_type'] ?? '', $validTaxTypes) ? $data['tax_type'] : '課税10%',
            'invoice_no' => $this->validateInvoiceNo($data['invoice_no'] ?? null),
            'items' => $data['items'] ?? [],
            'payment_method' => $data['payment_method'] ?? null,
            'card_last4' => $data['card_last4'] ?? null,
            'expense_type' => $expenseType !== '' ? mb_substr($expenseType, 0, 50) : 'その他',
            'account' => in_array($data['account'] ?? '', $validAccounts) ? $data['account'] : '雑費',
            'description' => mb_substr($data['description'] ?? '', 0, 50),
            'confidence' => max(0, min(100, intval($data['confidence'] ?? 0))),
            'warnings' => $data['warnings'] ?? [],
            'receipt_type' => $data['receipt_type'] ?? 'レシート',
            'is_valid_receipt' => $data['is_valid_receipt'] ?? true,
            'raw_response' => $response
        ];
    }

    private function normalizeExpenseTypeToId($value) {
        $value = trim((string)$value);
        if ($value === '') return null;

        foreach ($this->expenseTypeOptions as $opt) {
            $id = isset($opt['id']) ? trim((string)$opt['id']) : '';
            if ($id !== '' && $value === $id) return $id;
        }

        foreach ($this->expenseTypeOptions as $opt) {
            $id = isset($opt['id']) ? trim((string)$opt['id']) : '';
            $label = isset($opt['label']) ? trim((string)$opt['label']) : '';
            if ($id !== '' && $label !== '' && $value === $label) return $id;
        }

        return null;
    }

    private function validateDate($date) {
        if (empty($date)) return date('Y-m-d');
        $timestamp = strtotime($date);
        return $timestamp ? date('Y-m-d', $timestamp) : date('Y-m-d');
    }

    private function validateInvoiceNo($invoiceNo) {
        if (empty($invoiceNo)) return null;
        return preg_match('/^T\d{13}$/', $invoiceNo) ? $invoiceNo : null;
    }

    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;
    private $accountLabelToIdMap;

    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 : []);
        $this->accountLabelToIdMap = $config['accountLabelToIdMap'] ?? [];
    }

    public function setAccountLabelToIdMap($map) {
        $this->accountLabelToIdMap = $map;
    }

    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 = '交際費';
            }
        }

        // 勘定科目ラベル→IDの自動変換(optionsから取得したマップを使用)
        $accountId = null;
        if (!empty($this->accountLabelToIdMap) && isset($this->accountLabelToIdMap[$account])) {
            $accountId = $this->accountLabelToIdMap[$account];
        }
        // フォールバック: EXPENSE_ACCOUNT_ID_MAP(手動設定)
        if (($accountId === null || $accountId === '') && defined('EXPENSE_ACCOUNT_ID_MAP') && is_array(EXPENSE_ACCOUNT_ID_MAP)) {
            $accountId = EXPENSE_ACCOUNT_ID_MAP[$account] ?? null;
        }

        return [
            'expense_type' => $matchedCategory,
            'account' => $account,
            'account_id' => $accountId,
            '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, $applicantId, $excludeRecordId = 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;
        }
        
        // 金額異常チェック
        $amountResult = $this->checkAmountAnomaly($amount, 'その他');
        if ($amountResult['is_anomaly']) {
            $result['is_amount_anomaly'] = true;
            $result['warnings'][] = $amountResult['message'];
            $result['risk_score'] += 30;
        }
        
        // 頻度チェック(同一申請者の直近申請数)
        $frequencyResult = $this->checkFrequency($applicantId);
        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) {
        $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records';
        
        // SPIRAL API: whereパラメータで条件式を指定
        $escapedStoreName = str_replace("'", "''", $storeName);
        $where = "@store_name = '" . $escapedStoreName . "' AND @amount = " . intval($amount) . " AND @use_date = '" . $date . "'";
        $params = 'where=' . rawurlencode($where) . '&limit=10';
        
        $ch = curl_init($url . '?' . $params);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . SPIRAL_API_TOKEN,
                'X-Spiral-Api-Version: 1.1'
            ]
        ]);
        
        $response = curl_exec($ch);
        curl_close($ch);
        
        $data = json_decode($response, true);
        $records = $data['items'] ?? [];
        
        // 自分自身を除外
        if ($excludeRecordId !== null && $excludeRecordId !== '') {
            $excludeId = (string)$excludeRecordId;
            $records = array_filter($records, function($r) use ($excludeId) {
                return (string)($r['_id'] ?? '') !== $excludeId;
            });
        }
        
        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($applicantId) {
        if ($applicantId === null || $applicantId === '') {
            return ['is_anomaly' => false];
        }
        $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records';
        
        $weekAgo = date('Y-m-d\TH:i:s\Z', strtotime('-7 days'));
        $escapedApplicantId = str_replace("'", "''", (string)$applicantId);
        $where = "@applicant_id = '" . $escapedApplicantId . "' AND @_createdAt >= '" . $weekAgo . "'";
        $params = 'where=' . rawurlencode($where) . '&limit=100';
        
        $ch = curl_init($url . '?' . $params);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . SPIRAL_API_TOKEN,
                'X-Spiral-Api-Version: 1.1'
            ]
        ]);
        
        $response = curl_exec($ch);
        curl_close($ch);
        
        $data = json_decode($response, true);
        $count = count($data['items'] ?? []);
        
        if ($count >= 20) {
            return [
                'is_anomaly' => true,
                'message' => "直近7日間で{$count}件の申請があります(通常より多い)"
            ];
        }
        
        return ['is_anomaly' => false];
    }
}

function getReceiptImage($record) {
    $fileFieldValue = $record['receipt_image'] ?? null;
    if (empty($fileFieldValue)) return false;

    $recordId = $record['_id'] ?? '';
    if ($recordId === '') return false;

    $fileKey = extractFileKeyFromFileField($fileFieldValue);
    if ($fileKey && defined('RECEIPT_FILE_FIELD_ID') && RECEIPT_FILE_FIELD_ID !== '' && RECEIPT_FILE_FIELD_ID !== 'your_receipt_file_field_id') {
        $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/' . RECEIPT_FILE_FIELD_ID . '/' . $recordId . '/files/' . rawurlencode($fileKey) . '/download';
        $download = downloadBinary($url);
        if ($download !== false) {
            return $download;
        }
    }

    $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records/' . $recordId . '/fields/receipt_image/file';
    return downloadBinary($url);
}

function extractFileKeyFromFileField($fileFieldValue) {
    if (is_string($fileFieldValue)) {
        $v = trim($fileFieldValue);
        return $v !== '' ? $v : null;
    }

    if (!is_array($fileFieldValue)) return null;

    if (isset($fileFieldValue['fileKey'])) return (string)$fileFieldValue['fileKey'];
    if (isset($fileFieldValue['file_key'])) return (string)$fileFieldValue['file_key'];
    if (isset($fileFieldValue['key'])) return (string)$fileFieldValue['key'];
    if (isset($fileFieldValue['id'])) return (string)$fileFieldValue['id'];

    if (isset($fileFieldValue[0])) {
        $first = $fileFieldValue[0];
        if (is_string($first)) return trim($first) !== '' ? trim($first) : null;
        if (is_array($first)) {
            if (isset($first['fileKey'])) return (string)$first['fileKey'];
            if (isset($first['file_key'])) return (string)$first['file_key'];
            if (isset($first['key'])) return (string)$first['key'];
            if (isset($first['id'])) return (string)$first['id'];
        }
    }

    if (isset($fileFieldValue['files']) && is_array($fileFieldValue['files']) && isset($fileFieldValue['files'][0])) {
        $first = $fileFieldValue['files'][0];
        if (is_array($first)) {
            if (isset($first['fileKey'])) return (string)$first['fileKey'];
            if (isset($first['file_key'])) return (string)$first['file_key'];
            if (isset($first['key'])) return (string)$first['key'];
            if (isset($first['id'])) return (string)$first['id'];
        }
    }

    return null;
}

function downloadBinary($url) {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . SPIRAL_API_TOKEN,
            'X-Spiral-Api-Version: 1.1'
        ]
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
    curl_close($ch);

    if ($httpCode !== 200 || $response === false) return false;

    $mimeType = $contentType ?: '';
    $mimeType = strtolower(trim(explode(';', $mimeType)[0]));

    // Content-Typeが不明・汎用の場合、バイナリのマジックバイトからMIMEタイプを推定
    if ($mimeType === '' || $mimeType === 'application/octet-stream') {
        $mimeType = detectMimeTypeFromBinary($response);
    }

    return [
        'data' => base64_encode($response),
        'mime_type' => $mimeType
    ];
}

function detectMimeTypeFromBinary($binaryData) {
    if (strlen($binaryData) < 4) return 'application/octet-stream';

    $header = substr($binaryData, 0, 16);

    // JPEG: FF D8 FF
    if (substr($header, 0, 3) === "\xFF\xD8\xFF") return 'image/jpeg';

    // PNG: 89 50 4E 47
    if (substr($header, 0, 4) === "\x89PNG") return 'image/png';

    // GIF: GIF87a or GIF89a
    if (substr($header, 0, 3) === 'GIF') return 'image/gif';

    // BMP: BM
    if (substr($header, 0, 2) === 'BM') return 'image/bmp';

    // WebP: RIFF....WEBP
    if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') return 'image/webp';

    // TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian)
    if (substr($header, 0, 4) === "\x49\x49\x2A\x00" || substr($header, 0, 4) === "\x4D\x4D\x00\x2A") return 'image/tiff';

    // PDF: %PDF
    if (substr($header, 0, 4) === '%PDF') return 'application/pdf';

    return 'application/octet-stream';
}

function updateRecord($recordId, $data) {
    global $_lastUpdateResponse;
    $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records/' . $recordId;

    // SPIRAL APIは全フィールドの値を文字列で受け取る
    $stringData = [];
    foreach ($data as $key => $value) {
        if (is_null($value)) continue;
        $stringData[$key] = is_array($value) ? $value : (string)$value;
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST => 'PATCH',
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . SPIRAL_API_TOKEN,
            'Content-Type: application/json',
            'X-Spiral-Api-Version: 1.1'
        ],
        CURLOPT_POSTFIELDS => json_encode($stringData)
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $_lastUpdateResponse = [
        'http_code' => $httpCode,
        'body' => mb_substr((string)$response, 0, 1000),
        'url' => $url
    ];

    return $httpCode >= 200 && $httpCode < 300;
}

function getSelectOptionsFromDb($dbId, $fieldName) {
    $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . $dbId;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . SPIRAL_API_TOKEN,
            'X-Spiral-Api-Version: 1.1'
        ]
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200 || $response === false) return [];

    $data = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE) return [];

    $fields = $data['fields'] ?? ($data['data']['fields'] ?? ($data['data']['result']['fields'] ?? null));
    if (!is_array($fields)) return [];

    $targetField = null;
    foreach ($fields as $field) {
        $name = $field['name'] ?? ($field['identifier'] ?? '');
        if ($name === $fieldName) {
            $targetField = $field;
            break;
        }
    }

    if (!$targetField) return [];
    $options = $targetField['options'] ?? [];
    if (!is_array($options)) return [];

    $result = [];
    foreach ($options as $opt) {
        $id = $opt['value'] ?? ($opt['id'] ?? ($opt['name'] ?? ''));
        $label = $opt['label'] ?? ($opt['name'] ?? ($opt['value'] ?? ($opt['id'] ?? '')));
        $id = trim((string)$id);
        $label = trim((string)$label);
        if ($id === '' && $label === '') continue;
        if ($label === '') $label = $id;
        $result[] = ['id' => $id, 'label' => $label];
    }

    return $result;
}

function normalizeSelectValueToId($value, $options) {
    $value = trim((string)$value);
    if ($value === '') return '';

    if (!is_array($options) || count($options) === 0) return $value;

    foreach ($options as $opt) {
        $id = trim((string)($opt['id'] ?? ''));
        if ($id !== '' && $value === $id) return $id;
    }

    foreach ($options as $opt) {
        $id = trim((string)($opt['id'] ?? ''));
        $label = trim((string)($opt['label'] ?? ''));
        if ($id !== '' && $label !== '' && $value === $label) return $id;
    }

    return $value;
}

 function buildOptionListFromTriggerOptions($optionsMap) {
     if (!is_array($optionsMap) || count($optionsMap) === 0) return [];
 
     $list = [];
     foreach ($optionsMap as $id => $label) {
         $id = trim((string)$id);
         $label = trim((string)$label);
         if ($id === '' && $label === '') continue;
         if ($label === '') $label = $id;
         $list[] = ['id' => $id, 'label' => $label];
     }
 
     return $list;
 }

function main() {
    global $SPIRAL;

    $triggerData = null;
    if (isset($SPIRAL) && is_object($SPIRAL) && method_exists($SPIRAL, 'getRecord')) {
        $triggerData = $SPIRAL->getRecord();
    }
    if (!$triggerData) {
        return;
    }

    if (!$triggerData || (!isset($triggerData['item']) && !isset($triggerData['record']))) {
        return;
    }

    $record = $triggerData['item'] ?? $triggerData['record'];
    $recordId = $record['_id'] ?? '';
    $applicantId = $record['applicant_id'] ?? '';

    $triggerOptions = $triggerData['options'] ?? [];

    $expenseTypeOptions = buildOptionListFromTriggerOptions($triggerOptions['expense_type'] ?? []);
    $taxTypeOptions = buildOptionListFromTriggerOptions($triggerOptions['tax_type'] ?? []);
    $ocrStatusOptions = buildOptionListFromTriggerOptions($triggerOptions['ocr_status'] ?? []);
    $warningFlagOptions = buildOptionListFromTriggerOptions($triggerOptions['warning_flag'] ?? []);

    // 勘定科目フィールドのoptions取得(ACCOUNT_FIELD_NAMEが設定されている場合)
    $accountOptions = [];
    $accountLabelToIdMap = [];
    if (defined('ACCOUNT_FIELD_NAME') && ACCOUNT_FIELD_NAME !== '') {
        $accountOptions = buildOptionListFromTriggerOptions($triggerOptions[ACCOUNT_FIELD_NAME] ?? []);
        // ラベル→ID変換マップを作成
        foreach ($accountOptions as $opt) {
            $id = trim((string)($opt['id'] ?? ''));
            $label = trim((string)($opt['label'] ?? ''));
            if ($id !== '' && $label !== '') {
                $accountLabelToIdMap[$label] = $id;
            }
        }
    }

    // optionsが空の場合はAPIでフォールバック取得
    if (empty($expenseTypeOptions)) $expenseTypeOptions = getSelectOptionsFromDb(EXPENSE_DB_ID, 'expense_type');
    if (empty($taxTypeOptions)) $taxTypeOptions = getSelectOptionsFromDb(EXPENSE_DB_ID, 'tax_type');
    if (empty($ocrStatusOptions)) $ocrStatusOptions = getSelectOptionsFromDb(EXPENSE_DB_ID, 'ocr_status');
    if (empty($warningFlagOptions)) $warningFlagOptions = getSelectOptionsFromDb(EXPENSE_DB_ID, 'warning_flag');
    if (defined('ACCOUNT_FIELD_NAME') && ACCOUNT_FIELD_NAME !== '' && empty($accountOptions)) {
        $accountOptions = getSelectOptionsFromDb(EXPENSE_DB_ID, ACCOUNT_FIELD_NAME);
        foreach ($accountOptions as $opt) {
            $id = trim((string)($opt['id'] ?? ''));
            $label = trim((string)($opt['label'] ?? ''));
            if ($id !== '' && $label !== '') {
                $accountLabelToIdMap[$label] = $id;
            }
        }
    }

    updateRecord($recordId, ['ocr_status' => normalizeSelectValueToId('解析中', $ocrStatusOptions)]);

    $imageData = getReceiptImage($record);
    if ($imageData === false) {
        updateRecord($recordId, [
            'ocr_status' => normalizeSelectValueToId('失敗', $ocrStatusOptions),
            'ai_analysis' => json_encode([
                'error' => '画像取得に失敗',
                'debug_step' => 'getReceiptImage',
                'debug_detail' => '領収書ファイルのダウンロードに失敗しました。receipt_imageフィールド値=' . json_encode($record['receipt_image'] ?? null),
                'debug_record_id' => $recordId,
                'debug_at' => date('Y-m-d H:i:s')
            ], JSON_UNESCAPED_UNICODE)
        ]);
        return;
    }

    $analyzer = new ReceiptOcrAnalyzer(OPENAI_API_KEY);
    if (!empty($expenseTypeOptions)) {
        $analyzer->setExpenseTypeOptions($expenseTypeOptions);
    }

    $ocrResult = $analyzer->analyze($imageData['data'], $imageData['mime_type']);

    // OCR解析が失敗した場合(_debug_errorがある)、デバッグ情報を付けて失敗ステータスで更新
    if (isset($ocrResult['_debug_error'])) {
        updateRecord($recordId, [
            'ocr_status' => normalizeSelectValueToId('失敗', $ocrStatusOptions),
            'ai_analysis' => json_encode([
                'error' => 'OCR解析失敗',
                'debug_step' => 'ReceiptOcrAnalyzer.analyze',
                'debug_detail' => $ocrResult['_debug_error'],
                'debug_raw_response' => $ocrResult['_debug_raw_response'] ?? null,
                'debug_record_id' => $recordId,
                'debug_at' => date('Y-m-d H:i:s')
            ], JSON_UNESCAPED_UNICODE)
        ]);
        return;
    }

    $classifier = new ExpenseAccountClassifier();
    $classifier->setAccountLabelToIdMap($accountLabelToIdMap);
    $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'),
        $record['applicant_id'] ?? null,
        $recordId
    );

    $warningFlag = 'なし';
    if ($fraudResult['is_duplicate']) {
        $warningFlag = '重複疑い';
    } elseif ($fraudResult['is_amount_anomaly']) {
        $warningFlag = '金額異常';
    }

    $expenseTypeValue = $classification['expense_type'] ?? ($ocrResult['expense_type'] ?? 'その他');
    $expenseTypeValue = normalizeSelectValueToId($expenseTypeValue, $expenseTypeOptions);
    $taxTypeValue = normalizeSelectValueToId($ocrResult['tax_type'] ?? '', $taxTypeOptions);
    $warningFlagValue = normalizeSelectValueToId($warningFlag, $warningFlagOptions);
    $ocrStatusDoneValue = normalizeSelectValueToId('完了', $ocrStatusOptions);

    $accountValue = $classification['account_id'] ?? null;
    if (($accountValue === null || $accountValue === '') && isset($classification['account'])) {
        $accountValue = $accountLabelToIdMap[$classification['account']] ?? null;
        if ($accountValue === null || $accountValue === '') {
            $accountValue = (defined('EXPENSE_ACCOUNT_ID_MAP') && is_array(EXPENSE_ACCOUNT_ID_MAP)) ? (EXPENSE_ACCOUNT_ID_MAP[$classification['account']] ?? null) : null;
        }
    }

    $updateData = [
        'use_date' => $ocrResult['date'] ?? '',
        'store_name' => $ocrResult['store_name'] ?? '',
        'amount' => $ocrResult['amount'] ?? 0,
        'tax_amount' => $ocrResult['tax_amount'] ?? 0,
        'tax_type' => $taxTypeValue,
        'expense_type' => $expenseTypeValue,
        'invoice_no' => $ocrResult['invoice_no'] ?? '',
        'description' => $ocrResult['description'] ?? '',
        'confidence' => $ocrResult['confidence'] ?? 0,
        'warning_flag' => $warningFlagValue,
        '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' => $ocrStatusDoneValue
    ];

    if (defined('ACCOUNT_FIELD_NAME') && ACCOUNT_FIELD_NAME !== '' && $accountValue !== null && $accountValue !== '') {
        $updateData[ACCOUNT_FIELD_NAME] = (string)$accountValue;
    }

    $updateResult = updateRecord($recordId, $updateData);
    if (!$updateResult) {
        global $_lastUpdateResponse;
        updateRecord($recordId, [
            'ocr_status' => normalizeSelectValueToId('失敗', $ocrStatusOptions),
            'ai_analysis' => json_encode([
                'error' => 'レコード更新失敗',
                'debug_step' => 'updateRecord',
                'debug_detail' => 'OCR解析結果のレコード更新に失敗しました',
                'debug_api_response' => $_lastUpdateResponse ?? null,
                'debug_update_data_keys' => array_keys($updateData),
                'debug_update_data_values' => array_map(function($v) { return is_string($v) ? mb_substr($v, 0, 100) : $v; }, $updateData),
                'debug_record_id' => $recordId,
                'debug_at' => date('Y-m-d H:i:s')
            ], JSON_UNESCAPED_UNICODE)
        ]);
        return;
    }

}

main();
            

詳しく説明(分割コード)

統合版PHPの中身を機能ごとに分割したコードです。必要に応じてカスタマイズしてください。

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;
    }

    public function getLastError() {
        return $this->lastError;
    }

    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} - " . mb_substr((string)$response, 0, 500);
            return false;
        }
        
        $result = json_decode($response, true);
        if (!is_array($result)) {
            $this->lastError = "OpenAI APIレスポンスのJSONパース失敗: " . mb_substr((string)$response, 0, 500);
            return false;
        }
        if (isset($result['output_text']) && is_string($result['output_text'])) return $result['output_text'];
        $text = $this->extractOutputText($result);
        if ($text === '') {
            $this->lastError = "OpenAI APIレスポンスからテキスト抽出失敗: " . mb_substr(json_encode($result, JSON_UNESCAPED_UNICODE), 0, 500);
            return false;
        }
        return $text;
    }

    private function extractOutputText($responseJson) {
        $output = $responseJson['output'] ?? null;
        if (!is_array($output) || count($output) === 0) return '';
        foreach ($output as $item) {
            $content = $item['content'] ?? null;
            if (!is_array($content)) continue;
            foreach ($content as $part) {
                if (isset($part['text']) && is_string($part['text'])) {
                    return $part['text'];
                }
                if (isset($part['type']) && $part['type'] === 'output_text' && isset($part['text']) && is_string($part['text'])) {
                    return $part['text'];
                }
            }
        }
        return '';
    }

    private function parseResponse($response) {
        $cleaned = trim($response);
        // markdownコードブロック(```json ... ```)を除去
        if (preg_match('/^```(?:json)?\s*\n?(.*?)\n?```$/s', $cleaned, $m)) {
            $cleaned = trim($m[1]);
        }
        $data = json_decode($cleaned, true);
        
        if (json_last_error() !== JSON_ERROR_NONE) {
            return $this->getDefaultResult();
        }
        
        $validAccounts = ['旅費交通費', '交際費', '会議費', '消耗品費', '通信費', '研修費', '福利厚生費', '雑費'];
        $validTaxTypes = ['課税10%', '軽減8%', '非課税'];
        $expenseType = isset($data['expense_type']) ? trim((string)$data['expense_type']) : '';

        if ($expenseType !== '' && is_array($this->expenseTypeOptions) && count($this->expenseTypeOptions) > 0) {
            $normalized = $this->normalizeExpenseTypeToId($expenseType);
            if ($normalized !== null) {
                $expenseType = $normalized;
            }
        }
        
        return [
            'store_name' => $data['store_name'] ?? null,
            'store_address' => $data['store_address'] ?? null,
            'store_phone' => $data['store_phone'] ?? null,
            'date' => $this->validateDate($data['date'] ?? null),
            'amount' => max(0, intval($data['amount'] ?? 0)),
            'tax_amount' => max(0, intval($data['tax_amount'] ?? 0)),
            'subtotal' => max(0, intval($data['subtotal'] ?? 0)),
            'tax_rate' => in_array($data['tax_rate'] ?? 10, [8, 10]) ? $data['tax_rate'] : 10,
            'tax_type' => in_array($data['tax_type'] ?? '', $validTaxTypes) ? $data['tax_type'] : '課税10%',
            'invoice_no' => $this->validateInvoiceNo($data['invoice_no'] ?? null),
            'items' => $data['items'] ?? [],
            'payment_method' => $data['payment_method'] ?? null,
            'card_last4' => $data['card_last4'] ?? null,
            'expense_type' => $expenseType !== '' ? mb_substr($expenseType, 0, 50) : 'その他',
            'account' => in_array($data['account'] ?? '', $validAccounts) ? $data['account'] : '雑費',
            'description' => mb_substr($data['description'] ?? '', 0, 50),
            'confidence' => max(0, min(100, intval($data['confidence'] ?? 0))),
            'warnings' => $data['warnings'] ?? [],
            'receipt_type' => $data['receipt_type'] ?? 'レシート',
            'is_valid_receipt' => $data['is_valid_receipt'] ?? true,
            'raw_response' => $response
        ];
    }

    private function normalizeExpenseTypeToId($value) {
        $value = trim((string)$value);
        if ($value === '') return null;

        foreach ($this->expenseTypeOptions as $opt) {
            $id = isset($opt['id']) ? trim((string)$opt['id']) : '';
            if ($id !== '' && $value === $id) return $id;
        }

        foreach ($this->expenseTypeOptions as $opt) {
            $id = isset($opt['id']) ? trim((string)$opt['id']) : '';
            $label = isset($opt['label']) ? trim((string)$opt['label']) : '';
            if ($id !== '' && $label !== '' && $value === $label) return $id;
        }

        return null;
    }
    
    private function validateDate($date) {
        if (empty($date)) return date('Y-m-d');
        $timestamp = strtotime($date);
        return $timestamp ? date('Y-m-d', $timestamp) : date('Y-m-d');
    }
    
    private function validateInvoiceNo($invoiceNo) {
        if (empty($invoiceNo)) return null;
        return preg_match('/^T\d{13}$/', $invoiceNo) ? $invoiceNo : null;
    }
    
    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

class ExpenseAccountClassifier {

    private $accountMapping;
    private $amountRules;
    private $accountLabelToIdMap;

    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 : []);
        $this->accountLabelToIdMap = $config['accountLabelToIdMap'] ?? [];
    }

    public function setAccountLabelToIdMap($map) {
        $this->accountLabelToIdMap = $map;
    }

    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 = '交際費';
            }
        }

        // 勘定科目ラベル→IDの自動変換(optionsから取得したマップを使用)
        $accountId = null;
        if (!empty($this->accountLabelToIdMap) && isset($this->accountLabelToIdMap[$account])) {
            $accountId = $this->accountLabelToIdMap[$account];
        }
        // フォールバック: EXPENSE_ACCOUNT_ID_MAP(手動設定)
        if (($accountId === null || $accountId === '') && defined('EXPENSE_ACCOUNT_ID_MAP') && is_array(EXPENSE_ACCOUNT_ID_MAP)) {
            $accountId = EXPENSE_ACCOUNT_ID_MAP[$account] ?? null;
        }

        return [
            'expense_type' => $matchedCategory,
            'account' => $account,
            'account_id' => $accountId,
            '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, $applicantId, $excludeRecordId = 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;
        }
        
        // 金額異常チェック
        $amountResult = $this->checkAmountAnomaly($amount, 'その他');
        if ($amountResult['is_anomaly']) {
            $result['is_amount_anomaly'] = true;
            $result['warnings'][] = $amountResult['message'];
            $result['risk_score'] += 30;
        }
        
        // 頻度チェック(同一申請者の直近申請数)
        $frequencyResult = $this->checkFrequency($applicantId);
        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) {
        $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records';
        
        // SPIRAL API: whereパラメータで条件式を指定
        $escapedStoreName = str_replace("'", "''", $storeName);
        $where = "@store_name = '" . $escapedStoreName . "' AND @amount = " . intval($amount) . " AND @use_date = '" . $date . "'";
        $params = 'where=' . rawurlencode($where) . '&limit=10';
        
        $ch = curl_init($url . '?' . $params);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . SPIRAL_API_TOKEN,
                'X-Spiral-Api-Version: 1.1'
            ]
        ]);
        
        $response = curl_exec($ch);
        curl_close($ch);
        
        $data = json_decode($response, true);
        $records = $data['items'] ?? [];
        
        // 自分自身を除外
        if ($excludeRecordId !== null && $excludeRecordId !== '') {
            $excludeId = (string)$excludeRecordId;
            $records = array_filter($records, function($r) use ($excludeId) {
                return (string)($r['_id'] ?? '') !== $excludeId;
            });
        }
        
        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($applicantId) {
        if ($applicantId === null || $applicantId === '') {
            return ['is_anomaly' => false];
        }
        $url = SPIRAL_API_URL . '/apps/' . APP_ID . '/dbs/' . EXPENSE_DB_ID . '/records';
        
        $weekAgo = date('Y-m-d\TH:i:s\Z', strtotime('-7 days'));
        $escapedApplicantId = str_replace("'", "''", (string)$applicantId);
        $where = "@applicant_id = '" . $escapedApplicantId . "' AND @_createdAt >= '" . $weekAgo . "'";
        $params = 'where=' . rawurlencode($where) . '&limit=100';
        
        $ch = curl_init($url . '?' . $params);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . SPIRAL_API_TOKEN,
                'X-Spiral-Api-Version: 1.1'
            ]
        ]);
        
        $response = curl_exec($ch);
        curl_close($ch);
        
        $data = json_decode($response, true);
        $count = count($data['items'] ?? []);
        
        if ($count >= 20) {
            return [
                'is_anomaly' => true,
                'message' => "直近7日間で{$count}件の申請があります(通常より多い)"
            ];
        }
        
        return ['is_anomaly' => false];
    }
}
            

不正検知機能

重複申請チェック:同一店舗・同一金額・同一日付の申請を検出
金額異常チェック:経費区分ごとの上限を超える申請を検出
申請頻度チェック:短期間での大量申請を検出

コスト試算

月間処理枚数 推定APIコスト
100枚約500〜1,000円
500枚約2,500〜5,000円
1,000枚約5,000〜10,000円
※GPT-5.2の画像解析コスト。画像サイズにより変動します。

まとめ

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