経費精算で領収書やレシートの情報を手入力していませんか?
本記事では、OpenAI GPT-5.2 APIを活用して領収書・レシートのOCR解析と勘定科目の自動分類を実現する方法を紹介します。
不正検知も実装し、
より堅牢な経費管理システムを構築できます。
注意点
・ 画像解析のAPIコストは通常のテキストより高めです
・ 手書きの領収書は認識精度が下がる場合があります
・ 最終的な経費承認は人間が確認してください
・ SPIRAL ver.2.18以降のPHP機能が必要です
実装の概要
今回のコードでは、以下の流れで処理を行います。
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円 |
まとめ
ver.2ではAPIを活用した不正検知や承認ワークフローとの連携も実現し、より堅牢な経費管理システムを構築できます。
不具合やご質問がある場合は、下記の「コンテンツに関しての要望はこちら」からご連絡ください。