input('q', '')); $tenantId = session('selected_tenant_id', 1); $query = User::where('is_active', true) ->whereHas('userRoles', function ($w) use ($tenantId) { $w->where('tenant_id', $tenantId) ->whereHas('role', fn ($r) => $r->whereIn('name', ['sales', 'manager'])); }) ->with(['salesPartner', 'userRoles' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId)->with('role'); }]); if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") ->orWhere('email', 'like', "%{$q}%") ->orWhere('phone', 'like', "%{$q}%"); }); } $users = $query->orderBy('name')->limit(20)->get(); $roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저']; $data = $users->map(function ($user) use ($roleLabels) { $sp = $user->salesPartner; $roles = $user->userRoles->map(fn ($ur) => $roleLabels[$ur->role?->name] ?? null)->filter()->values(); return [ 'id' => $user->id, 'name' => $user->name, 'phone' => $user->phone, 'email' => $user->email, 'company_name' => $sp?->company_name, 'biz_no' => $sp?->biz_no, 'address' => $sp?->address, 'position' => $roles->implode('/'), ]; }); return response()->json(['success' => true, 'data' => $data]); } /** * 고객(명함 등록 고객) 검색 */ public function searchTenants(Request $request): JsonResponse { $q = trim($request->input('q', '')); $query = TenantProspect::query(); if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('company_name', 'like', "%{$q}%") ->orWhere('business_number', 'like', "%{$q}%") ->orWhere('ceo_name', 'like', "%{$q}%") ->orWhere('contact_phone', 'like', "%{$q}%"); }); } $prospects = $query->orderBy('company_name')->limit(20)->get(); $data = $prospects->map(fn ($p) => [ 'id' => $p->id, 'company_name' => $p->company_name, 'business_number' => $p->business_number, 'ceo_name' => $p->ceo_name, 'address' => $p->address, 'phone' => $p->contact_phone, 'email' => $p->contact_email, ]); return response()->json(['success' => true, 'data' => $data]); } /** * 계약번호 자동 채번 (CONTRACT-YYYY-MMDD-N) */ public function generateContractNumber(): JsonResponse { $tenantId = session('selected_tenant_id', 1); $today = now()->format('Ymd'); $prefix = "CONTRACT-{$today}-"; $lastContract = EsignContract::where('tenant_id', $tenantId) ->where('contract_code', 'like', "{$prefix}%") ->orderByRaw('CAST(SUBSTRING(contract_code, ?) AS UNSIGNED) DESC', [strlen($prefix) + 1]) ->first(); $seq = 1; if ($lastContract) { $lastSeq = (int) str_replace($prefix, '', $lastContract->contract_code); $seq = $lastSeq + 1; } return response()->json([ 'success' => true, 'data' => ['contract_number' => $prefix.$seq], ]); } /** * 법인도장 조회 */ public function getStamp(): JsonResponse { $tenantId = session('selected_tenant_id', 1); $setting = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if (! $setting) { return response()->json(['success' => true, 'data' => null]); } $value = $setting->setting_value; // 로컬 스토리지 if (! empty($value['local_path'])) { if (Storage::disk('local')->exists($value['local_path'])) { $imageUrl = route('esign.contracts.stamp.image', ['tenant' => $tenantId]); return response()->json([ 'success' => true, 'data' => ['image_url' => $imageUrl], ]); } return response()->json(['success' => true, 'data' => null]); } // GCS 스토리지 (레거시) if (! empty($value['gcs_object'])) { $gcs = app(GoogleCloudStorageService::class); $signedUrl = $gcs->getSignedUrl($value['gcs_object'], 60); if ($signedUrl) { return response()->json([ 'success' => true, 'data' => ['image_url' => $signedUrl], ]); } } return response()->json(['success' => true, 'data' => null]); } /** * 법인도장 이미지 서빙 (로컬 스토리지용) */ public function serveStampImage(int $tenant) { $setting = TenantSetting::where('tenant_id', $tenant) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if (! $setting || empty($setting->setting_value['local_path'])) { abort(404); } $path = $setting->setting_value['local_path']; if (! Storage::disk('local')->exists($path)) { abort(404); } return response(Storage::disk('local')->get($path)) ->header('Content-Type', 'image/png') ->header('Cache-Control', 'private, max-age=3600'); } /** * 법인도장 업로드 */ public function uploadStamp(Request $request): JsonResponse { $request->validate([ 'stamp_image_data' => 'required|string', ]); $tenantId = session('selected_tenant_id', 1); $imageData = base64_decode($request->input('stamp_image_data')); if (! $imageData) { return response()->json(['success' => false, 'message' => '이미지 데이터가 올바르지 않습니다.'], 422); } $gcs = app(GoogleCloudStorageService::class); // 기존 파일 삭제 $existing = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if ($existing) { $val = $existing->setting_value; if (! empty($val['gcs_object']) && $gcs->isAvailable()) { $gcs->delete($val['gcs_object']); } if (! empty($val['local_path'])) { Storage::disk('local')->delete($val['local_path']); } } // GCS 사용 가능하면 GCS에 업로드 if ($gcs->isAvailable()) { $tmpFile = tempnam(sys_get_temp_dir(), 'stamp_'); file_put_contents($tmpFile, $imageData); $gcsObject = "esign/{$tenantId}/stamps/company_stamp.png"; $gcsUri = $gcs->upload($tmpFile, $gcsObject); unlink($tmpFile); if ($gcsUri) { TenantSetting::updateOrCreate( ['tenant_id' => $tenantId, 'setting_group' => 'esign', 'setting_key' => 'company_stamp'], ['setting_value' => ['gcs_object' => $gcsObject], 'updated_by' => auth()->id()] ); return response()->json([ 'success' => true, 'message' => '법인도장이 등록되었습니다.', 'data' => ['image_url' => $gcs->getSignedUrl($gcsObject, 60)], ]); } } // GCS 미사용 → 로컬 스토리지 $localPath = "esign/stamps/{$tenantId}/company_stamp.png"; Storage::disk('local')->put($localPath, $imageData); TenantSetting::updateOrCreate( ['tenant_id' => $tenantId, 'setting_group' => 'esign', 'setting_key' => 'company_stamp'], ['setting_value' => ['local_path' => $localPath], 'updated_by' => auth()->id()] ); $imageUrl = route('esign.contracts.stamp.image', ['tenant' => $tenantId]); return response()->json([ 'success' => true, 'message' => '법인도장이 등록되었습니다.', 'data' => ['image_url' => $imageUrl], ]); } /** * 법인도장 삭제 */ public function deleteStamp(): JsonResponse { $tenantId = session('selected_tenant_id', 1); $setting = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if ($setting) { $val = $setting->setting_value; if (! empty($val['gcs_object'])) { $gcs = app(GoogleCloudStorageService::class); if ($gcs->isAvailable()) { $gcs->delete($val['gcs_object']); } } if (! empty($val['local_path'])) { Storage::disk('local')->delete($val['local_path']); } $setting->delete(); } return response()->json([ 'success' => true, 'message' => '법인도장이 삭제되었습니다.', ]); } /** * 상태별 통계 */ public function stats(): JsonResponse { $tenantId = session('selected_tenant_id', 1); $contracts = EsignContract::forTenant($tenantId)->get(); $stats = [ 'total' => $contracts->count(), 'draft' => $contracts->where('status', 'draft')->count(), 'pending' => $contracts->where('status', 'pending')->count(), 'partially_signed' => $contracts->where('status', 'partially_signed')->count(), 'completed' => $contracts->where('status', 'completed')->count(), 'expired' => $contracts->where('status', 'expired')->count(), 'cancelled' => $contracts->where('status', 'cancelled')->count(), 'rejected' => $contracts->where('status', 'rejected')->count(), ]; return response()->json(['success' => true, 'data' => $stats]); } /** * 계약 목록 (페이지네이션) */ public function index(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $query = EsignContract::forTenant($tenantId)->with(['signers:id,contract_id,name,role,status']); if ($status = $request->input('status')) { $query->where('status', $status); } if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('contract_code', 'like', "%{$search}%"); }); } $perPage = $request->input('per_page', 20); $data = $query->orderBy('created_at', 'desc')->paginate($perPage); return response()->json(['success' => true, 'data' => $data]); } /** * 계약 상세 조회 */ public function show(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId) ->with(['signers', 'signFields', 'auditLogs' => fn ($q) => $q->orderBy('created_at', 'desc')]) ->findOrFail($id); return response()->json(['success' => true, 'data' => $contract]); } /** * 계약 생성 */ public function store(Request $request): JsonResponse { $request->validate([ 'title' => 'required|string|max:200', 'description' => 'nullable|string', 'sign_order_type' => 'required|in:counterpart_first,creator_first', 'expires_at' => 'nullable|date', 'expires_days' => 'nullable|integer|min:1|max:365', 'template_id' => 'nullable|integer', 'signers' => 'required|array|size:2', 'signers.*.name' => 'required|string|max:100', 'signers.*.email' => 'required|email|max:200', 'signers.*.phone' => 'nullable|string|max:20', 'signers.*.role' => 'required|in:creator,counterpart', 'metadata' => 'nullable|array', 'metadata.*' => 'nullable|string|max:500', 'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480', ]); $tenantId = session('selected_tenant_id', 1); $userId = auth()->id(); // 계약 코드 생성 $contractCode = 'ES-'.date('Ymd').'-'.strtoupper(Str::random(6)); // PDF 파일 처리 $filePath = null; $fileName = null; $fileHash = null; $fileSize = null; if ($request->hasFile('file')) { // 사용자가 직접 업로드한 파일 우선 사용 (Word면 PDF 자동 변환) $file = $request->file('file'); $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts"); $filePath = $result['path']; $fileName = $result['name']; $fileHash = $result['hash']; $fileSize = $result['size']; } elseif ($request->input('template_id')) { // 템플릿에 PDF가 있으면 복사 $template = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true) ->find($request->input('template_id')); if ($template && $template->file_path && Storage::disk('local')->exists($template->file_path)) { $ext = pathinfo($template->file_path, PATHINFO_EXTENSION) ?: 'pdf'; $newPath = "esign/{$tenantId}/contracts/".Str::random(40).".{$ext}"; Storage::disk('local')->copy($template->file_path, $newPath); $filePath = $newPath; $fileName = $template->file_name; $fileHash = $template->file_hash; $fileSize = $template->file_size; } } $contract = EsignContract::create([ 'tenant_id' => $tenantId, 'contract_code' => $contractCode, 'title' => $request->input('title'), 'description' => $request->input('description'), 'sign_order_type' => $request->input('sign_order_type'), 'original_file_path' => $filePath, 'original_file_name' => $fileName, 'original_file_hash' => $fileHash, 'original_file_size' => $fileSize, 'status' => 'draft', 'metadata' => $request->input('metadata'), 'expires_at' => $request->input('expires_at') ? \Carbon\Carbon::parse($request->input('expires_at')) : now()->addDays($request->input('expires_days', 30)), 'created_by' => $userId, 'updated_by' => $userId, ]); // 서명자 생성 $signers = $request->input('signers'); foreach ($signers as $i => $signerData) { EsignSigner::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'role' => $signerData['role'], 'sign_order' => $signerData['role'] === 'creator' ? ($request->input('sign_order_type') === 'creator_first' ? 1 : 2) : ($request->input('sign_order_type') === 'counterpart_first' ? 1 : 2), 'name' => $signerData['name'], 'email' => $signerData['email'], 'phone' => $signerData['phone'] ?? null, 'access_token' => Str::random(128), 'token_expires_at' => now()->addDays($request->input('expires_days', 30)), 'status' => 'waiting', ]); } // 법인도장 자동 적용: GCS에서 다운로드 → 로컬 저장 → signer에 설정 $stampSetting = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if ($stampSetting && ! empty($stampSetting->setting_value['gcs_object'])) { $creatorSigner = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->where('role', 'creator') ->first(); if ($creatorSigner) { $gcs = app(GoogleCloudStorageService::class); $signedUrl = $gcs->getSignedUrl($stampSetting->setting_value['gcs_object'], 5); if ($signedUrl) { $imageData = @file_get_contents($signedUrl); if ($imageData) { $localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png"; Storage::disk('local')->put($localPath, $imageData); $creatorSigner->update(['signature_image_path' => $localPath]); } } } } // 템플릿 자동 적용 (template_id가 있으면 필드 자동 생성) $autoApplied = false; if ($request->input('template_id')) { $template = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true)->with('items') ->find($request->input('template_id')); if ($template && $template->items->isNotEmpty()) { $contract->loadMissing('signers'); // 템플릿 signer_order를 역할(role)로 매핑: 1=creator(갑/회사), 2=counterpart(을/파트너) $signerMap = []; foreach ($contract->signers as $signer) { $templateOrder = $signer->role === 'creator' ? 1 : 2; $signerMap[$templateOrder] = $signer->id; } $variableValues = $this->buildVariableMap($contract, $template); foreach ($template->items as $item) { $signerId = $signerMap[$item->signer_order] ?? null; if (! $signerId) { continue; } $fieldValue = null; if ($item->field_variable && isset($variableValues[$item->field_variable])) { $fieldValue = $variableValues[$item->field_variable]; } EsignSignField::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'signer_id' => $signerId, 'page_number' => $item->page_number, 'position_x' => $item->position_x, 'position_y' => $item->position_y, 'width' => $item->width, 'height' => $item->height, 'field_type' => $item->field_type, 'field_label' => $item->field_label, 'field_variable' => $item->field_variable, 'font_size' => $item->font_size, 'text_align' => $item->text_align ?? 'L', 'field_value' => $fieldValue, 'is_required' => $item->is_required, 'sort_order' => $item->sort_order, ]); } $autoApplied = true; } } // 감사 로그 EsignAuditLog::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'action' => 'contract_created', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['created_by' => $userId], 'created_at' => now(), ]); return response()->json([ 'success' => true, 'message' => '계약이 생성되었습니다.', 'data' => $contract->load('signers'), 'auto_applied' => $autoApplied, ]); } /** * 계약 취소 */ public function cancel(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->findOrFail($id); if (in_array($contract->status, ['completed', 'cancelled'])) { return response()->json(['success' => false, 'message' => '취소할 수 없는 상태입니다.'], 422); } $contract->update([ 'status' => 'cancelled', 'updated_by' => auth()->id(), ]); EsignAuditLog::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'action' => 'contract_cancelled', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['cancelled_by' => auth()->id()], 'created_at' => now(), ]); return response()->json(['success' => true, 'message' => '계약이 취소되었습니다.']); } /** * 계약 삭제 - 휴지통으로 이동 (SoftDelete) */ public function destroy(Request $request): JsonResponse { $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'required|integer', ]); $tenantId = session('selected_tenant_id', 1); $ids = $request->input('ids'); $contracts = EsignContract::forTenant($tenantId)->whereIn('id', $ids)->get(); if ($contracts->isEmpty()) { return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404); } // 진행 중인 계약(pending, partially_signed) 차단 $activeContracts = $contracts->filter(fn ($c) => in_array($c->status, ['pending', 'partially_signed'])); if ($activeContracts->isNotEmpty()) { return response()->json([ 'success' => false, 'message' => '서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.', ], 422); } $deletedCount = 0; foreach ($contracts as $contract) { $contract->update(['deleted_by' => auth()->id()]); $contract->delete(); // SoftDelete → deleted_at 설정 $deletedCount++; } return response()->json([ 'success' => true, 'message' => "{$deletedCount}건의 계약이 휴지통으로 이동되었습니다.", ]); } /** * 휴지통 목록 */ public function trashed(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $query = EsignContract::onlyTrashed() ->where('tenant_id', $tenantId) ->with(['signers:id,contract_id,name,role,status']); if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('contract_code', 'like', "%{$search}%"); }); } $perPage = $request->input('per_page', 20); $data = $query->orderBy('deleted_at', 'desc')->paginate($perPage); return response()->json(['success' => true, 'data' => $data]); } /** * 휴지통에서 복구 */ public function restore(Request $request): JsonResponse { $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'required|integer', ]); $tenantId = session('selected_tenant_id', 1); $contracts = EsignContract::onlyTrashed() ->where('tenant_id', $tenantId) ->whereIn('id', $request->input('ids')) ->get(); if ($contracts->isEmpty()) { return response()->json(['success' => false, 'message' => '복구할 계약을 찾을 수 없습니다.'], 404); } $restoredCount = 0; foreach ($contracts as $contract) { $contract->update(['deleted_by' => null]); $contract->restore(); $restoredCount++; } return response()->json([ 'success' => true, 'message' => "{$restoredCount}건의 계약이 복구되었습니다.", ]); } /** * 영구 삭제 */ public function forceDestroy(Request $request): JsonResponse { $request->validate([ 'ids' => 'required|array|min:1', 'ids.*' => 'required|integer', ]); $tenantId = session('selected_tenant_id', 1); $contracts = EsignContract::onlyTrashed() ->where('tenant_id', $tenantId) ->whereIn('id', $request->input('ids')) ->get(); if ($contracts->isEmpty()) { return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404); } $deletedCount = 0; foreach ($contracts as $contract) { // 관련 파일 삭제 if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) { Storage::disk('local')->delete($contract->original_file_path); } if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) { Storage::disk('local')->delete($contract->signed_file_path); } // 서명 이미지 파일 삭제 $signers = EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->get(); foreach ($signers as $signer) { if ($signer->signature_image_path && Storage::disk('local')->exists($signer->signature_image_path)) { Storage::disk('local')->delete($signer->signature_image_path); } } // 관련 레코드 영구 삭제 EsignSignField::where('contract_id', $contract->id)->delete(); EsignAuditLog::where('contract_id', $contract->id)->delete(); EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->delete(); $contract->forceDelete(); $deletedCount++; } return response()->json([ 'success' => true, 'message' => "{$deletedCount}건의 계약이 영구 삭제되었습니다.", ]); } /** * 서명 위치 설정 */ public function configureFields(Request $request, int $id): JsonResponse { $request->validate([ 'fields' => 'required|array', 'fields.*.signer_id' => 'required|integer', 'fields.*.page_number' => 'required|integer|min:1', 'fields.*.position_x' => 'required|numeric', 'fields.*.position_y' => 'required|numeric', 'fields.*.width' => 'required|numeric', 'fields.*.height' => 'required|numeric', 'fields.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'fields.*.field_label' => 'nullable|string|max:100', 'fields.*.field_variable' => 'nullable|string|max:50', 'fields.*.font_size' => 'nullable|integer|min:6|max:72', 'fields.*.text_align' => 'nullable|string|in:L,C,R', 'fields.*.is_required' => 'nullable|boolean', ]); $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id); // 변수 맵 구성 (field_variable → 실제 값 매핑) $variableValues = $this->buildVariableMap($contract); // 기존 필드 삭제 후 새로 생성 EsignSignField::where('contract_id', $contract->id)->delete(); foreach ($request->input('fields') as $i => $field) { // field_variable이 있으면 변수 맵에서 값 조회 $fieldValue = null; $fieldVariable = $field['field_variable'] ?? null; if ($fieldVariable && isset($variableValues[$fieldVariable])) { $fieldValue = $variableValues[$fieldVariable]; } EsignSignField::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'signer_id' => $field['signer_id'], 'page_number' => $field['page_number'], 'position_x' => $field['position_x'], 'position_y' => $field['position_y'], 'width' => $field['width'], 'height' => $field['height'], 'field_type' => $field['field_type'], 'field_label' => $field['field_label'] ?? null, 'field_variable' => $fieldVariable, 'font_size' => $field['font_size'] ?? null, 'text_align' => $field['text_align'] ?? 'L', 'field_value' => $fieldValue, 'is_required' => $field['is_required'] ?? true, 'sort_order' => $i, ]); } return response()->json(['success' => true, 'message' => '서명 위치가 설정되었습니다.']); } /** * 서명 요청 발송 (상태 변경 + 이메일은 추후) */ public function send(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id); if ($contract->status !== 'draft') { return response()->json(['success' => false, 'message' => '초안 상태에서만 발송할 수 있습니다.'], 422); } $sendMethod = $request->input('send_method', 'email'); $smsFallback = $request->boolean('sms_fallback', true); $templateName = $request->input('template_name'); $contract->update([ 'status' => 'pending', 'send_method' => $sendMethod, 'sms_fallback' => $smsFallback, 'updated_by' => auth()->id(), ]); // 발송 대상 서명자 결정 if ($contract->sign_order_type === 'parallel') { $targetSigners = $contract->signers; } else { $first = $contract->signers()->orderBy('sign_order')->first(); $targetSigners = $first ? collect([$first]) : collect(); } $notificationResults = []; foreach ($targetSigners as $signer) { $signer->update(['status' => 'notified']); $results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback, templateName: $templateName); $notificationResults[] = [ 'signer_id' => $signer->id, 'signer_name' => $signer->name, 'results' => $results, ]; } EsignAuditLog::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => [ 'sent_by' => auth()->id(), 'send_method' => $sendMethod, 'notification_results' => $notificationResults, ], 'created_at' => now(), ]); // 실패한 알림 확인 $failures = []; foreach ($notificationResults as $nr) { foreach ($nr['results'] as $r) { if (! $r['success']) { $failures[] = "{$nr['signer_name']}: {$r['channel']} 실패 ({$r['error']})"; } } } $message = '서명 요청이 발송되었습니다.'; if (! empty($failures)) { $message .= ' (일부 알림 실패: '.implode(', ', $failures).')'; } return response()->json([ 'success' => true, 'message' => $message, 'notification_results' => $notificationResults, ]); } /** * 리마인더 발송 */ public function remind(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id); if (! in_array($contract->status, ['pending', 'partially_signed'])) { return response()->json(['success' => false, 'message' => '리마인더를 발송할 수 없는 상태입니다.'], 422); } // 다음 서명 대상자 찾기 $nextSigner = $contract->signers() ->whereIn('status', ['waiting', 'notified']) ->orderBy('sign_order') ->first(); // 요청에서 발송 방식 지정이 있으면 우선 사용, 없으면 계약 저장값, 최종 기본값은 email $sendMethod = $request->input('send_method') ?: ($contract->send_method ?? 'email'); $notificationResults = []; if ($nextSigner) { $nextSigner->update(['status' => 'notified']); $results = $this->dispatchNotification( $contract, $nextSigner, $sendMethod, $contract->sms_fallback ?? true, isReminder: true, ); $notificationResults[] = [ 'signer_id' => $nextSigner->id, 'signer_name' => $nextSigner->name, 'results' => $results, ]; } EsignAuditLog::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'action' => 'reminded', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => [ 'reminded_by' => auth()->id(), 'target_signer_id' => $nextSigner?->id, 'notification_results' => $notificationResults, ], 'created_at' => now(), ]); // 실패 확인 $failures = []; foreach ($notificationResults as $nr) { foreach ($nr['results'] as $r) { if (! $r['success']) { $failures[] = "{$r['channel']} 실패 ({$r['error']})"; } } } $message = $nextSigner ? "{$nextSigner->name}에게 리마인더가 발송되었습니다." : '리마인더가 기록되었습니다.'; if (! empty($failures)) { $message .= ' (일부 알림 실패: '.implode(', ', $failures).')'; } return response()->json([ 'success' => true, 'message' => $message, 'notification_results' => $notificationResults, ]); } /** * 발송 방식에 따라 알림톡/이메일 분기 발송 */ private function dispatchNotification( EsignContract $contract, EsignSigner $signer, string $sendMethod, bool $smsFallback, bool $isReminder = false, ?string $templateName = null, ): array { $results = []; $alimtalkFailed = false; // 알림톡 발송 if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) { $alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder, $templateName); $results[] = $alimtalkResult; $alimtalkFailed = ! ($alimtalkResult['success'] ?? false); } // 이메일 발송 조건: // 1) email/both 선택 시 // 2) alimtalk인데 번호 없으면 폴백 // 3) alimtalk 발송 실패 시 이메일 자동 폴백 $shouldSendEmail = in_array($sendMethod, ['email', 'both']) || ($sendMethod === 'alimtalk' && ! $signer->phone) || ($sendMethod === 'alimtalk' && $alimtalkFailed); if ($shouldSendEmail && $signer->email) { try { Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder)); $results[] = ['success' => true, 'channel' => 'email', 'error' => null]; } catch (\Throwable $e) { \Log::warning('E-Sign 이메일 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); $results[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()]; } } return $results; } /** * 알림톡 발송 */ private function sendAlimtalk( EsignContract $contract, EsignSigner $signer, bool $smsFallback = true, bool $isReminder = false, ?string $templateName = null, ): array { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if (! $member) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } if (! $member->biz_no) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '사업자번호 미설정 (알림톡 발송 불가)']; } $barobill = app(BarobillService::class); $barobill->setServerMode($member->server_mode ?? 'production'); // 카카오톡 채널 ID 조회 (YellowId로 사용) $channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no); if (! $channelId) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널이 없습니다']; } $signUrl = config('app.url').'/esign/sign/'.$signer->access_token; $expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; if (! $templateName) { $templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청'; } // 등록된 템플릿 본문 + 버튼 정보 조회 (정확한 포맷 유지) $tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName); $templateContent = $tplData['content']; $templateButtons = $tplData['buttons']; if ($templateContent) { $message = str_replace( ['#{이름}', '#{계약명}', '#{기한}'], [$signer->name, $contract->title, $expires], $templateContent ); } else { \Log::warning('E-Sign 알림톡: 템플릿 내용 조회 실패, 하드코딩 폴백 사용', [ 'template_name' => $templateName, 'channel_id' => $channelId, ]); $message = $isReminder ? "안녕하세요, {$signer->name}님.\n아직 서명이 완료되지 않은 전자계약이 있습니다.\n\n ■ 계약명: {$contract->title}\n ■ 서명 기한: {$expires}\n\n 기한 내에 서명을 완료해 주세요." : " 안녕하세요, {$signer->name}님. \n 전자계약 서명 요청이 도착했습니다.\n\n ■ 계약명: {$contract->title}\n ■ 서명 기한: {$expires}\n\n 아래 버튼을 눌러 계약서를 확인하고 서명해 주세요."; } // 등록된 버튼 URL을 그대로 사용 (동적 URL 사용 시 템플릿 불일치 오류) $buttons = ! empty($templateButtons) ? $templateButtons : [ ['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => 'https://mng.codebridge-x.com', 'Url2' => 'https://mng.codebridge-x.com'], ]; $receiverNum = preg_replace('/[^0-9]/', '', $signer->phone); \Log::info('E-Sign 알림톡 발송 시도', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'template_name' => $templateName, 'template_from_api' => (bool) $templateContent, 'buttons_from_api' => ! empty($templateButtons), 'receiver_num' => $receiverNum, ]); $result = $barobill->sendATKakaotalkEx( corpNum: $member->biz_no, senderId: $member->barobill_id, yellowId: $channelId, templateName: $templateName, receiverName: $signer->name, receiverNum: $receiverNum, title: '', message: $message, buttons: $buttons, ); // 발송 접수 후 결과 확인 (SendKey 반환 시) if (($result['success'] ?? false) && ! empty($result['data']) && is_string($result['data'])) { $sendKey = $result['data']; \Log::info('E-Sign 알림톡 접수 성공', [ 'contract_id' => $contract->id, 'send_key' => $sendKey, ]); // 3초 후 전달 결과 확인 sleep(3); $sendResult = $barobill->getSendKakaotalk($member->biz_no, $sendKey); $resultData = $sendResult['data'] ?? null; $resultCode = is_object($resultData) ? ($resultData->ResultCode ?? null) : ($resultData['ResultCode'] ?? null); $resultMsg = is_object($resultData) ? ($resultData->ResultMessage ?? null) : ($resultData['ResultMessage'] ?? null); \Log::info('E-Sign 알림톡 전달 결과', [ 'contract_id' => $contract->id, 'send_key' => $sendKey, 'result_code' => $resultCode, 'result_message' => $resultMsg, ]); // ResultCode 1 = 성공, 그 외 = 실패 if ($resultCode !== null && $resultCode != 1) { return [ 'success' => false, 'channel' => 'alimtalk', 'error' => "카카오톡 전달 실패: {$resultMsg} (코드: {$resultCode})", ]; } } if (! ($result['success'] ?? false)) { \Log::warning('E-Sign 알림톡 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $result['error'] ?? 'Unknown error', ]); return ['success' => false, 'channel' => 'alimtalk', 'error' => $result['error'] ?? 'API 호출 실패']; } return ['success' => true, 'channel' => 'alimtalk', 'error' => null]; } catch (\Throwable $e) { \Log::warning('E-Sign 알림톡 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } /** * 바로빌 카카오톡 채널 ID 조회 (YellowId로 사용) */ private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo): ?string { $result = $barobill->getKakaotalkChannels($bizNo); if (! ($result['success'] ?? false) || empty($result['data'])) { return null; } $data = $result['data']; if (is_object($data) && isset($data->KakaotalkChannel)) { $channels = is_array($data->KakaotalkChannel) ? $data->KakaotalkChannel : [$data->KakaotalkChannel]; } elseif (is_array($data) && isset($data['KakaotalkChannel'])) { $channels = is_array($data['KakaotalkChannel']) ? $data['KakaotalkChannel'] : [$data['KakaotalkChannel']]; } else { $channels = is_array($data) ? $data : [$data]; } $channel = $channels[0] ?? null; if (! $channel) { return null; } return is_array($channel) ? ($channel['ChannelId'] ?? null) : ($channel->ChannelId ?? null); } /** * 바로빌 등록 템플릿의 본문 내용 및 버튼 정보 조회 * * @return array{content: string|null, buttons: array} */ private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array { $empty = ['content' => null, 'buttons' => []]; $result = $barobill->getKakaotalkTemplates($bizNo, $channelId); if (! ($result['success'] ?? false) || empty($result['data'])) { return $empty; } $data = $result['data']; $items = []; if (is_object($data) && isset($data->KakaotalkTemplate)) { $items = is_array($data->KakaotalkTemplate) ? $data->KakaotalkTemplate : [$data->KakaotalkTemplate]; } foreach ($items as $tpl) { if (($tpl->TemplateName ?? '') === $templateName) { $buttons = []; $btnData = $tpl->Buttons ?? null; if ($btnData) { $btnList = $btnData->KakaotalkButton ?? null; if ($btnList) { $btnList = is_array($btnList) ? $btnList : [$btnList]; foreach ($btnList as $btn) { $buttons[] = [ 'Name' => $btn->Name ?? '', 'ButtonType' => $btn->ButtonType ?? 'WL', 'Url1' => $btn->Url1 ?? '', 'Url2' => $btn->Url2 ?? '', ]; } } } return [ 'content' => $tpl->TemplateContent ?? null, 'buttons' => $buttons, ]; } } return $empty; } /** * 바로빌 등록 알림톡 템플릿 목록 조회 (승인 완료된 것만) */ public function getAlimtalkTemplates(): JsonResponse { try { $tenantId = session('selected_tenant_id', 1); $member = BarobillMember::where('tenant_id', $tenantId)->first(); if (! $member || ! $member->biz_no) { return response()->json([ 'success' => false, 'message' => '바로빌 회원 정보 또는 사업자번호가 설정되지 않았습니다.', ]); } $barobill = app(BarobillService::class); $barobill->setServerMode($member->server_mode ?? 'production'); $channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no); if (! $channelId) { return response()->json([ 'success' => false, 'message' => '등록된 카카오톡 채널이 없습니다.', ]); } $result = $barobill->getKakaotalkTemplates($member->biz_no, $channelId); if (! ($result['success'] ?? false) || empty($result['data'])) { return response()->json([ 'success' => false, 'message' => '템플릿 목록을 조회할 수 없습니다.', ]); } $data = $result['data']; $items = []; if (is_object($data) && isset($data->KakaotalkTemplate)) { $items = is_array($data->KakaotalkTemplate) ? $data->KakaotalkTemplate : [$data->KakaotalkTemplate]; } // 승인(Status=3)된 템플릿만 필터링 $templates = []; foreach ($items as $tpl) { $status = $tpl->Status ?? null; if ($status != 3) { continue; } $buttons = []; $btnData = $tpl->Buttons ?? null; if ($btnData) { $btnList = $btnData->KakaotalkButton ?? null; if ($btnList) { $btnList = is_array($btnList) ? $btnList : [$btnList]; foreach ($btnList as $btn) { $buttons[] = [ 'Name' => $btn->Name ?? '', 'ButtonType' => $btn->ButtonType ?? 'WL', 'Url1' => $btn->Url1 ?? '', 'Url2' => $btn->Url2 ?? '', ]; } } } $templates[] = [ 'name' => $tpl->TemplateName ?? '', 'content' => $tpl->TemplateContent ?? '', 'status' => $status, 'buttons' => $buttons, ]; } return response()->json([ 'success' => true, 'data' => [ 'channel_id' => $channelId, 'templates' => $templates, ], ]); } catch (\Throwable $e) { \Log::error('알림톡 템플릿 목록 조회 실패', ['error' => $e->getMessage()]); return response()->json([ 'success' => false, 'message' => '템플릿 목록 조회 중 오류: '.$e->getMessage(), ]); } } /** * PDF 다운로드 */ public function download(int $id) { $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->findOrFail($id); $filePath = $contract->signed_file_path ?: $contract->original_file_path; if (! $filePath || ! \Storage::disk('local')->exists($filePath)) { abort(404, 'PDF 파일을 찾을 수 없습니다.'); } $fileName = $contract->original_file_name ?: 'contract.pdf'; return \Storage::disk('local')->download($filePath, $fileName, [ 'Content-Type' => 'application/pdf', ]); } /** * PDF 업로드 (PDF 없이 생성된 계약에 나중에 업로드) */ public function uploadPdf(Request $request, int $id): JsonResponse { $request->validate([ 'file' => 'required|file|mimes:pdf,doc,docx|max:20480', ]); $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->findOrFail($id); if ($contract->original_file_path) { return response()->json(['success' => false, 'message' => '이미 PDF 파일이 존재합니다.'], 422); } $file = $request->file('file'); $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts"); $contract->update([ 'original_file_path' => $result['path'], 'original_file_name' => $result['name'], 'original_file_hash' => $result['hash'], 'original_file_size' => $result['size'], 'updated_by' => auth()->id(), ]); return response()->json([ 'success' => true, 'message' => 'PDF 파일이 업로드되었습니다.', 'data' => ['path' => $result['path'], 'name' => $result['name']], ]); } /** * 템플릿 PDF 다운로드 */ public function downloadTemplatePdf(int $id) { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); if (! $template->file_path || ! Storage::disk('local')->exists($template->file_path)) { abort(404, 'PDF 파일을 찾을 수 없습니다.'); } $fileName = $template->file_name ?: 'template.pdf'; return Storage::disk('local')->download($template->file_path, $fileName, [ 'Content-Type' => 'application/pdf', ]); } // ─── 필드 템플릿 관련 메서드 ─── /** * 템플릿 목록 조회 */ public function indexTemplates(Request $request): JsonResponse { $tenantId = session('selected_tenant_id', 1); $query = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true); if ($category = $request->input('category')) { $query->where('category', $category); } if ($search = $request->input('search')) { $query->where('name', 'like', "%{$search}%"); } if ($signerCount = $request->input('signer_count')) { $query->where('signer_count', $signerCount); } $templates = $query->withCount('items')->with('creator:id,name')->latest()->get(); return response()->json(['success' => true, 'data' => $templates]); } /** * 템플릿 저장 (현재 필드를 템플릿으로) */ public function storeTemplate(Request $request): JsonResponse { $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string', 'category' => 'nullable|string|max:50', 'include_pdf' => 'nullable|boolean', 'contract_id' => 'nullable|integer', 'signer_count' => 'nullable|integer|min:1|max:6', 'variables' => 'nullable|array', 'items' => 'nullable|array', 'items.*.signer_order' => 'required|integer|min:1', 'items.*.page_number' => 'required|integer|min:1', 'items.*.position_x' => 'required|numeric', 'items.*.position_y' => 'required|numeric', 'items.*.width' => 'required|numeric', 'items.*.height' => 'required|numeric', 'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'items.*.field_label' => 'nullable|string|max:100', 'items.*.field_variable' => 'nullable|string|max:50', 'items.*.font_size' => 'nullable|integer|min:6|max:72', 'items.*.text_align' => 'nullable|string|in:L,C,R', 'items.*.is_required' => 'nullable|boolean', ]); $tenantId = session('selected_tenant_id', 1); // signer_count 결정: 직접 지정 > items에서 추출 > 기본값 2 $items = $request->input('items', []); $signerCount = $request->input('signer_count') ?: (count($items) > 0 ? max(array_column($items, 'signer_order')) : 2); // PDF 포함 여부 확인 $includePdf = $request->boolean('include_pdf'); $contractId = $request->input('contract_id'); $sourceContract = null; if ($includePdf && $contractId) { $sourceContract = EsignContract::forTenant($tenantId)->find($contractId); if (! $sourceContract || ! $sourceContract->original_file_path || ! Storage::disk('local')->exists($sourceContract->original_file_path)) { $sourceContract = null; } } $template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount, $sourceContract) { $fileData = []; if ($sourceContract) { $timestamp = now()->format('YmdHis'); $ext = pathinfo($sourceContract->original_file_path, PATHINFO_EXTENSION) ?: 'pdf'; $newPath = "esign/{$tenantId}/templates/{$timestamp}.{$ext}"; Storage::disk('local')->copy($sourceContract->original_file_path, $newPath); $fileData = [ 'file_path' => $newPath, 'file_name' => $sourceContract->original_file_name, 'file_hash' => $sourceContract->original_file_hash, 'file_size' => $sourceContract->original_file_size, ]; } $template = EsignFieldTemplate::create(array_merge([ 'tenant_id' => $tenantId, 'name' => $request->input('name'), 'description' => $request->input('description'), 'category' => $request->input('category'), 'signer_count' => $signerCount, 'variables' => $request->input('variables', []), 'is_active' => true, 'created_by' => auth()->id(), ], $fileData)); foreach ($items as $i => $item) { EsignFieldTemplateItem::create([ 'template_id' => $template->id, 'signer_order' => $item['signer_order'], 'page_number' => $item['page_number'], 'position_x' => $item['position_x'], 'position_y' => $item['position_y'], 'width' => $item['width'], 'height' => $item['height'], 'field_type' => $item['field_type'], 'field_label' => $item['field_label'] ?? null, 'field_variable' => $item['field_variable'] ?? null, 'font_size' => $item['font_size'] ?? null, 'text_align' => $item['text_align'] ?? 'L', 'is_required' => $item['is_required'] ?? true, 'sort_order' => $i, ]); } return $template; }); return response()->json([ 'success' => true, 'message' => '필드 템플릿이 저장되었습니다.', 'data' => $template->load('items'), ]); } /** * 템플릿 단건 조회 */ public function showTemplate(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true) ->with(['items', 'creator:id,name']) ->findOrFail($id); return response()->json(['success' => true, 'data' => $template]); } /** * 템플릿 메타데이터 수정 */ public function updateTemplate(Request $request, int $id): JsonResponse { $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string', 'category' => 'nullable|string|max:50', 'variables' => 'nullable|array', 'variables.*.key' => 'required|string|max:50', 'variables.*.label' => 'required|string|max:100', 'variables.*.type' => 'nullable|in:text,number,date', 'variables.*.default' => 'nullable|string|max:500', ]); $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); $updateData = [ 'name' => $request->input('name'), 'description' => $request->input('description'), 'category' => $request->input('category'), ]; if ($request->has('variables')) { $updateData['variables'] = $request->input('variables'); } $template->update($updateData); return response()->json([ 'success' => true, 'message' => '템플릿이 수정되었습니다.', 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), ]); } /** * 템플릿 PDF 교체 */ public function uploadTemplatePdf(Request $request, int $id): JsonResponse { $request->validate([ 'file' => 'required|file|mimes:pdf,doc,docx|max:20480', ]); $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); // 기존 파일 삭제 if ($template->file_path && Storage::disk('local')->exists($template->file_path)) { Storage::disk('local')->delete($template->file_path); } $file = $request->file('file'); $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/templates"); $template->update([ 'file_path' => $result['path'], 'file_name' => $result['name'], 'file_hash' => $result['hash'], 'file_size' => $result['size'], ]); return response()->json([ 'success' => true, 'message' => 'PDF가 교체되었습니다.', 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), ]); } /** * 템플릿 PDF 제거 */ public function removeTemplatePdf(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); if ($template->file_path && Storage::disk('local')->exists($template->file_path)) { Storage::disk('local')->delete($template->file_path); } $template->update([ 'file_path' => null, 'file_name' => null, 'file_hash' => null, 'file_size' => null, ]); return response()->json([ 'success' => true, 'message' => 'PDF가 제거되었습니다.', 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), ]); } /** * 템플릿 필드 아이템 삭제 */ public function destroyTemplateItem(int $templateId, int $itemId): JsonResponse { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId); $item = EsignFieldTemplateItem::where('template_id', $template->id)->findOrFail($itemId); $item->delete(); // signer_count 재계산 $maxOrder = EsignFieldTemplateItem::where('template_id', $template->id)->max('signer_order'); $template->update(['signer_count' => $maxOrder ?: 0]); return response()->json([ 'success' => true, 'message' => '필드가 삭제되었습니다.', 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), ]); } /** * 템플릿 필드 아이템 일괄 저장 (에디터에서 사용) */ public function updateTemplateItems(Request $request, int $templateId): JsonResponse { $request->validate([ 'items' => 'present|array', 'items.*.signer_order' => 'required|integer|min:1', 'items.*.page_number' => 'required|integer|min:1', 'items.*.position_x' => 'required|numeric', 'items.*.position_y' => 'required|numeric', 'items.*.width' => 'required|numeric', 'items.*.height' => 'required|numeric', 'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'items.*.field_label' => 'nullable|string|max:100', 'items.*.field_variable' => 'nullable|string|max:50', 'items.*.font_size' => 'nullable|integer|min:6|max:72', 'items.*.text_align' => 'nullable|string|in:L,C,R', 'items.*.is_required' => 'nullable|boolean', ]); $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId); $items = $request->input('items', []); DB::transaction(function () use ($template, $items) { // 기존 아이템 삭제 EsignFieldTemplateItem::where('template_id', $template->id)->delete(); // 새 아이템 생성 foreach ($items as $i => $itemData) { EsignFieldTemplateItem::create([ 'template_id' => $template->id, 'signer_order' => $itemData['signer_order'], 'page_number' => $itemData['page_number'], 'position_x' => round($itemData['position_x'], 2), 'position_y' => round($itemData['position_y'], 2), 'width' => round($itemData['width'], 2), 'height' => round($itemData['height'], 2), 'field_type' => $itemData['field_type'], 'field_label' => $itemData['field_label'] ?? '', 'field_variable' => $itemData['field_variable'] ?? null, 'font_size' => $itemData['font_size'] ?? null, 'text_align' => $itemData['text_align'] ?? 'L', 'is_required' => $itemData['is_required'] ?? true, 'sort_order' => $i, ]); } // signer_count 업데이트 $maxOrder = collect($items)->max('signer_order') ?: 0; $template->update(['signer_count' => $maxOrder]); }); return response()->json([ 'success' => true, 'message' => '템플릿 필드가 저장되었습니다.', 'data' => $template->fresh()->load('items'), ]); } /** * 템플릿 복제 */ public function duplicateTemplate(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true) ->with('items') ->findOrFail($id); $newTemplate = DB::transaction(function () use ($template, $tenantId) { $fileData = []; if ($template->file_path && Storage::disk('local')->exists($template->file_path)) { $timestamp = now()->format('YmdHis'); $ext = pathinfo($template->file_path, PATHINFO_EXTENSION) ?: 'pdf'; $newPath = "esign/{$tenantId}/templates/{$timestamp}_copy.{$ext}"; Storage::disk('local')->copy($template->file_path, $newPath); $fileData = [ 'file_path' => $newPath, 'file_name' => $template->file_name, 'file_hash' => $template->file_hash, 'file_size' => $template->file_size, ]; } $newTemplate = EsignFieldTemplate::create(array_merge([ 'tenant_id' => $tenantId, 'name' => $template->name.' (복사)', 'description' => $template->description, 'category' => $template->category, 'signer_count' => $template->signer_count, 'variables' => $template->variables, 'is_active' => true, 'created_by' => auth()->id(), ], $fileData)); foreach ($template->items as $item) { EsignFieldTemplateItem::create([ 'template_id' => $newTemplate->id, 'signer_order' => $item->signer_order, 'page_number' => $item->page_number, 'position_x' => $item->position_x, 'position_y' => $item->position_y, 'width' => $item->width, 'height' => $item->height, 'field_type' => $item->field_type, 'field_label' => $item->field_label, 'field_variable' => $item->field_variable, 'font_size' => $item->font_size, 'is_required' => $item->is_required, 'sort_order' => $item->sort_order, ]); } return $newTemplate; }); return response()->json([ 'success' => true, 'message' => '템플릿이 복제되었습니다.', 'data' => $newTemplate->load(['items', 'creator:id,name'])->loadCount('items'), ]); } /** * 템플릿 삭제 (soft: is_active=false) */ public function destroyTemplate(int $id): JsonResponse { $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); $template->update(['is_active' => false]); return response()->json(['success' => true, 'message' => '템플릿이 삭제되었습니다.']); } /** * 템플릿을 계약에 적용 */ public function applyTemplate(Request $request, int $id): JsonResponse { $request->validate([ 'template_id' => 'required|integer', ]); $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id); $template = EsignFieldTemplate::forTenant($tenantId) ->where('is_active', true) ->with('items') ->findOrFail($request->input('template_id')); // 서명자 수 확인 $contractSignerCount = $contract->signers->count(); if ($template->signer_count > $contractSignerCount) { return response()->json([ 'success' => false, 'message' => "템플릿에 필요한 서명자 수({$template->signer_count}명)가 계약의 서명자 수({$contractSignerCount}명)보다 많습니다.", ], 422); } // signer_order → signer_id 매핑 $signerMap = []; foreach ($contract->signers as $signer) { $signerMap[$signer->sign_order] = $signer->id; } // 변수 해석용 맵 구성 $variableValues = $this->buildVariableMap($contract, $template); DB::transaction(function () use ($contract, $template, $tenantId, $signerMap, $variableValues) { // 기존 필드 삭제 EsignSignField::where('contract_id', $contract->id)->delete(); // 템플릿 아이템 → 필드 생성 foreach ($template->items as $item) { $signerId = $signerMap[$item->signer_order] ?? null; if (! $signerId) { continue; } // 변수가 바인딩된 필드는 자동 채움 $fieldValue = null; if ($item->field_variable && isset($variableValues[$item->field_variable])) { $fieldValue = $variableValues[$item->field_variable]; } EsignSignField::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, 'signer_id' => $signerId, 'page_number' => $item->page_number, 'position_x' => $item->position_x, 'position_y' => $item->position_y, 'width' => $item->width, 'height' => $item->height, 'field_type' => $item->field_type, 'field_label' => $item->field_label, 'field_variable' => $item->field_variable, 'font_size' => $item->font_size, 'field_value' => $fieldValue, 'is_required' => $item->is_required, 'sort_order' => $item->sort_order, ]); } }); $fields = EsignSignField::where('contract_id', $contract->id)->orderBy('sort_order')->get(); return response()->json([ 'success' => true, 'message' => '템플릿이 적용되었습니다.', 'data' => $fields, ]); } /** * 다른 계약에서 필드 복사 */ public function copyFieldsFromContract(Request $request, int $id, int $sourceId): JsonResponse { $tenantId = session('selected_tenant_id', 1); $targetContract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id); $sourceContract = EsignContract::forTenant($tenantId)->with(['signers', 'signFields'])->findOrFail($sourceId); if ($sourceContract->signFields->isEmpty()) { return response()->json([ 'success' => false, 'message' => '소스 계약에 복사할 필드가 없습니다.', ], 422); } // 소스 계약 서명자의 sign_order → signer_id 매핑 $sourceSignerOrderMap = []; foreach ($sourceContract->signers as $signer) { $sourceSignerOrderMap[$signer->id] = $signer->sign_order; } // 대상 계약 sign_order → signer_id 매핑 $targetSignerMap = []; foreach ($targetContract->signers as $signer) { $targetSignerMap[$signer->sign_order] = $signer->id; } // 대상 계약 기준 변수 맵 구성 $variableValues = $this->buildVariableMap($targetContract); DB::transaction(function () use ($targetContract, $sourceContract, $tenantId, $sourceSignerOrderMap, $targetSignerMap, $variableValues) { // 기존 필드 삭제 EsignSignField::where('contract_id', $targetContract->id)->delete(); foreach ($sourceContract->signFields as $field) { // 소스 signer_id → sign_order → 대상 signer_id $signOrder = $sourceSignerOrderMap[$field->signer_id] ?? null; $targetSignerId = $signOrder ? ($targetSignerMap[$signOrder] ?? null) : null; if (! $targetSignerId) { continue; } // field_variable이 있으면 대상 계약의 변수 맵에서 값 조회 $fieldValue = null; if ($field->field_variable && isset($variableValues[$field->field_variable])) { $fieldValue = $variableValues[$field->field_variable]; } EsignSignField::create([ 'tenant_id' => $tenantId, 'contract_id' => $targetContract->id, 'signer_id' => $targetSignerId, 'page_number' => $field->page_number, 'position_x' => $field->position_x, 'position_y' => $field->position_y, 'width' => $field->width, 'height' => $field->height, 'field_type' => $field->field_type, 'field_label' => $field->field_label, 'field_variable' => $field->field_variable, 'font_size' => $field->font_size, 'field_value' => $fieldValue, 'is_required' => $field->is_required, 'sort_order' => $field->sort_order, ]); } }); $fields = EsignSignField::where('contract_id', $targetContract->id)->orderBy('sort_order')->get(); return response()->json([ 'success' => true, 'message' => '필드가 복사되었습니다.', 'data' => $fields, ]); } /** * 변수 해석 맵 구성 (시스템 변수 + 커스텀 변수) */ private function buildVariableMap(EsignContract $contract, ?EsignFieldTemplate $template = null): array { $map = []; // 시스템 변수: 서명자 정보 (역할 기반: 1=creator/갑/회사, 2=counterpart/을/파트너) $signers = $contract->signers->sortBy(fn ($s) => $s->role === 'creator' ? 1 : 2); $idx = 1; foreach ($signers as $signer) { $map["signer{$idx}_name"] = $signer->name; $map["signer{$idx}_email"] = $signer->email; $map["signer{$idx}_phone"] = $signer->phone ?? ''; $idx++; } // 시스템 변수: 계약 정보 $map['contract_title'] = $contract->title ?? ''; $map['current_date'] = now()->format('Y년 n월 j일'); $map['expires_at'] = $contract->expires_at ? $contract->expires_at->format('Y년 n월 j일') : ''; // 커스텀 변수: contract.metadata에서 조회 $metadata = $contract->metadata ?? []; foreach ($metadata as $key => $value) { $map[$key] = $value ?? ''; } return $map; } }