Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
@@ -255,6 +255,7 @@ public function sendToNts(Request $request): JsonResponse
|
||||
if ($result['success']) {
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-' . date('YmdHis');
|
||||
$data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
@@ -271,6 +272,7 @@ public function sendToNts(Request $request): JsonResponse
|
||||
// 시뮬레이션
|
||||
$data['invoices'][$invoiceIndex]['status'] = 'sent';
|
||||
$data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-SIM-' . date('YmdHis');
|
||||
$data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d');
|
||||
file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return response()->json([
|
||||
@@ -495,6 +497,7 @@ private function createInvoiceRecord(array $input, string $issueKey, $apiData):
|
||||
'status' => 'issued',
|
||||
'memo' => $input['memo'] ?? '',
|
||||
'createdAt' => date('Y-m-d\TH:i:s'),
|
||||
'sentAt' => date('Y-m-d'),
|
||||
'barobillInvoiceId' => is_numeric($apiData) ? (string)$apiData : '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -120,6 +120,11 @@ public function index(Request $request): View|Response
|
||||
|
||||
/**
|
||||
* 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList)
|
||||
*
|
||||
* 바로빌 API 참고:
|
||||
* - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원)
|
||||
* - DateType: 3(전송일자) 권장
|
||||
* - 전체 조회 시 1, 3을 각각 조회하여 합침
|
||||
*/
|
||||
public function sales(Request $request): JsonResponse
|
||||
{
|
||||
@@ -128,7 +133,7 @@ public function sales(Request $request): JsonResponse
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$page = (int)$request->input('page', 1);
|
||||
$limit = (int)$request->input('limit', 50);
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
@@ -150,63 +155,78 @@ public function sales(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
// CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $taxType,
|
||||
'DateType' => 1, // 1:작성일 기준
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침
|
||||
$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType];
|
||||
$allInvoices = [];
|
||||
$totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0];
|
||||
$lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0];
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
foreach ($taxTypesToQuery as $queryTaxType) {
|
||||
$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
}
|
||||
|
||||
$resultData = $result['data'];
|
||||
if (!$result['success']) {
|
||||
// 첫 번째 조회 실패 시 에러 반환
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
continue; // 이미 일부 데이터가 있으면 계속 진행
|
||||
}
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
$resultData = $result['data'];
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if ($errorCode && in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => [],
|
||||
'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
]);
|
||||
}
|
||||
// 에러 코드 체크 (데이터 없음 외의 에러)
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 파싱
|
||||
$parsed = $this->parseInvoices($resultData, 'sales');
|
||||
// 데이터가 있는 경우 파싱
|
||||
if (!$errorCode || !in_array($errorCode, [-60005, -60001])) {
|
||||
$parsed = $this->parseInvoices($resultData, 'sales');
|
||||
$allInvoices = array_merge($allInvoices, $parsed['invoices']);
|
||||
$totalSummary['totalAmount'] += $parsed['summary']['totalAmount'];
|
||||
$totalSummary['totalTax'] += $parsed['summary']['totalTax'];
|
||||
$totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']);
|
||||
$totalSummary['count'] += $parsed['summary']['count'];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $parsed['invoices'],
|
||||
'pagination' => [
|
||||
// 페이지네이션 정보 업데이트 (마지막 조회 결과 사용)
|
||||
$lastPagination = [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => $parsed['summary']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작성일 기준으로 정렬 (최신순)
|
||||
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $allInvoices,
|
||||
'pagination' => $lastPagination,
|
||||
'summary' => $totalSummary
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -220,6 +240,11 @@ public function sales(Request $request): JsonResponse
|
||||
|
||||
/**
|
||||
* 매입 세금계산서 목록 조회 (GetPeriodTaxInvoicePurchaseList)
|
||||
*
|
||||
* 바로빌 API 참고:
|
||||
* - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원)
|
||||
* - DateType: 3(전송일자) 권장
|
||||
* - 전체 조회 시 1, 3을 각각 조회하여 합침
|
||||
*/
|
||||
public function purchases(Request $request): JsonResponse
|
||||
{
|
||||
@@ -228,7 +253,7 @@ public function purchases(Request $request): JsonResponse
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$page = (int)$request->input('page', 1);
|
||||
$limit = (int)$request->input('limit', 50);
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세
|
||||
$taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세
|
||||
|
||||
// 현재 테넌트의 바로빌 회원 정보 조회
|
||||
$tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID);
|
||||
@@ -250,63 +275,78 @@ public function purchases(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [
|
||||
// CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $taxType,
|
||||
'DateType' => 1, // 1:작성일 기준
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침
|
||||
$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType];
|
||||
$allInvoices = [];
|
||||
$totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0];
|
||||
$lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0];
|
||||
|
||||
if (!$result['success']) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
foreach ($taxTypesToQuery as $queryTaxType) {
|
||||
$result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page
|
||||
]);
|
||||
}
|
||||
|
||||
$resultData = $result['data'];
|
||||
if (!$result['success']) {
|
||||
// 첫 번째 조회 실패 시 에러 반환
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
]);
|
||||
}
|
||||
continue; // 이미 일부 데이터가 있으면 계속 진행
|
||||
}
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
$resultData = $result['data'];
|
||||
$errorCode = $this->checkErrorCode($resultData);
|
||||
|
||||
// 데이터가 없는 경우
|
||||
if ($errorCode && in_array($errorCode, [-60005, -60001])) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => [],
|
||||
'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
]);
|
||||
}
|
||||
// 에러 코드 체크 (데이터 없음 외의 에러)
|
||||
if ($errorCode && !in_array($errorCode, [-60005, -60001])) {
|
||||
if (empty($allInvoices)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $this->getErrorMessage($errorCode),
|
||||
'error_code' => $errorCode
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 파싱
|
||||
$parsed = $this->parseInvoices($resultData, 'purchase');
|
||||
// 데이터가 있는 경우 파싱
|
||||
if (!$errorCode || !in_array($errorCode, [-60005, -60001])) {
|
||||
$parsed = $this->parseInvoices($resultData, 'purchase');
|
||||
$allInvoices = array_merge($allInvoices, $parsed['invoices']);
|
||||
$totalSummary['totalAmount'] += $parsed['summary']['totalAmount'];
|
||||
$totalSummary['totalTax'] += $parsed['summary']['totalTax'];
|
||||
$totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']);
|
||||
$totalSummary['count'] += $parsed['summary']['count'];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $parsed['invoices'],
|
||||
'pagination' => [
|
||||
// 페이지네이션 정보 업데이트 (마지막 조회 결과 사용)
|
||||
$lastPagination = [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => $parsed['summary']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 작성일 기준으로 정렬 (최신순)
|
||||
usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoices' => $allInvoices,
|
||||
'pagination' => $lastPagination,
|
||||
'summary' => $totalSummary
|
||||
]
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -482,10 +522,11 @@ public function diagnose(Request $request): JsonResponse
|
||||
];
|
||||
|
||||
// 테스트 2: 매출 세금계산서 조회 (기간: 최근 1개월)
|
||||
// TaxType: 1(과세+영세), 3(면세) 만 가능 / DateType: 3(전송일자) 권장
|
||||
$salesResult = $this->callSoap('GetPeriodTaxInvoiceSalesList', [
|
||||
'UserID' => $userId,
|
||||
'TaxType' => 0,
|
||||
'DateType' => 1,
|
||||
'TaxType' => 1, // 1: 과세+영세 (0은 미지원)
|
||||
'DateType' => 3, // 3: 전송일자 기준 (권장)
|
||||
'StartDate' => date('Ymd', strtotime('-1 month')),
|
||||
'EndDate' => date('Ymd'),
|
||||
'CountPerPage' => 1,
|
||||
@@ -661,8 +702,8 @@ private function getErrorMessage(int $errorCode): string
|
||||
{
|
||||
$messages = [
|
||||
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.',
|
||||
-10008 => '등록되지 않은 사용자입니다 (-10008). 바로빌에 등록된 사업자번호/사용자ID를 확인해주세요.',
|
||||
-11010 => '세금계산서 조회 권한이 없습니다 (-11010). 바로빌 사이트에서 서비스 권한을 확인해주세요.',
|
||||
-10008 => '날짜형식이 잘못되었습니다 (-10008). 날짜는 YYYYMMDD 형식(하이픈 제외)으로 입력해주세요.',
|
||||
-11010 => '과세형태(TaxType)가 잘못되었습니다 (-11010). TaxType은 1(과세+영세) 또는 3(면세)만 가능합니다.',
|
||||
-24005 => 'UserID가 필요합니다 (-24005). 바로빌 회원사 ID를 설정해주세요.',
|
||||
-24006 => '조회된 데이터가 없습니다 (-24006).',
|
||||
-25005 => '조회된 데이터가 없습니다 (-25005).',
|
||||
|
||||
@@ -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([
|
||||
|
||||
262
app/Http/Controllers/Credit/CreditUsageController.php
Normal file
262
app/Http/Controllers/Credit/CreditUsageController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
381
app/Http/Controllers/Finance/SalesCommissionController.php
Normal file
381
app/Http/Controllers/Finance/SalesCommissionController.php
Normal file
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesCommission;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Services\SalesCommissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업수수료 정산 컨트롤러
|
||||
*/
|
||||
class SalesCommissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SalesCommissionService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 정산 목록
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지로 리다이렉트 (JavaScript 필요)
|
||||
if ($request->header('HX-Request') && !$request->header('HX-Boosted')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.sales-commissions.index'));
|
||||
}
|
||||
|
||||
// 필터 파라미터
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
'search' => $request->input('search'),
|
||||
];
|
||||
|
||||
// 정산 목록
|
||||
$commissions = $this->service->getCommissions($filters);
|
||||
|
||||
// 통계
|
||||
$stats = $this->service->getSettlementStats($year, $month);
|
||||
|
||||
// 영업파트너 목록 (필터용)
|
||||
$partners = SalesPartner::with('user')
|
||||
->active()
|
||||
->orderBy('partner_code')
|
||||
->get();
|
||||
|
||||
// 입금 대기 테넌트 목록
|
||||
$pendingTenants = $this->service->getPendingPaymentTenants();
|
||||
|
||||
return view('finance.sales-commission.index', compact(
|
||||
'commissions',
|
||||
'stats',
|
||||
'partners',
|
||||
'pendingTenants',
|
||||
'year',
|
||||
'month',
|
||||
'filters'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$commission = $this->service->getCommissionById($id);
|
||||
|
||||
if (!$commission) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '정산 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $commission,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 상세 모달 (HTMX)
|
||||
*/
|
||||
public function detail(int $id): View
|
||||
{
|
||||
$commission = $this->service->getCommissionById($id);
|
||||
|
||||
return view('finance.sales-commission.partials.detail-modal', compact('commission'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 등록 (수당 생성)
|
||||
*/
|
||||
public function registerPayment(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'management_id' => 'required|integer|exists:sales_tenant_managements,id',
|
||||
'payment_type' => 'required|in:deposit,balance',
|
||||
'payment_amount' => 'required|numeric|min:0',
|
||||
'payment_date' => 'required|date',
|
||||
]);
|
||||
|
||||
try {
|
||||
$commission = $this->service->createCommission(
|
||||
$validated['management_id'],
|
||||
$validated['payment_type'],
|
||||
$validated['payment_amount'],
|
||||
$validated['payment_date']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '입금이 등록되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
public function approve(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$commission = $this->service->approve($id, auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 승인
|
||||
*/
|
||||
public function bulkApprove(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:sales_commissions,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->service->bulkApprove($validated['ids'], auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건이 승인되었습니다.",
|
||||
'count' => $count,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급완료 처리
|
||||
*/
|
||||
public function markPaid(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bank_reference' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$commission = $this->service->markAsPaid($id, $validated['bank_reference'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '지급완료 처리되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 지급완료
|
||||
*/
|
||||
public function bulkMarkPaid(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer|exists:sales_commissions,id',
|
||||
'bank_reference' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->service->bulkMarkAsPaid($validated['ids'], $validated['bank_reference'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건이 지급완료 처리되었습니다.",
|
||||
'count' => $count,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 처리
|
||||
*/
|
||||
public function cancel(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$commission = $this->service->cancel($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '취소되었습니다.',
|
||||
'data' => $commission,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정산 테이블 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function table(Request $request): View
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
'search' => $request->input('search'),
|
||||
];
|
||||
|
||||
$commissions = $this->service->getCommissions($filters);
|
||||
|
||||
return view('finance.sales-commission.partials.commission-table', compact('commissions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 카드 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function stats(Request $request): View
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$stats = $this->service->getSettlementStats($year, $month);
|
||||
|
||||
return view('finance.sales-commission.partials.stats-cards', compact('stats', 'year', 'month'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 등록 폼 (HTMX 모달)
|
||||
*/
|
||||
public function paymentForm(Request $request): View
|
||||
{
|
||||
$managementId = $request->input('management_id');
|
||||
$management = null;
|
||||
|
||||
if ($managementId) {
|
||||
$management = SalesTenantManagement::with(['tenant', 'salesPartner.user', 'contractProducts.product'])
|
||||
->find($managementId);
|
||||
}
|
||||
|
||||
// 입금 대기 테넌트 목록
|
||||
$pendingTenants = $this->service->getPendingPaymentTenants();
|
||||
|
||||
return view('finance.sales-commission.partials.payment-form', compact('management', 'pendingTenants'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$year = $request->input('year', now()->year);
|
||||
$month = $request->input('month', now()->month);
|
||||
|
||||
$filters = [
|
||||
'scheduled_year' => $year,
|
||||
'scheduled_month' => $month,
|
||||
'status' => $request->input('status'),
|
||||
'payment_type' => $request->input('payment_type'),
|
||||
'partner_id' => $request->input('partner_id'),
|
||||
];
|
||||
|
||||
// 전체 데이터 조회 (페이지네이션 없이)
|
||||
$commissions = SalesCommission::query()
|
||||
->with(['tenant', 'partner.user', 'manager'])
|
||||
->when(!empty($filters['status']), fn($q) => $q->where('status', $filters['status']))
|
||||
->when(!empty($filters['payment_type']), fn($q) => $q->where('payment_type', $filters['payment_type']))
|
||||
->when(!empty($filters['partner_id']), fn($q) => $q->where('partner_id', $filters['partner_id']))
|
||||
->forScheduledMonth($year, $month)
|
||||
->orderBy('scheduled_payment_date')
|
||||
->get();
|
||||
|
||||
// CSV 생성
|
||||
$filename = "sales_commission_{$year}_{$month}.csv";
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||||
];
|
||||
|
||||
$callback = function () use ($commissions) {
|
||||
$file = fopen('php://output', 'w');
|
||||
|
||||
// BOM for UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
// 헤더
|
||||
fputcsv($file, [
|
||||
'번호', '테넌트', '입금구분', '입금액', '입금일',
|
||||
'기준액', '영업파트너', '파트너수당', '매니저', '매니저수당',
|
||||
'지급예정일', '상태', '실제지급일'
|
||||
]);
|
||||
|
||||
// 데이터
|
||||
foreach ($commissions as $commission) {
|
||||
fputcsv($file, [
|
||||
$commission->id,
|
||||
$commission->tenant->name ?? $commission->tenant->company_name,
|
||||
$commission->payment_type_label,
|
||||
number_format($commission->payment_amount),
|
||||
$commission->payment_date->format('Y-m-d'),
|
||||
number_format($commission->base_amount),
|
||||
$commission->partner?->user?->name ?? '-',
|
||||
number_format($commission->partner_commission),
|
||||
$commission->manager?->name ?? '-',
|
||||
number_format($commission->manager_commission),
|
||||
$commission->scheduled_payment_date->format('Y-m-d'),
|
||||
$commission->status_label,
|
||||
$commission->actual_payment_date?->format('Y-m-d') ?? '-',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Lab;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* R&D Labs > A. AI/자동화 메뉴 컨트롤러
|
||||
*/
|
||||
class AIController extends Controller
|
||||
{
|
||||
// 웹 녹음 AI 요약
|
||||
public function webRecording(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지 리로드 (스크립트 로딩을 위해)
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.ai.web-recording'));
|
||||
}
|
||||
|
||||
return view('lab.ai.web-recording');
|
||||
}
|
||||
|
||||
// 회의록 AI 요약
|
||||
public function meetingSummary()
|
||||
{
|
||||
return view('lab.ai.meeting-summary');
|
||||
}
|
||||
|
||||
// 업무협의록 AI 요약
|
||||
public function workMemoSummary()
|
||||
{
|
||||
return view('lab.ai.work-memo-summary');
|
||||
}
|
||||
|
||||
// 운영자용 챗봇
|
||||
public function operatorChatbot()
|
||||
{
|
||||
return view('lab.ai.operator-chatbot');
|
||||
}
|
||||
|
||||
// Vertex RAG 챗봇
|
||||
public function vertexRag()
|
||||
{
|
||||
return view('lab.ai.vertex-rag');
|
||||
}
|
||||
|
||||
// 테넌트 지식 업로드
|
||||
public function tenantKnowledge()
|
||||
{
|
||||
return view('lab.ai.tenant-knowledge');
|
||||
}
|
||||
|
||||
// 테넌트 챗봇
|
||||
public function tenantChatbot()
|
||||
{
|
||||
return view('lab.ai.tenant-chatbot');
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,6 @@ private function handlePresentationPage(Request $request, string $routeName): ?R
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세무 전략 (장기적 세무전략 프레젠테이션)
|
||||
*/
|
||||
public function tax(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.strategy.tax'));
|
||||
}
|
||||
return view('lab.strategy.tax');
|
||||
}
|
||||
|
||||
/**
|
||||
* 노무 전략
|
||||
*/
|
||||
@@ -47,28 +36,6 @@ public function labor(Request $request): View|Response
|
||||
return view('lab.strategy.labor');
|
||||
}
|
||||
|
||||
/**
|
||||
* 채권추심 전략
|
||||
*/
|
||||
public function debt(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('lab.strategy.debt'));
|
||||
}
|
||||
return view('lab.strategy.debt');
|
||||
}
|
||||
|
||||
/**
|
||||
* MRP 해외사례 (presentation layout)
|
||||
*/
|
||||
public function mrpOverseas(Request $request): View|Response
|
||||
{
|
||||
if ($redirect = $this->handlePresentationPage($request, 'lab.strategy.mrp-overseas')) {
|
||||
return $redirect;
|
||||
}
|
||||
return view('lab.strategy.mrp-overseas');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담용 챗봇 전략
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Tenants\Tenant;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
|
||||
282
app/Http/Controllers/Sales/ConsultationController.php
Normal file
282
app/Http/Controllers/Sales/ConsultationController.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesConsultation;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 상담 기록 관리 컨트롤러
|
||||
*
|
||||
* 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다.
|
||||
* 데이터는 sales_consultations 테이블에 저장됩니다.
|
||||
*/
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상담 기록 목록 (HTMX 부분 뷰)
|
||||
*/
|
||||
public function index(int $tenantId, Request $request): View
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$scenarioType = $request->input('scenario_type', 'sales');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// DB에서 상담 기록 조회
|
||||
$consultations = SalesConsultation::getByTenantAndType($tenantId, $scenarioType, $stepId);
|
||||
|
||||
return view('sales.modals.consultation-log', [
|
||||
'tenant' => $tenant,
|
||||
'consultations' => $consultations,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $stepId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 상담 기록 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$consultation = SalesConsultation::createText(
|
||||
$request->input('tenant_id'),
|
||||
$request->input('scenario_type'),
|
||||
$request->input('step_id'),
|
||||
$request->input('content')
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'content' => $consultation->content,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 기록 삭제
|
||||
*/
|
||||
public function destroy(int $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
// 파일이 있으면 함께 삭제
|
||||
$consultation->deleteWithFile();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 업로드
|
||||
*
|
||||
* 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다.
|
||||
*/
|
||||
public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', // 50MB
|
||||
'transcript' => 'nullable|string|max:10000',
|
||||
'duration' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$transcript = $request->input('transcript');
|
||||
$duration = $request->input('duration');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('audio');
|
||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||
$localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
// 10MB 이상 파일은 GCS에도 업로드 (본사 연구용)
|
||||
$gcsUri = null;
|
||||
$maxLocalSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if ($fileSize > $maxLocalSize && $gcs->isAvailable()) {
|
||||
$gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}";
|
||||
$localFullPath = Storage::disk('local')->path($localPath);
|
||||
$gcsUri = $gcs->upload($localFullPath, $gcsObjectName);
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
$consultation = SalesConsultation::createAudio(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$localPath,
|
||||
$fileName,
|
||||
$fileSize,
|
||||
$transcript,
|
||||
$duration,
|
||||
$gcsUri
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'file_name' => $consultation->file_name,
|
||||
'transcript' => $consultation->transcript,
|
||||
'duration' => $consultation->duration,
|
||||
'formatted_duration' => $consultation->formatted_duration,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
'has_gcs' => !empty($gcsUri),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'file' => 'required|file|max:20480', // 20MB
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('file');
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName;
|
||||
$path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// DB에 저장
|
||||
$consultation = SalesConsultation::createFile(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$path,
|
||||
$originalName,
|
||||
$file->getSize(),
|
||||
$file->getMimeType()
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => [
|
||||
'id' => $consultation->id,
|
||||
'type' => $consultation->consultation_type,
|
||||
'file_name' => $consultation->file_name,
|
||||
'file_size' => $consultation->file_size,
|
||||
'formatted_file_size' => $consultation->formatted_file_size,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $fileId, Request $request): JsonResponse
|
||||
{
|
||||
return $this->destroy($fileId, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 파일 다운로드
|
||||
*/
|
||||
public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'audio') {
|
||||
abort(400, '오디오 파일이 아닙니다.');
|
||||
}
|
||||
|
||||
// GCS에 저장된 경우 서명된 URL로 리다이렉트
|
||||
if ($consultation->gcs_uri) {
|
||||
$objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri);
|
||||
$signedUrl = $gcs->getSignedUrl($objectName, 60);
|
||||
|
||||
if ($signedUrl) {
|
||||
return redirect()->away($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 파일 다운로드
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm';
|
||||
$mimeTypes = [
|
||||
'webm' => 'audio/webm',
|
||||
'wav' => 'audio/wav',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'ogg' => 'audio/ogg',
|
||||
'm4a' => 'audio/mp4'
|
||||
];
|
||||
$contentType = $mimeTypes[$extension] ?? 'audio/webm';
|
||||
|
||||
$downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension;
|
||||
|
||||
return response()->download($localPath, $downloadFileName, [
|
||||
'Content-Type' => $contentType,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $consultationId): BinaryFileResponse
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'file') {
|
||||
abort(400, '첨부파일이 아닙니다.');
|
||||
}
|
||||
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return response()->download($localPath, $consultation->file_name);
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/Sales/SalesContractController.php
Normal file
91
app/Http/Controllers/Sales/SalesContractController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesContractProduct;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 영업 계약관리 컨트롤러
|
||||
*/
|
||||
class SalesContractController extends Controller
|
||||
{
|
||||
/**
|
||||
* 계약 상품 저장 (전체 교체 방식)
|
||||
*/
|
||||
public function saveProducts(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
'products' => 'required|array',
|
||||
'products.*.product_id' => 'required|exists:sales_products,id',
|
||||
'products.*.category_id' => 'required|exists:sales_product_categories,id',
|
||||
'products.*.registration_fee' => 'required|numeric|min:0',
|
||||
'products.*.subscription_fee' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated) {
|
||||
$tenantId = $validated['tenant_id'];
|
||||
|
||||
// 영업관리 레코드 조회 (없으면 생성)
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 기존 상품 삭제
|
||||
SalesContractProduct::where('tenant_id', $tenantId)->delete();
|
||||
|
||||
// 새 상품 저장
|
||||
foreach ($validated['products'] as $product) {
|
||||
SalesContractProduct::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'management_id' => $management->id,
|
||||
'category_id' => $product['category_id'],
|
||||
'product_id' => $product['product_id'],
|
||||
'registration_fee' => $product['registration_fee'],
|
||||
'subscription_fee' => $product['subscription_fee'],
|
||||
'discount_rate' => 0,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계약 상품이 저장되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '저장 중 오류가 발생했습니다.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 상품 조회
|
||||
*/
|
||||
public function getProducts(int $tenantId): JsonResponse
|
||||
{
|
||||
$products = SalesContractProduct::where('tenant_id', $tenantId)
|
||||
->with(['product', 'category'])
|
||||
->get();
|
||||
|
||||
$totals = [
|
||||
'development_fee' => $products->sum('development_fee'),
|
||||
'subscription_fee' => $products->sum('subscription_fee'),
|
||||
'count' => $products->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'products' => $products,
|
||||
'totals' => $totals,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Models\Sales\TenantProspect;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\SalesCommissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -11,6 +18,10 @@
|
||||
*/
|
||||
class SalesDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SalesCommissionService $commissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 대시보드 화면
|
||||
*/
|
||||
@@ -18,6 +29,9 @@ public function index(Request $request): View
|
||||
{
|
||||
$data = $this->getDashboardData($request);
|
||||
|
||||
// 영업파트너 수당 정보 추가
|
||||
$data = array_merge($data, $this->getCommissionData());
|
||||
|
||||
return view('sales.dashboard.index', $data);
|
||||
}
|
||||
|
||||
@@ -93,11 +107,39 @@ private function getDashboardData(Request $request): array
|
||||
'confirmed_commission' => 0, // 확정 가입비 수당
|
||||
];
|
||||
|
||||
// 테넌트 목록 (가망고객에서 전환된 테넌트만)
|
||||
// 전환된 가망고객의 tenant_id 목록 조회
|
||||
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
|
||||
->where('status', TenantProspect::STATUS_CONVERTED)
|
||||
->pluck('tenant_id')
|
||||
->toArray();
|
||||
|
||||
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
|
||||
$tenants = Tenant::whereIn('id', $convertedTenantIds)
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 각 테넌트의 영업 관리 정보 로드
|
||||
$tenantIds = $tenants->pluck('id')->toArray();
|
||||
$managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds)
|
||||
->with('manager')
|
||||
->get()
|
||||
->keyBy('tenant_id');
|
||||
|
||||
// 내가 유치한 영업파트너 목록 (드롭다운용)
|
||||
$allManagers = auth()->user()->children()
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return compact(
|
||||
'stats',
|
||||
'commissionByRole',
|
||||
'totalCommissionRatio',
|
||||
'tenantStats',
|
||||
'tenants',
|
||||
'managements',
|
||||
'allManagers',
|
||||
'period',
|
||||
'year',
|
||||
'month',
|
||||
@@ -105,4 +147,123 @@ private function getDashboardData(Request $request): array
|
||||
'endDate'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 지정 변경
|
||||
*/
|
||||
public function assignManager(int $tenantId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'manager_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$managerId = $request->input('manager_id');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
if ($managerId === 0) {
|
||||
// 본인으로 설정 (현재 로그인 사용자)
|
||||
$manager = auth()->user();
|
||||
$management->update([
|
||||
'manager_user_id' => $manager->id,
|
||||
]);
|
||||
} else {
|
||||
// 특정 매니저 지정
|
||||
$manager = User::find($managerId);
|
||||
if (!$manager) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '매니저를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$management->update([
|
||||
'manager_user_id' => $manager->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'manager' => [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 리스트 부분 새로고침 (HTMX)
|
||||
*/
|
||||
public function refreshTenantList(Request $request): View
|
||||
{
|
||||
// 전환된 가망고객의 tenant_id 목록 조회
|
||||
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
|
||||
->where('status', TenantProspect::STATUS_CONVERTED)
|
||||
->pluck('tenant_id')
|
||||
->toArray();
|
||||
|
||||
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
|
||||
$tenants = Tenant::whereIn('id', $convertedTenantIds)
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
// 각 테넌트의 영업 관리 정보 로드
|
||||
$tenantIds = $tenants->pluck('id')->toArray();
|
||||
$managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds)
|
||||
->with('manager')
|
||||
->get()
|
||||
->keyBy('tenant_id');
|
||||
|
||||
// 내가 유치한 영업파트너 목록 (드롭다운용)
|
||||
$allManagers = auth()->user()->children()
|
||||
->where('is_active', true)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return view('sales.dashboard.partials.tenant-list', compact(
|
||||
'tenants',
|
||||
'managements',
|
||||
'allManagers'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 목록 조회 (드롭다운용)
|
||||
*/
|
||||
public function getManagers(Request $request): JsonResponse
|
||||
{
|
||||
// HQ 테넌트의 사용자 중 매니저 역할이 있는 사용자 조회
|
||||
$managers = User::whereHas('tenants', function ($query) {
|
||||
$query->where('tenant_type', 'HQ');
|
||||
})->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'managers' => $managers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업파트너 수당 정보 조회
|
||||
*/
|
||||
private function getCommissionData(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$commissionSummary = [];
|
||||
$recentCommissions = collect();
|
||||
|
||||
// 현재 사용자가 영업파트너인지 확인
|
||||
$partner = SalesPartner::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($partner) {
|
||||
$commissionSummary = $this->commissionService->getPartnerCommissionSummary($partner->id);
|
||||
$recentCommissions = $this->commissionService->getRecentCommissions($partner->id, 5);
|
||||
}
|
||||
|
||||
return compact('commissionSummary', 'recentCommissions', 'partner');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +73,9 @@ public function store(Request $request)
|
||||
'documents.*.description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// 등록자가 영업파트너인 경우 자동으로 추천인(parent)으로 설정
|
||||
// 본사 관리자가 등록하는 경우 parent_id는 null (최상위 파트너)
|
||||
$currentUser = auth()->user();
|
||||
$isSalesPartner = $currentUser->userRoles()
|
||||
->whereHas('role', fn($q) => $q->whereIn('name', ['sales', 'manager', 'recruiter']))
|
||||
->exists();
|
||||
|
||||
$validated['parent_id'] = $isSalesPartner ? $currentUser->id : null;
|
||||
// 등록자를 추천인(parent)으로 자동 설정
|
||||
// 본사 관리자가 등록해도 해당 관리자가 추천인이 됨
|
||||
$validated['parent_id'] = auth()->id();
|
||||
|
||||
// 문서 배열 구성
|
||||
$documents = [];
|
||||
@@ -122,6 +117,38 @@ public function show(int $id): View
|
||||
return view('sales.managers.show', compact('partner', 'level', 'children', 'delegationCandidates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 모달용
|
||||
*/
|
||||
public function modalShow(int $id): View
|
||||
{
|
||||
$partner = User::with(['parent', 'children', 'userRoles.role', 'salesDocuments', 'approver'])
|
||||
->findOrFail($id);
|
||||
|
||||
$level = $this->service->getPartnerLevel($partner);
|
||||
|
||||
$children = User::where('parent_id', $partner->id)
|
||||
->with('userRoles.role')
|
||||
->get();
|
||||
|
||||
return view('sales.managers.partials.show-modal', compact('partner', 'level', 'children'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 모달용
|
||||
*/
|
||||
public function modalEdit(int $id): View
|
||||
{
|
||||
$partner = User::with(['userRoles.role', 'salesDocuments', 'parent'])->findOrFail($id);
|
||||
$roles = $this->service->getSalesRoles();
|
||||
$currentRoleIds = $partner->userRoles->pluck('role_id')->toArray();
|
||||
$documentTypes = SalesManagerDocument::DOCUMENT_TYPES;
|
||||
|
||||
return view('sales.managers.partials.edit-modal', compact(
|
||||
'partner', 'roles', 'currentRoleIds', 'documentTypes'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 폼
|
||||
*/
|
||||
|
||||
279
app/Http/Controllers/Sales/SalesProductController.php
Normal file
279
app/Http/Controllers/Sales/SalesProductController.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesProduct;
|
||||
use App\Models\Sales\SalesProductCategory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 상품관리 컨트롤러 (HQ 전용)
|
||||
*/
|
||||
class SalesProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상품관리 메인 화면
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청인 경우 전체 페이지 리로드 (Alpine.js 스크립트 실행 필요)
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('sales.products.index'));
|
||||
}
|
||||
|
||||
$categories = SalesProductCategory::active()
|
||||
->ordered()
|
||||
->with(['products' => fn($q) => $q->ordered()])
|
||||
->get();
|
||||
|
||||
$currentCategoryCode = $request->input('category', $categories->first()?->code);
|
||||
$currentCategory = $categories->firstWhere('code', $currentCategoryCode) ?? $categories->first();
|
||||
|
||||
return view('sales.products.index', compact('categories', 'currentCategory'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 (HTMX용)
|
||||
*/
|
||||
public function productList(Request $request): View
|
||||
{
|
||||
$categoryCode = $request->input('category');
|
||||
$category = SalesProductCategory::where('code', $categoryCode)
|
||||
->with(['products' => fn($q) => $q->ordered()])
|
||||
->first();
|
||||
|
||||
return view('sales.products.partials.product-list', compact('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category_id' => 'required|exists:sales_product_categories,id',
|
||||
'code' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'development_fee' => 'required|numeric|min:0',
|
||||
'registration_fee' => 'required|numeric|min:0',
|
||||
'subscription_fee' => 'required|numeric|min:0',
|
||||
'partner_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'manager_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'allow_flexible_pricing' => 'boolean',
|
||||
'is_required' => 'boolean',
|
||||
]);
|
||||
|
||||
// 코드 중복 체크
|
||||
$exists = SalesProduct::where('category_id', $validated['category_id'])
|
||||
->where('code', $validated['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 존재하는 상품 코드입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 순서 설정 (마지막)
|
||||
$maxOrder = SalesProduct::where('category_id', $validated['category_id'])->max('display_order') ?? 0;
|
||||
$validated['display_order'] = $maxOrder + 1;
|
||||
$validated['partner_commission_rate'] = $validated['partner_commission_rate'] ?? 20.00;
|
||||
$validated['manager_commission_rate'] = $validated['manager_commission_rate'] ?? 5.00;
|
||||
|
||||
$product = SalesProduct::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 등록되었습니다.',
|
||||
'product' => $product,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'development_fee' => 'sometimes|numeric|min:0',
|
||||
'registration_fee' => 'sometimes|numeric|min:0',
|
||||
'subscription_fee' => 'sometimes|numeric|min:0',
|
||||
'partner_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'manager_commission_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'allow_flexible_pricing' => 'boolean',
|
||||
'is_required' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 수정되었습니다.',
|
||||
'product' => $product->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 삭제 (soft delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
$product->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상품이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 토글
|
||||
*/
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
$product = SalesProduct::findOrFail($id);
|
||||
$product->update(['is_active' => !$product->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $product->is_active ? '상품이 활성화되었습니다.' : '상품이 비활성화되었습니다.',
|
||||
'is_active' => $product->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'orders' => 'required|array',
|
||||
'orders.*.id' => 'required|exists:sales_products,id',
|
||||
'orders.*.order' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
foreach ($validated['orders'] as $item) {
|
||||
SalesProduct::where('id', $item['id'])->update(['display_order' => $item['order']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== 카테고리 관리 ====================
|
||||
|
||||
/**
|
||||
* 카테고리 목록
|
||||
*/
|
||||
public function categories(): JsonResponse
|
||||
{
|
||||
$categories = SalesProductCategory::ordered()->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
public function storeCategory(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:50|unique:sales_product_categories,code',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'base_storage' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$maxOrder = SalesProductCategory::max('display_order') ?? 0;
|
||||
$validated['display_order'] = $maxOrder + 1;
|
||||
|
||||
$category = SalesProductCategory::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 생성되었습니다.',
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
public function updateCategory(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$category = SalesProductCategory::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'base_storage' => 'nullable|string|max:20',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 수정되었습니다.',
|
||||
'category' => $category->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
public function deleteCategory(int $id): JsonResponse
|
||||
{
|
||||
$category = SalesProductCategory::findOrFail($id);
|
||||
|
||||
// 상품이 있으면 삭제 불가
|
||||
if ($category->products()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '상품이 있는 카테고리는 삭제할 수 없습니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== API (영업 시나리오용) ====================
|
||||
|
||||
/**
|
||||
* 상품 목록 API (카테고리 포함)
|
||||
*/
|
||||
public function getProductsApi(): JsonResponse
|
||||
{
|
||||
$categories = SalesProductCategory::active()
|
||||
->ordered()
|
||||
->with(['products' => fn($q) => $q->active()->ordered()])
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
167
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesScenarioChecklist;
|
||||
use App\Models\Sales\SalesTenantManagement;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 관리 컨트롤러
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다.
|
||||
* 데이터는 sales_scenario_checklists 테이블에 저장됩니다.
|
||||
*/
|
||||
class SalesScenarioController extends Controller
|
||||
{
|
||||
/**
|
||||
* 영업 시나리오 모달 뷰
|
||||
*/
|
||||
public function salesScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.sales_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, 'sales', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('sales', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 시나리오 모달 뷰
|
||||
*/
|
||||
public function managerScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.manager_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 테넌트 영업 관리 정보 조회 또는 생성
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
|
||||
// 체크리스트 진행 상태 조회 (DB에서)
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, 'manager', $steps);
|
||||
|
||||
// 진행률 업데이트
|
||||
$management->updateProgress('manager', $progress['percentage']);
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
'management' => $management,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 토글 (HTMX)
|
||||
*/
|
||||
public function toggleChecklist(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'required|integer',
|
||||
'checkpoint_id' => 'required|string',
|
||||
'checked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$checkpointId = $request->input('checkpoint_id');
|
||||
$checked = $request->boolean('checked');
|
||||
|
||||
// 체크리스트 토글 (DB에 저장)
|
||||
SalesScenarioChecklist::toggle(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$checkpointId,
|
||||
$checked,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 진행률 재계산
|
||||
$steps = config("sales_scenario.{$scenarioType}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, $scenarioType, $steps);
|
||||
|
||||
// 테넌트 영업 관리 정보 업데이트
|
||||
$management = SalesTenantManagement::findOrCreateByTenant($tenantId);
|
||||
$management->updateProgress($scenarioType, $progress['percentage']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 조회
|
||||
*/
|
||||
public function getProgress(int $tenantId, string $type): JsonResponse
|
||||
{
|
||||
$steps = config("sales_scenario.{$type}_steps");
|
||||
$progress = SalesScenarioChecklist::calculateProgress($tenantId, $type, $steps);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public function store(Request $request)
|
||||
'contact_email' => 'nullable|email|max:100',
|
||||
'address' => 'nullable|string|max:500',
|
||||
'business_card' => 'nullable|image|max:5120',
|
||||
'business_card_image_data' => 'nullable|string',
|
||||
'memo' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -79,9 +80,14 @@ public function store(Request $request)
|
||||
// 등록자는 현재 로그인 사용자
|
||||
$validated['registered_by'] = auth()->id();
|
||||
|
||||
// Base64 이미지 데이터가 있으면 전달
|
||||
$businessCardBase64 = $validated['business_card_image_data'] ?? null;
|
||||
unset($validated['business_card_image_data']);
|
||||
|
||||
$this->service->register(
|
||||
$validated,
|
||||
$request->file('business_card')
|
||||
$request->file('business_card'),
|
||||
$businessCardBase64
|
||||
);
|
||||
|
||||
return redirect()->route('sales.prospects.index')
|
||||
@@ -214,6 +220,27 @@ public function checkBusinessNumber(Request $request)
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달용 상세 정보
|
||||
*/
|
||||
public function modalShow(int $id): View
|
||||
{
|
||||
$prospect = TenantProspect::with(['registeredBy', 'tenant', 'convertedBy'])
|
||||
->findOrFail($id);
|
||||
|
||||
return view('sales.prospects.partials.show-modal', compact('prospect'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달용 수정 폼
|
||||
*/
|
||||
public function modalEdit(int $id): View
|
||||
{
|
||||
$prospect = TenantProspect::findOrFail($id);
|
||||
|
||||
return view('sales.prospects.partials.edit-modal', compact('prospect'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 이미지 삭제 (AJAX)
|
||||
*/
|
||||
|
||||
@@ -20,12 +20,25 @@ public function index(Request $request): View|Response
|
||||
return response('', 200)->header('HX-Redirect', route('system.ai-config.index'));
|
||||
}
|
||||
|
||||
$configs = AiConfig::orderBy('provider')
|
||||
// AI 설정 (gemini, claude, openai)
|
||||
$aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS)
|
||||
->orderBy('provider')
|
||||
->orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('system.ai-config.index', compact('configs'));
|
||||
// 스토리지 설정 (gcs)
|
||||
$storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS)
|
||||
->orderBy('provider')
|
||||
->orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('system.ai-config.index', [
|
||||
'configs' => $aiConfigs,
|
||||
'aiConfigs' => $aiConfigs,
|
||||
'storageConfigs' => $storageConfigs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,26 +48,40 @@ public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
||||
'api_key' => 'nullable|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'nullable|array',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account',
|
||||
'options.project_id' => 'nullable|string|max:100',
|
||||
'options.region' => 'nullable|string|max:50',
|
||||
'options.service_account_path' => 'nullable|string|max:500',
|
||||
'options.bucket_name' => 'nullable|string|max:200',
|
||||
'options.service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
// GCS의 경우 별도 검증
|
||||
if ($validated['provider'] === 'gcs') {
|
||||
if (empty($validated['options']['bucket_name'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '버킷 이름을 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
$validated['model'] = '-'; // GCS는 모델 불필요
|
||||
$validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약
|
||||
} else {
|
||||
// AI 설정: Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
@@ -81,26 +108,40 @@ public function update(Request $request, int $id): JsonResponse
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'provider' => 'required|string|in:gemini,claude,openai,gcs',
|
||||
'api_key' => 'nullable|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'model' => 'nullable|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'nullable|array',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account',
|
||||
'options.project_id' => 'nullable|string|max:100',
|
||||
'options.region' => 'nullable|string|max:50',
|
||||
'options.service_account_path' => 'nullable|string|max:500',
|
||||
'options.bucket_name' => 'nullable|string|max:200',
|
||||
'options.service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
// GCS의 경우 별도 검증
|
||||
if ($validated['provider'] === 'gcs') {
|
||||
if (empty($validated['options']['bucket_name'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => '버킷 이름을 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
$validated['model'] = '-';
|
||||
$validated['api_key'] = 'gcs_service_account';
|
||||
} else {
|
||||
// AI 설정: Vertex AI가 아닌 경우 API 키 필수
|
||||
$authType = $validated['options']['auth_type'] ?? 'api_key';
|
||||
if ($authType !== 'vertex_ai' && empty($validated['api_key'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'API 키를 입력해주세요.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
@@ -163,19 +204,35 @@ public function test(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'api_key' => 'required|string',
|
||||
'api_key' => 'nullable|string',
|
||||
'model' => 'required|string',
|
||||
'base_url' => 'nullable|string',
|
||||
'auth_type' => 'nullable|string|in:api_key,vertex_ai',
|
||||
'project_id' => 'nullable|string',
|
||||
'region' => 'nullable|string',
|
||||
'service_account_path' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$provider = $validated['provider'];
|
||||
$apiKey = $validated['api_key'];
|
||||
$model = $validated['model'];
|
||||
$baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider];
|
||||
$authType = $validated['auth_type'] ?? 'api_key';
|
||||
|
||||
if ($provider === 'gemini') {
|
||||
$result = $this->testGemini($baseUrl, $model, $apiKey);
|
||||
if ($authType === 'vertex_ai') {
|
||||
// Vertex AI (서비스 계정) 방식
|
||||
$result = $this->testGeminiVertexAi(
|
||||
$model,
|
||||
$validated['project_id'] ?? '',
|
||||
$validated['region'] ?? 'us-central1',
|
||||
$validated['service_account_path'] ?? ''
|
||||
);
|
||||
} else {
|
||||
// API 키 방식
|
||||
$apiKey = $validated['api_key'] ?? '';
|
||||
$baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider];
|
||||
$result = $this->testGemini($baseUrl, $model, $apiKey);
|
||||
}
|
||||
} else {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
@@ -193,7 +250,7 @@ public function test(Request $request): JsonResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 테스트
|
||||
* Gemini API 테스트 (API 키 방식)
|
||||
*/
|
||||
private function testGemini(string $baseUrl, string $model, string $apiKey): array
|
||||
{
|
||||
@@ -225,4 +282,223 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr
|
||||
'error' => 'API 응답 오류: ' . $response->status(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 테스트 (Vertex AI 방식)
|
||||
*/
|
||||
private function testGeminiVertexAi(string $model, string $projectId, string $region, string $serviceAccountPath): array
|
||||
{
|
||||
// 필수 파라미터 검증
|
||||
if (empty($projectId)) {
|
||||
return ['ok' => false, 'error' => '프로젝트 ID가 필요합니다.'];
|
||||
}
|
||||
|
||||
if (empty($serviceAccountPath)) {
|
||||
return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.'];
|
||||
}
|
||||
|
||||
if (!file_exists($serviceAccountPath)) {
|
||||
return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"];
|
||||
}
|
||||
|
||||
// 서비스 계정 JSON 로드
|
||||
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) {
|
||||
return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.'];
|
||||
}
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getVertexAiAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.'];
|
||||
}
|
||||
|
||||
// Vertex AI 엔드포인트 URL 구성
|
||||
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(30)
|
||||
->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->post($url, [
|
||||
'contents' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'parts' => [
|
||||
['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Vertex AI 연결 테스트 성공',
|
||||
];
|
||||
}
|
||||
|
||||
// 상세 오류 메시지 추출
|
||||
$errorBody = $response->json();
|
||||
$errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status());
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => "Vertex AI 오류: {$errorMsg}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI OAuth 토큰 획득
|
||||
*/
|
||||
private function getVertexAiAccessToken(array $serviceAccount): ?string
|
||||
{
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 연결 테스트
|
||||
*/
|
||||
public function testGcs(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bucket_name' => 'required|string',
|
||||
'service_account_path' => 'nullable|string',
|
||||
'service_account_json' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$bucketName = $validated['bucket_name'];
|
||||
$serviceAccount = null;
|
||||
|
||||
// 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로)
|
||||
if (!empty($validated['service_account_json'])) {
|
||||
$serviceAccount = $validated['service_account_json'];
|
||||
} elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) {
|
||||
$serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true);
|
||||
}
|
||||
|
||||
if (!$serviceAccount) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '서비스 계정 정보를 찾을 수 없습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// OAuth 토큰 획득
|
||||
$accessToken = $this->getGcsAccessToken($serviceAccount);
|
||||
if (!$accessToken) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'OAuth 토큰 획득 실패',
|
||||
]);
|
||||
}
|
||||
|
||||
// 버킷 존재 확인
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(10)
|
||||
->withHeaders(['Authorization' => 'Bearer ' . $accessToken])
|
||||
->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "GCS 연결 성공! 버킷: {$bucketName}",
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '버킷 접근 실패: ' . $response->status(),
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS OAuth 토큰 획득
|
||||
*/
|
||||
private function getGcsAccessToken(array $serviceAccount): ?string
|
||||
{
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL 인코딩
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user