Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-01-30 13:51:49 +09:00
97 changed files with 13237 additions and 4176 deletions

View File

@@ -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 : '',
];
}

View File

@@ -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).',

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

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

View File

@@ -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');
}
}

View File

@@ -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');
}
/**
* 상담용 챗봇 전략
*/

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\Tenants\Tenant;
class PermissionController extends Controller
{

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

View 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,
],
]);
}
}

View File

@@ -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');
}
}

View File

@@ -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'
));
}
/**
* 수정 폼
*/

View 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,
]);
}
}

View 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,
]);
}
}

View File

@@ -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)
*/

View File

@@ -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), '+/', '-_'), '=');
}
}