From 748a9d1807d4854f9fbb01083d348ded3e4722e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 24 Feb 2026 00:46:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[esign]=20=EC=95=8C=EB=A6=BC=ED=86=A1=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(corp=5Fnum=20=E2=86=92=20biz=5Fno)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BarobillMember의 실제 컬럼명은 biz_no인데 corp_num으로 접근하여 항상 null - is_test_mode → server_mode로 수정 - kakaotalk_sender_id → 빈 문자열 (기본 발신프로필 사용) --- .../Controllers/ESign/EsignApiController.php | 109 ++++++++++-------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 738fb863..5a1554b9 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -5,17 +5,17 @@ use App\Http\Controllers\Controller; use App\Mail\EsignRequestMail; use App\Models\Barobill\BarobillMember; +use App\Models\ESign\EsignAuditLog; use App\Models\ESign\EsignContract; -use App\Models\User; -use App\Services\ESign\DocxToPdfConverter; use App\Models\ESign\EsignFieldTemplate; use App\Models\ESign\EsignFieldTemplateItem; use App\Models\ESign\EsignSigner; use App\Models\ESign\EsignSignField; -use App\Models\ESign\EsignAuditLog; use App\Models\Sales\TenantProspect; use App\Models\Tenants\TenantSetting; +use App\Models\User; use App\Services\Barobill\BarobillService; +use App\Services\ESign\DocxToPdfConverter; use App\Services\GoogleCloudStorageService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -38,7 +38,7 @@ public function searchPartners(Request $request): JsonResponse $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'])); + ->whereHas('role', fn ($r) => $r->whereIn('name', ['sales', 'manager'])); }) ->with(['salesPartner', 'userRoles' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId)->with('role'); @@ -47,8 +47,8 @@ public function searchPartners(Request $request): JsonResponse if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") - ->orWhere('email', 'like', "%{$q}%") - ->orWhere('phone', 'like', "%{$q}%"); + ->orWhere('email', 'like', "%{$q}%") + ->orWhere('phone', 'like', "%{$q}%"); }); } @@ -58,7 +58,8 @@ public function searchPartners(Request $request): JsonResponse $data = $users->map(function ($user) use ($roleLabels) { $sp = $user->salesPartner; - $roles = $user->userRoles->map(fn($ur) => $roleLabels[$ur->role?->name] ?? null)->filter()->values(); + $roles = $user->userRoles->map(fn ($ur) => $roleLabels[$ur->role?->name] ?? null)->filter()->values(); + return [ 'id' => $user->id, 'name' => $user->name, @@ -86,15 +87,15 @@ public function searchTenants(Request $request): JsonResponse 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}%"); + ->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) => [ + $data = $prospects->map(fn ($p) => [ 'id' => $p->id, 'company_name' => $p->company_name, 'business_number' => $p->business_number, @@ -118,7 +119,7 @@ public function generateContractNumber(): JsonResponse $lastContract = EsignContract::where('tenant_id', $tenantId) ->where('contract_code', 'like', "{$prefix}%") - ->orderByRaw("CAST(SUBSTRING(contract_code, ?) AS UNSIGNED) DESC", [strlen($prefix) + 1]) + ->orderByRaw('CAST(SUBSTRING(contract_code, ?) AS UNSIGNED) DESC', [strlen($prefix) + 1]) ->first(); $seq = 1; @@ -129,7 +130,7 @@ public function generateContractNumber(): JsonResponse return response()->json([ 'success' => true, - 'data' => ['contract_number' => $prefix . $seq], + 'data' => ['contract_number' => $prefix.$seq], ]); } @@ -144,14 +145,14 @@ public function getStamp(): JsonResponse ->where('setting_key', 'company_stamp') ->first(); - if (!$setting || empty($setting->setting_value['gcs_object'])) { + if (! $setting || empty($setting->setting_value['gcs_object'])) { return response()->json(['success' => true, 'data' => null]); } $gcs = app(GoogleCloudStorageService::class); $signedUrl = $gcs->getSignedUrl($setting->setting_value['gcs_object'], 60); - if (!$signedUrl) { + if (! $signedUrl) { return response()->json(['success' => true, 'data' => null]); } @@ -175,12 +176,12 @@ public function uploadStamp(Request $request): JsonResponse $tenantId = session('selected_tenant_id', 1); $imageData = base64_decode($request->input('stamp_image_data')); - if (!$imageData) { + if (! $imageData) { return response()->json(['success' => false, 'message' => '이미지 데이터가 올바르지 않습니다.'], 422); } $gcs = app(GoogleCloudStorageService::class); - if (!$gcs->isAvailable()) { + if (! $gcs->isAvailable()) { return response()->json(['success' => false, 'message' => 'GCS 설정이 되어 있지 않습니다.'], 500); } @@ -189,7 +190,7 @@ public function uploadStamp(Request $request): JsonResponse ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); - if ($existing && !empty($existing->setting_value['gcs_object'])) { + if ($existing && ! empty($existing->setting_value['gcs_object'])) { $gcs->delete($existing->setting_value['gcs_object']); } @@ -201,7 +202,7 @@ public function uploadStamp(Request $request): JsonResponse $gcsUri = $gcs->upload($tmpFile, $gcsObject); unlink($tmpFile); - if (!$gcsUri) { + if (! $gcsUri) { return response()->json(['success' => false, 'message' => 'GCS 업로드에 실패했습니다.'], 500); } @@ -290,7 +291,7 @@ public function index(Request $request): JsonResponse if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") - ->orWhere('contract_code', 'like', "%{$search}%"); + ->orWhere('contract_code', 'like', "%{$search}%"); }); } @@ -307,7 +308,7 @@ 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')]) + ->with(['signers', 'signFields', 'auditLogs' => fn ($q) => $q->orderBy('created_at', 'desc')]) ->findOrFail($id); return response()->json(['success' => true, 'data' => $contract]); @@ -339,7 +340,7 @@ public function store(Request $request): JsonResponse $userId = auth()->id(); // 계약 코드 생성 - $contractCode = 'ES-' . date('Ymd') . '-' . strtoupper(Str::random(6)); + $contractCode = 'ES-'.date('Ymd').'-'.strtoupper(Str::random(6)); // PDF 파일 처리 $filePath = null; @@ -350,7 +351,7 @@ public function store(Request $request): JsonResponse if ($request->hasFile('file')) { // 사용자가 직접 업로드한 파일 우선 사용 (Word면 PDF 자동 변환) $file = $request->file('file'); - $converter = new DocxToPdfConverter(); + $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts"); $filePath = $result['path']; $fileName = $result['name']; @@ -364,7 +365,7 @@ public function store(Request $request): JsonResponse 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}"; + $newPath = "esign/{$tenantId}/contracts/".Str::random(40).".{$ext}"; Storage::disk('local')->copy($template->file_path, $newPath); $filePath = $newPath; @@ -418,7 +419,7 @@ public function store(Request $request): JsonResponse ->where('setting_key', 'company_stamp') ->first(); - if ($stampSetting && !empty($stampSetting->setting_value['gcs_object'])) { + if ($stampSetting && ! empty($stampSetting->setting_value['gcs_object'])) { $creatorSigner = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->where('role', 'creator') @@ -459,7 +460,9 @@ public function store(Request $request): JsonResponse foreach ($template->items as $item) { $signerId = $signerMap[$item->signer_order] ?? null; - if (!$signerId) continue; + if (! $signerId) { + continue; + } $fieldValue = null; if ($item->field_variable && isset($variableValues[$item->field_variable])) { @@ -592,7 +595,7 @@ public function trashed(Request $request): JsonResponse if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") - ->orWhere('contract_code', 'like', "%{$search}%"); + ->orWhere('contract_code', 'like', "%{$search}%"); }); } @@ -807,15 +810,15 @@ public function send(Request $request, int $id): JsonResponse $failures = []; foreach ($notificationResults as $nr) { foreach ($nr['results'] as $r) { - if (!$r['success']) { + if (! $r['success']) { $failures[] = "{$nr['signer_name']}: {$r['channel']} 실패 ({$r['error']})"; } } } $message = '서명 요청이 발송되었습니다.'; - if (!empty($failures)) { - $message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')'; + if (! empty($failures)) { + $message .= ' (일부 알림 실패: '.implode(', ', $failures).')'; } return response()->json([ @@ -880,7 +883,7 @@ public function remind(Request $request, int $id): JsonResponse $failures = []; foreach ($notificationResults as $nr) { foreach ($nr['results'] as $r) { - if (!$r['success']) { + if (! $r['success']) { $failures[] = "{$r['channel']} 실패 ({$r['error']})"; } } @@ -889,8 +892,8 @@ public function remind(Request $request, int $id): JsonResponse $message = $nextSigner ? "{$nextSigner->name}에게 리마인더가 발송되었습니다." : '리마인더가 기록되었습니다.'; - if (!empty($failures)) { - $message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')'; + if (! empty($failures)) { + $message .= ' (일부 알림 실패: '.implode(', ', $failures).')'; } return response()->json([ @@ -917,7 +920,7 @@ private function dispatchNotification( if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) { $alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder); $results[] = $alimtalkResult; - $alimtalkFailed = !($alimtalkResult['success'] ?? false); + $alimtalkFailed = ! ($alimtalkResult['success'] ?? false); } // 이메일 발송 조건: @@ -925,7 +928,7 @@ private function dispatchNotification( // 2) alimtalk인데 번호 없으면 폴백 // 3) alimtalk 발송 실패 시 이메일 자동 폴백 $shouldSendEmail = in_array($sendMethod, ['email', 'both']) - || ($sendMethod === 'alimtalk' && !$signer->phone) + || ($sendMethod === 'alimtalk' && ! $signer->phone) || ($sendMethod === 'alimtalk' && $alimtalkFailed); if ($shouldSendEmail && $signer->email) { @@ -956,18 +959,18 @@ private function sendAlimtalk( ): array { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); - if (!$member) { + if (! $member) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } - if (!$member->corp_num) { + if (! $member->biz_no) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '사업자번호 미설정 (알림톡 발송 불가)']; } $barobill = app(BarobillService::class); - $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); + $barobill->setServerMode($member->server_mode ?? 'production'); - $signUrl = config('app.url') . '/esign/sign/' . $signer->access_token; + $signUrl = config('app.url').'/esign/sign/'.$signer->access_token; $expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; $templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청'; @@ -981,8 +984,8 @@ private function sendAlimtalk( : ''; $result = $barobill->sendATKakaotalkEx( - corpNum: $member->corp_num, - senderId: $member->kakaotalk_sender_id ?? '', + corpNum: $member->biz_no, + senderId: '', templateName: $templateName, receiverName: $signer->name, receiverNum: preg_replace('/[^0-9]/', '', $signer->phone), @@ -999,12 +1002,13 @@ private function sendAlimtalk( smsMessage: $smsMessage, ); - if (!($result['success'] ?? false)) { + 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 호출 실패']; } @@ -1015,6 +1019,7 @@ private function sendAlimtalk( 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } @@ -1056,7 +1061,7 @@ public function uploadPdf(Request $request, int $id): JsonResponse } $file = $request->file('file'); - $converter = new DocxToPdfConverter(); + $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts"); $contract->update([ @@ -1082,7 +1087,7 @@ 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)) { + if (! $template->file_path || ! Storage::disk('local')->exists($template->file_path)) { abort(404, 'PDF 파일을 찾을 수 없습니다.'); } @@ -1161,7 +1166,7 @@ public function storeTemplate(Request $request): JsonResponse if ($includePdf && $contractId) { $sourceContract = EsignContract::forTenant($tenantId)->find($contractId); - if (!$sourceContract || !$sourceContract->original_file_path || !Storage::disk('local')->exists($sourceContract->original_file_path)) { + if (! $sourceContract || ! $sourceContract->original_file_path || ! Storage::disk('local')->exists($sourceContract->original_file_path)) { $sourceContract = null; } } @@ -1293,7 +1298,7 @@ public function uploadTemplatePdf(Request $request, int $id): JsonResponse } $file = $request->file('file'); - $converter = new DocxToPdfConverter(); + $converter = new DocxToPdfConverter; $result = $converter->convertAndStore($file, "esign/{$tenantId}/templates"); $template->update([ @@ -1449,7 +1454,7 @@ public function duplicateTemplate(int $id): JsonResponse $newTemplate = EsignFieldTemplate::create(array_merge([ 'tenant_id' => $tenantId, - 'name' => $template->name . ' (복사)', + 'name' => $template->name.' (복사)', 'description' => $template->description, 'category' => $template->category, 'signer_count' => $template->signer_count, @@ -1540,7 +1545,9 @@ public function applyTemplate(Request $request, int $id): JsonResponse // 템플릿 아이템 → 필드 생성 foreach ($template->items as $item) { $signerId = $signerMap[$item->signer_order] ?? null; - if (!$signerId) continue; + if (! $signerId) { + continue; + } // 변수가 바인딩된 필드는 자동 채움 $fieldValue = null; @@ -1616,7 +1623,9 @@ public function copyFieldsFromContract(Request $request, int $id, int $sourceId) // 소스 signer_id → sign_order → 대상 signer_id $signOrder = $sourceSignerOrderMap[$field->signer_id] ?? null; $targetSignerId = $signOrder ? ($targetSignerMap[$signOrder] ?? null) : null; - if (!$targetSignerId) continue; + if (! $targetSignerId) { + continue; + } // field_variable이 있으면 대상 계약의 변수 맵에서 값 조회 $fieldValue = null; @@ -1661,7 +1670,7 @@ private function buildVariableMap(EsignContract $contract, ?EsignFieldTemplate $ $map = []; // 시스템 변수: 서명자 정보 (역할 기반: 1=creator/갑/회사, 2=counterpart/을/파트너) - $signers = $contract->signers->sortBy(fn($s) => $s->role === 'creator' ? 1 : 2); + $signers = $contract->signers->sortBy(fn ($s) => $s->role === 'creator' ? 1 : 2); $idx = 1; foreach ($signers as $signer) { $map["signer{$idx}_name"] = $signer->name;