diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 31a48df..a81b37c 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-07 01:10:55 +> **자동 생성**: 2026-02-07 09:56:46 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -499,8 +499,6 @@ ### orders - **item()**: belongsTo → `items` - **sale()**: belongsTo → `sales` - **items()**: hasMany → `order_items` -- **nodes()**: hasMany → `order_nodes` -- **rootNodes()**: hasMany → `order_nodes` - **histories()**: hasMany → `order_histories` - **versions()**: hasMany → `order_versions` - **workOrders()**: hasMany → `work_orders` @@ -516,7 +514,6 @@ ### order_items **모델**: `App\Models\Orders\OrderItem` - **order()**: belongsTo → `orders` -- **node()**: belongsTo → `order_nodes` - **item()**: belongsTo → `items` - **quote()**: belongsTo → `quotes` - **quoteItem()**: belongsTo → `quote_items` @@ -527,14 +524,6 @@ ### order_item_components - **orderItem()**: belongsTo → `order_items` -### order_nodes -**모델**: `App\Models\Orders\OrderNode` - -- **parent()**: belongsTo → `order_nodes` -- **order()**: belongsTo → `orders` -- **children()**: hasMany → `order_nodes` -- **items()**: hasMany → `order_items` - ### order_versions **모델**: `App\Models\Orders\OrderVersion` @@ -608,7 +597,6 @@ ### work_orders - **primaryAssignee()**: hasMany → `work_order_assignees` - **items()**: hasMany → `work_order_items` - **issues()**: hasMany → `work_order_issues` -- **stepProgress()**: hasMany → `work_order_step_progress` - **shipments()**: hasMany → `shipments` - **bendingDetail()**: hasOne → `work_order_bending_details` @@ -636,14 +624,6 @@ ### work_order_items - **workOrder()**: belongsTo → `work_orders` - **item()**: belongsTo → `items` -### work_order_step_progress -**모델**: `App\Models\Production\WorkOrderStepProgress` - -- **workOrder()**: belongsTo → `work_orders` -- **processStep()**: belongsTo → `process_steps` -- **workOrderItem()**: belongsTo → `work_order_items` -- **completedByUser()**: belongsTo → `users` - ### work_results **모델**: `App\Models\Production\WorkResult` @@ -777,6 +757,11 @@ ### ai_reports - **creator()**: belongsTo → `users` +### ai_token_usages +**모델**: `App\Models\Tenants\AiTokenUsage` + +- **creator()**: belongsTo → `users` + ### approvals **모델**: `App\Models\Tenants\Approval` diff --git a/app/Models/Tenants/AiPricingConfig.php b/app/Models/Tenants/AiPricingConfig.php new file mode 100644 index 0000000..e1fc250 --- /dev/null +++ b/app/Models/Tenants/AiPricingConfig.php @@ -0,0 +1,67 @@ + 'decimal:4', + 'output_price_per_million' => 'decimal:4', + 'unit_price' => 'decimal:6', + 'exchange_rate' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * 활성 단가 설정 조회 (캐시 적용) + */ + public static function getActivePricing(string $provider): ?self + { + return Cache::remember("ai_pricing_{$provider}", 3600, function () use ($provider) { + return self::where('provider', $provider) + ->where('is_active', true) + ->first(); + }); + } + + /** + * 환율 조회 (첫 번째 활성 레코드 기준) + */ + public static function getExchangeRate(): float + { + return Cache::remember('ai_pricing_exchange_rate', 3600, function () { + $config = self::where('is_active', true)->first(); + + return $config ? (float) $config->exchange_rate : 1400.0; + }); + } + + /** + * 캐시 초기화 + */ + public static function clearCache(): void + { + $providers = ['gemini', 'claude', 'google-stt', 'google-gcs']; + foreach ($providers as $provider) { + Cache::forget("ai_pricing_{$provider}"); + } + Cache::forget('ai_pricing_exchange_rate'); + } +} diff --git a/app/Models/Tenants/AiTokenUsage.php b/app/Models/Tenants/AiTokenUsage.php new file mode 100644 index 0000000..b3cf5e5 --- /dev/null +++ b/app/Models/Tenants/AiTokenUsage.php @@ -0,0 +1,37 @@ + 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + 'cost_usd' => 'decimal:6', + 'cost_krw' => 'decimal:2', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'created_by'); + } +} diff --git a/app/Models/Tenants/AiVoiceRecording.php b/app/Models/Tenants/AiVoiceRecording.php new file mode 100644 index 0000000..f2e385c --- /dev/null +++ b/app/Models/Tenants/AiVoiceRecording.php @@ -0,0 +1,46 @@ + 'integer', + 'file_expiry_date' => 'datetime', + ]; + + public const STATUS_PENDING = 'PENDING'; + + public const STATUS_PROCESSING = 'PROCESSING'; + + public const STATUS_COMPLETED = 'COMPLETED'; + + public const STATUS_FAILED = 'FAILED'; + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class); + } +} diff --git a/app/Services/AiReportService.php b/app/Services/AiReportService.php index 46a79c8..8a65a0e 100644 --- a/app/Services/AiReportService.php +++ b/app/Services/AiReportService.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Models\Tenants\AiPricingConfig; use App\Models\Tenants\AiReport; +use App\Models\Tenants\AiTokenUsage; use App\Models\Tenants\Card; use App\Models\Tenants\Deposit; use App\Models\Tenants\Purchase; @@ -13,6 +15,7 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class AiReportService extends Service { @@ -361,6 +364,9 @@ private function callGeminiApi(array $inputData): array $result = $response->json(); $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; + // 토큰 사용량 저장 + $this->saveTokenUsage($result, $model, 'AI리포트'); + // JSON 파싱 $parsed = json_decode($text, true); if (json_last_error() !== JSON_ERROR_NONE) { @@ -378,6 +384,47 @@ private function callGeminiApi(array $inputData): array } } + /** + * 토큰 사용량 저장 + */ + private function saveTokenUsage(array $apiResult, string $model, string $menuName): void + { + try { + $usage = $apiResult['usageMetadata'] ?? null; + if (! $usage) { + return; + } + + $promptTokens = $usage['promptTokenCount'] ?? 0; + $completionTokens = $usage['candidatesTokenCount'] ?? 0; + $totalTokens = $usage['totalTokenCount'] ?? 0; + + // DB 단가 조회 (fallback: 하드코딩 기본값) + $pricing = AiPricingConfig::getActivePricing('gemini'); + $inputPricePerToken = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000; + $outputPricePerToken = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000; + + $costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken); + $exchangeRate = AiPricingConfig::getExchangeRate(); + $costKrw = $costUsd * $exchangeRate; + + AiTokenUsage::create([ + 'tenant_id' => $this->tenantId(), + 'model' => $model, + 'menu_name' => $menuName, + 'prompt_tokens' => $promptTokens, + 'completion_tokens' => $completionTokens, + 'total_tokens' => $totalTokens, + 'cost_usd' => $costUsd, + 'cost_krw' => $costKrw, + 'request_id' => Str::uuid()->toString(), + 'created_by' => $this->apiUserId(), + ]); + } catch (\Exception $e) { + Log::warning('AI token usage save failed', ['error' => $e->getMessage()]); + } + } + /** * AI 프롬프트 생성 */ diff --git a/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php b/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php new file mode 100644 index 0000000..f509328 --- /dev/null +++ b/database/migrations/2026_02_07_100000_create_ai_token_usages_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + $table->string('model', 100)->comment('AI 모델명 (gemini-2.0-flash 등)'); + $table->string('menu_name', 100)->comment('호출 메뉴/기능명 (AI리포트, 명함OCR 등)'); + $table->unsignedInteger('prompt_tokens')->default(0)->comment('입력 토큰 수'); + $table->unsignedInteger('completion_tokens')->default(0)->comment('출력 토큰 수'); + $table->unsignedInteger('total_tokens')->default(0)->comment('전체 토큰 수'); + $table->decimal('cost_usd', 10, 6)->default(0)->comment('예상 비용 (USD)'); + $table->decimal('cost_krw', 12, 2)->default(0)->comment('예상 비용 (KRW)'); + $table->string('request_id', 100)->nullable()->comment('요청 추적 ID'); + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->timestamps(); + + $table->index(['tenant_id', 'created_at'], 'idx_ai_token_tenant_date'); + $table->index(['tenant_id', 'menu_name'], 'idx_ai_token_tenant_menu'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_token_usages'); + } +}; diff --git a/database/migrations/2026_02_07_200000_create_ai_voice_recordings_table.php b/database/migrations/2026_02_07_200000_create_ai_voice_recordings_table.php new file mode 100644 index 0000000..9e9075c --- /dev/null +++ b/database/migrations/2026_02_07_200000_create_ai_voice_recordings_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('작성자 ID'); + $table->string('title', 200)->comment('녹음 제목'); + $table->unsignedBigInteger('interview_template_id')->nullable()->comment('연결된 인터뷰 템플릿 ID'); + $table->string('audio_file_path', 500)->nullable()->comment('GCS 오브젝트 경로'); + $table->string('audio_gcs_uri', 500)->nullable()->comment('GCS URI (gs://...)'); + $table->longText('transcript_text')->nullable()->comment('STT 변환 텍스트'); + $table->longText('analysis_text')->nullable()->comment('Gemini AI 분석 결과'); + $table->string('status', 20)->default('PENDING')->comment('상태: PENDING, PROCESSING, COMPLETED, FAILED'); + $table->unsignedInteger('duration_seconds')->nullable()->comment('녹음 시간(초)'); + $table->timestamp('file_expiry_date')->nullable()->comment('파일 삭제 예정일'); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id'); + $table->index('user_id'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_voice_recordings'); + } +}; diff --git a/database/migrations/2026_02_09_100000_create_ai_pricing_configs_table.php b/database/migrations/2026_02_09_100000_create_ai_pricing_configs_table.php new file mode 100644 index 0000000..0b58226 --- /dev/null +++ b/database/migrations/2026_02_09_100000_create_ai_pricing_configs_table.php @@ -0,0 +1,97 @@ +id(); + $table->string('provider', 50)->comment('제공자 (gemini, claude, google-stt, google-gcs)'); + $table->string('model_name', 100)->comment('모델명 (gemini-2.0-flash, claude-3-haiku 등)'); + $table->decimal('input_price_per_million', 10, 4)->default(0)->comment('입력 토큰 백만개당 가격 (USD)'); + $table->decimal('output_price_per_million', 10, 4)->default(0)->comment('출력 토큰 백만개당 가격 (USD)'); + $table->decimal('unit_price', 10, 6)->default(0)->comment('단위 가격 (STT: 15초당, GCS: 1000건당)'); + $table->string('unit_description', 100)->nullable()->comment('단위 설명 (예: per 15 seconds)'); + $table->decimal('exchange_rate', 8, 2)->default(1400)->comment('USD→KRW 환율'); + $table->boolean('is_active')->default(true)->comment('활성 여부'); + $table->string('description', 255)->nullable()->comment('설명'); + $table->timestamps(); + + $table->unique('provider', 'uq_ai_pricing_provider'); + $table->index('is_active', 'idx_ai_pricing_active'); + }); + + // 기본 시드 데이터 삽입 + $now = now(); + DB::table('ai_pricing_configs')->insert([ + [ + 'provider' => 'gemini', + 'model_name' => 'gemini-2.0-flash', + 'input_price_per_million' => 0.1000, + 'output_price_per_million' => 0.4000, + 'unit_price' => 0, + 'unit_description' => null, + 'exchange_rate' => 1400, + 'is_active' => true, + 'description' => 'Gemini 2.0 Flash - 입력 $0.10/1M, 출력 $0.40/1M', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'claude', + 'model_name' => 'claude-3-haiku', + 'input_price_per_million' => 0.2500, + 'output_price_per_million' => 1.2500, + 'unit_price' => 0, + 'unit_description' => null, + 'exchange_rate' => 1400, + 'is_active' => true, + 'description' => 'Claude 3 Haiku - 입력 $0.25/1M, 출력 $1.25/1M', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'google-stt', + 'model_name' => 'latest_long', + 'input_price_per_million' => 0, + 'output_price_per_million' => 0, + 'unit_price' => 0.009000, + 'unit_description' => 'per 15 seconds', + 'exchange_rate' => 1400, + 'is_active' => true, + 'description' => 'Google Speech-to-Text - $0.009/15초', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'provider' => 'google-gcs', + 'model_name' => 'cloud-storage', + 'input_price_per_million' => 0, + 'output_price_per_million' => 0, + 'unit_price' => 0.005000, + 'unit_description' => 'per 1000 operations', + 'exchange_rate' => 1400, + 'is_active' => true, + 'description' => 'Google Cloud Storage - $0.005/1000건', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_pricing_configs'); + } +};