- Gemini API 연동: 프롬프트 → 음표 시퀀스 JSON 자동 생성 - AI 탭 UI: 프롬프트 입력, 카테고리/길이 선택, 빠른 프롬프트 10종 - AI 결과 미리보기: 음표 시각화, 미리듣기, 시퀀서 로드 - POST /rd/sound-logo/generate 엔드포인트 추가
456 lines
14 KiB
PHP
456 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\HR\Employee;
|
|
use App\Models\Rd\AiQuotation;
|
|
use App\Models\Tenants\Department;
|
|
use App\Models\Tenants\Tenant;
|
|
use App\Services\Rd\AiQuotationService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\View\View;
|
|
|
|
class RdController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly AiQuotationService $quotationService
|
|
) {}
|
|
|
|
/**
|
|
* R&D 대시보드
|
|
*/
|
|
public function index(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.index'));
|
|
}
|
|
|
|
$dashboard = $this->quotationService->getDashboardStats();
|
|
$statuses = AiQuotation::getStatuses();
|
|
|
|
return view('rd.index', compact('dashboard', 'statuses'));
|
|
}
|
|
|
|
/**
|
|
* 조직도 관리
|
|
*/
|
|
public function orgChart(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.org-chart'));
|
|
}
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
// 부서 트리 (parent_id=null이 최상위)
|
|
$departments = Department::where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// 전체 직원 (활성 상태)
|
|
$rawEmployees = Employee::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('employee_status', 'active')
|
|
->with(['user', 'department'])
|
|
->orderBy('display_name')
|
|
->get();
|
|
|
|
// Blade @json 호환을 위해 미리 배열로 변환
|
|
$employees = $rawEmployees->map(function ($e) {
|
|
return [
|
|
'id' => $e->id,
|
|
'user_id' => $e->user_id,
|
|
'department_id' => $e->department_id,
|
|
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
|
|
'position_label' => $e->position_label,
|
|
];
|
|
})->values();
|
|
|
|
// 회사 정보 (조직도 최상단)
|
|
$tenant = Tenant::find($tenantId);
|
|
$companyName = $tenant->company_name ?? 'SAM';
|
|
$ceoName = $tenant->ceo_name ?? '';
|
|
|
|
return view('rd.org-chart', compact('departments', 'employees', 'companyName', 'ceoName'));
|
|
}
|
|
|
|
/**
|
|
* 조직도 - 직원 부서 배치
|
|
*/
|
|
public function orgChartAssign(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'employee_id' => 'required|integer',
|
|
'department_id' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
$employee = Employee::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $request->employee_id)
|
|
->first();
|
|
|
|
if (! $employee) {
|
|
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
$employee->department_id = $request->department_id;
|
|
$employee->save();
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 조직도 - 직원 부서 해제 (미배치로 이동)
|
|
*/
|
|
public function orgChartUnassign(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'employee_id' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
$employee = Employee::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $request->employee_id)
|
|
->first();
|
|
|
|
if (! $employee) {
|
|
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
$employee->department_id = null;
|
|
$employee->save();
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 조직도 - 부서 내 직원 순서/이동 일괄 처리
|
|
*/
|
|
public function orgChartReorder(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'moves' => 'required|array',
|
|
'moves.*.employee_id' => 'required|integer',
|
|
'moves.*.department_id' => 'nullable|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
foreach ($request->moves as $move) {
|
|
Employee::withoutGlobalScopes()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $move['employee_id'])
|
|
->update(['department_id' => $move['department_id']]);
|
|
}
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 조직도 - 부서 순서 변경 (드래그 앤 드롭)
|
|
*/
|
|
public function orgChartReorderDepts(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'orders' => 'required|array',
|
|
'orders.*.id' => 'required|integer',
|
|
'orders.*.parent_id' => 'nullable|integer',
|
|
'orders.*.sort_order' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
foreach ($request->orders as $order) {
|
|
Department::where('tenant_id', $tenantId)
|
|
->where('id', $order['id'])
|
|
->update([
|
|
'parent_id' => $order['parent_id'],
|
|
'sort_order' => $order['sort_order'],
|
|
]);
|
|
}
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 조직도 - 부서 숨기기/표시 토글
|
|
*/
|
|
public function orgChartToggleHide(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'department_id' => 'required|integer',
|
|
'hidden' => 'required|boolean',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id');
|
|
$dept = Department::where('tenant_id', $tenantId)
|
|
->where('id', $request->department_id)
|
|
->first();
|
|
|
|
if (! $dept) {
|
|
return response()->json(['success' => false, 'message' => '부서를 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
$options = $dept->options ?? [];
|
|
$options['orgchart_hidden'] = $request->hidden;
|
|
$dept->options = $options;
|
|
$dept->save();
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 중대재해처벌법 실무 점검
|
|
*/
|
|
public function safetyAudit(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.safety-audit'));
|
|
}
|
|
|
|
return view('rd.safety-audit');
|
|
}
|
|
|
|
/**
|
|
* AI 견적 목록
|
|
*/
|
|
public function quotations(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.index'));
|
|
}
|
|
|
|
$statuses = AiQuotation::getStatuses();
|
|
|
|
return view('rd.ai-quotation.index', compact('statuses'));
|
|
}
|
|
|
|
/**
|
|
* AI 견적 생성 폼
|
|
*/
|
|
public function createQuotation(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.create'));
|
|
}
|
|
|
|
return view('rd.ai-quotation.create');
|
|
}
|
|
|
|
/**
|
|
* AI 견적 문서 (인쇄용 견적서)
|
|
*/
|
|
public function documentQuotation(Request $request, int $id): View
|
|
{
|
|
$quotation = $this->quotationService->getById($id);
|
|
|
|
if (! $quotation || ! $quotation->isCompleted()) {
|
|
abort(404, '완료된 견적만 문서로 조회할 수 있습니다.');
|
|
}
|
|
|
|
$template = $request->query('template', 'classic');
|
|
$allowed = ['classic', 'modern', 'blue', 'dark', 'colorful'];
|
|
if (! in_array($template, $allowed)) {
|
|
$template = 'classic';
|
|
}
|
|
|
|
return view('rd.ai-quotation.document', compact('quotation', 'template'));
|
|
}
|
|
|
|
/**
|
|
* AI 견적 상세
|
|
*/
|
|
public function showQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.show', $id));
|
|
}
|
|
|
|
$quotation = $this->quotationService->getById($id);
|
|
|
|
if (! $quotation) {
|
|
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
|
}
|
|
|
|
return view('rd.ai-quotation.show', compact('quotation'));
|
|
}
|
|
|
|
/**
|
|
* AI 견적 편집 (제조 모드)
|
|
*/
|
|
public function editQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.edit', $id));
|
|
}
|
|
|
|
$quotation = $this->quotationService->getById($id);
|
|
|
|
if (! $quotation) {
|
|
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
|
}
|
|
|
|
if (! $quotation->isCompleted()) {
|
|
abort(403, '완료된 견적만 편집할 수 있습니다.');
|
|
}
|
|
|
|
return view('rd.ai-quotation.edit', compact('quotation'));
|
|
}
|
|
|
|
/**
|
|
* 기획디자인 - 플래닝 캔버스
|
|
*/
|
|
public function planningDesign(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.planning-design'));
|
|
}
|
|
|
|
return view('rd.planning-design.index');
|
|
}
|
|
|
|
/**
|
|
* 디자인 인사이트 - UI/UX 연구 도구
|
|
*/
|
|
public function designInsight(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.design-insight'));
|
|
}
|
|
|
|
return view('rd.design-insight.index');
|
|
}
|
|
|
|
/**
|
|
* 사운드 로고 생성기
|
|
*/
|
|
public function soundLogo(Request $request): View|\Illuminate\Http\Response
|
|
{
|
|
if ($request->header('HX-Request')) {
|
|
return response('', 200)->header('HX-Redirect', route('rd.sound-logo'));
|
|
}
|
|
|
|
return view('rd.sound-logo.index');
|
|
}
|
|
|
|
/**
|
|
* 사운드 로고 AI 생성 (Gemini API)
|
|
*/
|
|
public function soundLogoGenerate(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'prompt' => 'required|string|max:500',
|
|
'category' => 'nullable|string',
|
|
'duration' => 'nullable|numeric|min:0.3|max:5',
|
|
]);
|
|
|
|
$apiKey = config('services.gemini.api_key');
|
|
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
|
|
$model = config('services.gemini.model', 'gemini-2.5-flash');
|
|
|
|
if (! $apiKey) {
|
|
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
|
|
}
|
|
|
|
$category = $request->category ?? '기업 시그널';
|
|
$duration = $request->duration ?? 1.5;
|
|
|
|
$prompt = <<<PROMPT
|
|
당신은 사운드 디자인 전문가입니다. 사용자의 요청에 맞는 사운드 로고(짧은 시그니처 사운드)를 Web Audio API 음표 시퀀스로 설계해주세요.
|
|
|
|
## 사용자 요청
|
|
- 설명: {$request->prompt}
|
|
- 카테고리: {$category}
|
|
- 목표 길이: {$duration}초
|
|
|
|
## 사용 가능한 음표
|
|
C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3,
|
|
C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4,
|
|
C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6
|
|
|
|
## 음표 타입
|
|
- note: 단일 음 (note 필드 필수)
|
|
- chord: 화음 (chord 배열 필수, 2~4개 음)
|
|
- rest: 쉼표 (duration만 필요)
|
|
|
|
## 신스 타입
|
|
- sine: 부드러움 (기업 로고, 알림에 적합)
|
|
- triangle: 따뜻함 (성공, 게임에 적합)
|
|
- square: 8bit/디지털 (게임, UI에 적합)
|
|
- sawtooth: 날카로움 (록, 긴급 알림에 적합)
|
|
|
|
## 반드시 아래 JSON 형식으로만 응답하세요
|
|
{
|
|
"name": "사운드 이름",
|
|
"desc": "사운드 설명 (한줄)",
|
|
"synth": "sine",
|
|
"adsr": { "attack": 10, "decay": 80, "sustain": 0.6, "release": 400 },
|
|
"volume": 0.8,
|
|
"reverb": 0.3,
|
|
"notes": [
|
|
{ "type": "note", "note": "C5", "duration": 0.20, "velocity": 0.8 },
|
|
{ "type": "rest", "duration": 0.10 },
|
|
{ "type": "chord", "chord": ["C4", "E4", "G4"], "duration": 0.50, "velocity": 1.0 }
|
|
]
|
|
}
|
|
|
|
## 설계 원칙
|
|
- 음표의 duration 합계가 목표 길이({$duration}초)에 근접하도록 설계
|
|
- velocity: 0.3~1.0 (음의 강약으로 표현력 추가)
|
|
- ADSR: attack(1~500ms), decay(10~1000ms), sustain(0~1.0), release(10~3000ms)
|
|
- 카테고리 특성에 맞는 synth와 ADSR 선택
|
|
- 음악적으로 조화롭고 기억에 남는 멜로디 설계
|
|
- 최소 2개, 최대 12개 음표 사용
|
|
PROMPT;
|
|
|
|
try {
|
|
$response = Http::timeout(30)->post(
|
|
"{$baseUrl}/models/{$model}:generateContent?key={$apiKey}",
|
|
[
|
|
'contents' => [
|
|
['parts' => [['text' => $prompt]]],
|
|
],
|
|
'generationConfig' => [
|
|
'temperature' => 0.9,
|
|
'maxOutputTokens' => 2048,
|
|
'responseMimeType' => 'application/json',
|
|
],
|
|
]
|
|
);
|
|
} catch (\Exception $e) {
|
|
Log::error('SoundLogo AI 생성 실패', ['error' => $e->getMessage()]);
|
|
|
|
return response()->json(['success' => false, 'error' => 'AI 서버 연결 실패'], 500);
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('SoundLogo AI API 오류', ['status' => $response->status(), 'body' => $response->body()]);
|
|
|
|
return response()->json(['success' => false, 'error' => 'AI 생성 실패: '.$response->status()], 500);
|
|
}
|
|
|
|
$data = $response->json();
|
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
|
|
// JSON 파싱 (코드블록 제거)
|
|
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
|
|
$text = preg_replace('/```\s*$/m', '', $text);
|
|
$result = json_decode(trim($text), true);
|
|
|
|
if (! $result || ! isset($result['notes'])) {
|
|
Log::warning('SoundLogo AI 응답 파싱 실패', ['text' => substr($text, 0, 500)]);
|
|
|
|
return response()->json(['success' => false, 'error' => 'AI 응답을 파싱할 수 없습니다.'], 500);
|
|
}
|
|
|
|
return response()->json(['success' => true, 'data' => $result]);
|
|
}
|
|
}
|