feat:신용평가 조회회수 집계 기능 추가

- CreditUsageController: 조회회수 집계 컨트롤러 신규 생성
- credit/usage/index.blade.php: 집계 화면 (월별/연간/기간별)
- 과금 정책: 월 5건 무료, 추가건당 2,000원
- 본사(tenant_id=1)는 전체 테넌트 조회 가능
- CreditInquiry 모델에 tenant_id 필드 추가
- 신용평가 조회 시 tenant_id 저장하도록 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-28 16:49:13 +09:00
parent 45aa0f9c72
commit 55d04537fc
5 changed files with 527 additions and 3 deletions

View File

@@ -95,12 +95,13 @@ public function search(Request $request): JsonResponse
$ntsService = new NtsBusinessService();
$ntsResult = $ntsService->getBusinessStatus($companyKey);
// DB에 저장
// DB에 저장 (tenant_id는 세션에서 가져옴)
$inquiry = CreditInquiry::createFromApiResponse(
$companyKey,
$apiResult,
$ntsResult,
auth()->id()
auth()->id(),
session('selected_tenant_id')
);
return response()->json([

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Http\Controllers\Credit;
use App\Http\Controllers\Controller;
use App\Models\Credit\CreditInquiry;
use App\Models\Tenants\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* 신용평가 조회회수 집계 컨트롤러
*/
class CreditUsageController extends Controller
{
// 과금 정책: 월 기본 무료 제공 건수
const FREE_MONTHLY_QUOTA = 5;
// 과금 정책: 추가 건당 요금 (원)
const ADDITIONAL_FEE_PER_INQUIRY = 2000;
/**
* 조회회수 집계 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('credit.usage.index'));
}
$user = auth()->user();
$selectedTenantId = session('selected_tenant_id');
$isHQ = $selectedTenantId == 1; // 본사(코드브릿지엑스)
// 기간 필터 (기본값: 현재 월)
$year = $request->input('year', date('Y'));
$month = $request->input('month', date('m'));
$viewType = $request->input('view_type', 'monthly'); // monthly, yearly, custom
// 기간 설정
if ($viewType === 'yearly') {
$startDate = "{$year}-01-01 00:00:00";
$endDate = "{$year}-12-31 23:59:59";
} elseif ($viewType === 'custom') {
$startDate = $request->input('start_date', date('Y-m-01')) . ' 00:00:00';
$endDate = $request->input('end_date', date('Y-m-t')) . ' 23:59:59';
} else {
$startDate = "{$year}-{$month}-01 00:00:00";
$endDate = date('Y-m-t 23:59:59', strtotime($startDate));
}
// 본사는 전체 테넌트 조회, 일반 테넌트는 자기 것만
if ($isHQ) {
$usageData = $this->getAllTenantsUsage($startDate, $endDate, $viewType, $year);
$tenants = Tenant::whereNull('deleted_at')
->orderBy('company_name')
->get(['id', 'company_name', 'code']);
} else {
$usageData = $this->getSingleTenantUsage($selectedTenantId, $startDate, $endDate, $viewType, $year);
$tenants = collect();
}
// 선택된 테넌트 필터
$filterTenantId = $request->input('tenant_id');
if ($isHQ && $filterTenantId) {
$usageData['details'] = collect($usageData['details'])->filter(function ($item) use ($filterTenantId) {
return $item['tenant_id'] == $filterTenantId;
})->values()->all();
}
return view('credit.usage.index', [
'isHQ' => $isHQ,
'usageData' => $usageData,
'tenants' => $tenants,
'filters' => [
'year' => $year,
'month' => $month,
'view_type' => $viewType,
'start_date' => substr($startDate, 0, 10),
'end_date' => substr($endDate, 0, 10),
'tenant_id' => $filterTenantId,
],
'policy' => [
'free_quota' => self::FREE_MONTHLY_QUOTA,
'additional_fee' => self::ADDITIONAL_FEE_PER_INQUIRY,
],
]);
}
/**
* 전체 테넌트 사용량 조회 (본사용)
*/
private function getAllTenantsUsage(string $startDate, string $endDate, string $viewType, string $year): array
{
// 테넌트별 조회 건수
$query = CreditInquiry::select(
'tenant_id',
DB::raw('COUNT(*) as total_count'),
DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month')
)
->whereBetween('inquired_at', [$startDate, $endDate])
->whereNotNull('tenant_id')
->groupBy('tenant_id', DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")'));
$rawData = $query->get();
// 테넌트 정보 조회
$tenantIds = $rawData->pluck('tenant_id')->unique();
$tenants = Tenant::whereIn('id', $tenantIds)->get()->keyBy('id');
// 월별로 그룹화하여 계산
$monthlyData = [];
foreach ($rawData as $row) {
$tenantId = $row->tenant_id;
$month = $row->month;
if (!isset($monthlyData[$tenantId])) {
$monthlyData[$tenantId] = [];
}
$monthlyData[$tenantId][$month] = $row->total_count;
}
// 결과 데이터 생성
$details = [];
$totalCount = 0;
$totalFee = 0;
foreach ($monthlyData as $tenantId => $months) {
$tenant = $tenants->get($tenantId);
$tenantTotalCount = 0;
$tenantTotalFee = 0;
foreach ($months as $month => $count) {
$fee = $this->calculateFee($count);
$tenantTotalCount += $count;
$tenantTotalFee += $fee;
if ($viewType === 'yearly') {
// 연간 조회 시 월별 상세 표시
$details[] = [
'tenant_id' => $tenantId,
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
'tenant_code' => $tenant?->code ?? '-',
'month' => $month,
'count' => $count,
'free_count' => min($count, self::FREE_MONTHLY_QUOTA),
'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA),
'fee' => $fee,
];
}
}
if ($viewType !== 'yearly') {
// 월간/기간 조회 시 테넌트별 합계만
$totalMonthCount = array_sum($months);
$fee = $this->calculateFee($totalMonthCount);
$details[] = [
'tenant_id' => $tenantId,
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
'tenant_code' => $tenant?->code ?? '-',
'count' => $totalMonthCount,
'free_count' => min($totalMonthCount, self::FREE_MONTHLY_QUOTA),
'paid_count' => max(0, $totalMonthCount - self::FREE_MONTHLY_QUOTA),
'fee' => $fee,
];
}
$totalCount += $tenantTotalCount;
$totalFee += $tenantTotalFee;
}
// 정렬: 조회 건수 내림차순
usort($details, fn($a, $b) => $b['count'] - $a['count']);
return [
'total_count' => $totalCount,
'total_fee' => $totalFee,
'details' => $details,
];
}
/**
* 단일 테넌트 사용량 조회
*/
private function getSingleTenantUsage(int $tenantId, string $startDate, string $endDate, string $viewType, string $year): array
{
$tenant = Tenant::find($tenantId);
// 월별 조회 건수
$query = CreditInquiry::select(
DB::raw('COUNT(*) as total_count'),
DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month')
)
->where('tenant_id', $tenantId)
->whereBetween('inquired_at', [$startDate, $endDate])
->groupBy(DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")'))
->orderBy('month');
$rawData = $query->get();
$details = [];
$totalCount = 0;
$totalFee = 0;
foreach ($rawData as $row) {
$count = $row->total_count;
$fee = $this->calculateFee($count);
$details[] = [
'tenant_id' => $tenantId,
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
'month' => $row->month,
'count' => $count,
'free_count' => min($count, self::FREE_MONTHLY_QUOTA),
'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA),
'fee' => $fee,
];
$totalCount += $count;
$totalFee += $fee;
}
// 연간 조회 시 없는 월도 표시
if ($viewType === 'yearly') {
$existingMonths = collect($details)->pluck('month')->toArray();
for ($m = 1; $m <= 12; $m++) {
$monthKey = sprintf('%s-%02d', $year, $m);
if (!in_array($monthKey, $existingMonths)) {
$details[] = [
'tenant_id' => $tenantId,
'tenant_name' => $tenant?->company_name ?? '(삭제됨)',
'month' => $monthKey,
'count' => 0,
'free_count' => 0,
'paid_count' => 0,
'fee' => 0,
];
}
}
// 월 순서로 정렬
usort($details, fn($a, $b) => strcmp($a['month'], $b['month']));
}
return [
'total_count' => $totalCount,
'total_fee' => $totalFee,
'details' => $details,
];
}
/**
* 요금 계산
*/
private function calculateFee(int $count): int
{
$paidCount = max(0, $count - self::FREE_MONTHLY_QUOTA);
return $paidCount * self::ADDITIONAL_FEE_PER_INQUIRY;
}
}

View File

@@ -12,6 +12,7 @@
class CreditInquiry extends Model
{
protected $fillable = [
'tenant_id',
'inquiry_key',
'company_key',
'company_name',
@@ -175,12 +176,14 @@ public function getNtsStatusLabelAttribute(): string
* @param array $apiResult 쿠콘 API 결과
* @param array|null $ntsResult 국세청 API 결과
* @param int|null $userId 조회자 ID
* @param int|null $tenantId 테넌트 ID
*/
public static function createFromApiResponse(
string $companyKey,
array $apiResult,
?array $ntsResult = null,
?int $userId = null
?int $userId = null,
?int $tenantId = null
): self {
// 요약 정보에서 건수 추출
$summaryData = $apiResult['summary']['data'] ?? [];
@@ -238,6 +241,7 @@ public static function createFromApiResponse(
};
return self::create([
'tenant_id' => $tenantId,
'company_key' => $companyKey,
'user_id' => $userId,
'inquired_at' => now(),

View File

@@ -0,0 +1,254 @@
@extends('layouts.app')
@section('title', '조회회수 집계')
@section('content')
<div class="max-w-7xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">신용평가 조회회수 집계</h1>
<p class="text-sm text-gray-500 mt-1">
@if($isHQ)
전체 테넌트의 신용평가 조회 현황을 확인합니다
@else
월별/기간별 신용평가 조회 현황과 요금을 확인합니다
@endif
</p>
</div>
<!-- 과금 정책 안내 -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-medium text-blue-800 mb-2 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
과금 정책
</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li> 기본 제공: <strong>{{ $policy['free_quota'] }}</strong> 무료</li>
<li>추가 조회: 건당 <strong>{{ number_format($policy['additional_fee']) }}</strong></li>
</ul>
</div>
<!-- 필터 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form method="GET" class="flex flex-wrap items-end gap-4">
<!-- 조회 유형 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">조회 유형</label>
<select name="view_type" onchange="toggleViewType(this.value)"
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="monthly" {{ $filters['view_type'] === 'monthly' ? 'selected' : '' }}>월별</option>
<option value="yearly" {{ $filters['view_type'] === 'yearly' ? 'selected' : '' }}>연간</option>
<option value="custom" {{ $filters['view_type'] === 'custom' ? 'selected' : '' }}>기간 지정</option>
</select>
</div>
<!-- 연도 선택 -->
<div id="year-filter">
<label class="block text-sm font-medium text-gray-700 mb-1">연도</label>
<select name="year" class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
@for($y = date('Y'); $y >= date('Y') - 2; $y--)
<option value="{{ $y }}" {{ $filters['year'] == $y ? 'selected' : '' }}>{{ $y }}</option>
@endfor
</select>
</div>
<!-- 선택 (월별 조회 ) -->
<div id="month-filter" class="{{ $filters['view_type'] !== 'monthly' ? 'hidden' : '' }}">
<label class="block text-sm font-medium text-gray-700 mb-1"></label>
<select name="month" class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
@for($m = 1; $m <= 12; $m++)
<option value="{{ str_pad($m, 2, '0', STR_PAD_LEFT) }}" {{ $filters['month'] == str_pad($m, 2, '0', STR_PAD_LEFT) ? 'selected' : '' }}>
{{ $m }}
</option>
@endfor
</select>
</div>
<!-- 기간 지정 (기간 지정 ) -->
<div id="custom-date-filter" class="{{ $filters['view_type'] !== 'custom' ? 'hidden' : '' }}">
<label class="block text-sm font-medium text-gray-700 mb-1">기간</label>
<div class="flex items-center gap-2">
<input type="date" name="start_date" value="{{ $filters['start_date'] }}"
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<span class="text-gray-500">~</span>
<input type="date" name="end_date" value="{{ $filters['end_date'] }}"
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
@if($isHQ && $tenants->isNotEmpty())
<!-- 테넌트 필터 (본사만) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">테넌트</label>
<select name="tenant_id" class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}" {{ $filters['tenant_id'] == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
</div>
@endif
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
조회
</button>
</form>
</div>
<!-- 요약 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500"> 조회 건수</p>
<p class="text-2xl font-bold text-gray-800">{{ number_format($usageData['total_count']) }}</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">유료 조회 건수</p>
<p class="text-2xl font-bold text-orange-600">
{{ number_format(max(0, $usageData['total_count'] - (count($usageData['details']) * $policy['free_quota']))) }}
</p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">예상 청구 금액</p>
<p class="text-2xl font-bold text-green-600">{{ number_format($usageData['total_fee']) }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
</div>
</div>
</div>
<!-- 상세 테이블 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">
@if($isHQ)
테넌트별 조회 현황
@else
월별 조회 현황
@endif
</h2>
</div>
@if(count($usageData['details']) > 0)
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
@if($isHQ)
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">테넌트</th>
@endif
@if($filters['view_type'] === 'yearly' || !$isHQ)
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">기간</th>
@endif
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> 조회</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">무료 ({{ $policy['free_quota'] }})</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">유료</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">요금</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($usageData['details'] as $row)
<tr class="hover:bg-gray-50">
@if($isHQ)
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $row['tenant_name'] }}</div>
@if(isset($row['tenant_code']))
<div class="text-xs text-gray-500">{{ $row['tenant_code'] }}</div>
@endif
</td>
@endif
@if($filters['view_type'] === 'yearly' || !$isHQ)
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $row['month'] ?? '-' }}
</td>
@endif
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
{{ number_format($row['count']) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-green-600">
{{ number_format($row['free_count']) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right {{ $row['paid_count'] > 0 ? 'text-orange-600 font-medium' : 'text-gray-500' }}">
{{ number_format($row['paid_count']) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right {{ $row['fee'] > 0 ? 'text-blue-600 font-medium' : 'text-gray-500' }}">
{{ number_format($row['fee']) }}
</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-gray-50">
<tr class="font-semibold">
<td class="px-6 py-4 text-sm text-gray-900" colspan="{{ $isHQ ? ($filters['view_type'] === 'yearly' ? 2 : 1) : 1 }}">
합계
</td>
<td class="px-6 py-4 text-sm text-right text-gray-900">{{ number_format($usageData['total_count']) }}</td>
<td class="px-6 py-4 text-sm text-right text-green-600">
{{ number_format(array_sum(array_column($usageData['details'], 'free_count'))) }}
</td>
<td class="px-6 py-4 text-sm text-right text-orange-600">
{{ number_format(array_sum(array_column($usageData['details'], 'paid_count'))) }}
</td>
<td class="px-6 py-4 text-sm text-right text-blue-600">{{ number_format($usageData['total_fee']) }}</td>
</tr>
</tfoot>
</table>
</div>
@else
<div class="px-6 py-12 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p>조회 내역이 없습니다.</p>
</div>
@endif
</div>
</div>
@push('scripts')
<script>
function toggleViewType(value) {
const monthFilter = document.getElementById('month-filter');
const customDateFilter = document.getElementById('custom-date-filter');
monthFilter.classList.add('hidden');
customDateFilter.classList.add('hidden');
if (value === 'monthly') {
monthFilter.classList.remove('hidden');
} else if (value === 'custom') {
customDateFilter.classList.remove('hidden');
}
}
</script>
@endpush
@endsection

View File

@@ -420,6 +420,9 @@
Route::get('/inquiry/{inquiryKey}/report', [\App\Http\Controllers\Credit\CreditController::class, 'getReportData'])->name('inquiry.report');
Route::delete('/inquiry/{id}', [\App\Http\Controllers\Credit\CreditController::class, 'deleteInquiry'])->name('inquiry.destroy');
// 조회회수 집계
Route::get('/usage', [\App\Http\Controllers\Credit\CreditUsageController::class, 'index'])->name('usage.index');
// 설정 관리
Route::get('/settings', [\App\Http\Controllers\Credit\CreditController::class, 'settings'])->name('settings.index');
Route::get('/settings/create', [\App\Http\Controllers\Credit\CreditController::class, 'createConfig'])->name('settings.create');