fix: [esign] 알림톡 발송 실패 수정 (corp_num → biz_no)

- BarobillMember의 실제 컬럼명은 biz_no인데 corp_num으로 접근하여 항상 null
- is_test_mode → server_mode로 수정
- kakaotalk_sender_id → 빈 문자열 (기본 발신프로필 사용)
This commit is contained in:
김보곤
2026-02-24 00:46:29 +09:00
parent 514e23930e
commit 748a9d1807

View File

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