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', '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', ]); $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')) { $file = $request->file('file'); $fileName = $file->getClientOriginalName(); $fileSize = $file->getSize(); $fileHash = hash_file('sha256', $file->getRealPath()); $filePath = $file->store("esign/{$tenantId}/contracts", 'local'); } $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', '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', ]); } // 감사 로그 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'), ]); } /** * 계약 취소 */ 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' => '계약이 취소되었습니다.']); } /** * 서명 위치 설정 */ 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.*.is_required' => 'nullable|boolean', ]); $tenantId = session('selected_tenant_id', 1); $contract = EsignContract::forTenant($tenantId)->findOrFail($id); // 기존 필드 삭제 후 새로 생성 EsignSignField::where('contract_id', $contract->id)->delete(); foreach ($request->input('fields') as $i => $field) { 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, '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); } $contract->update([ 'status' => 'pending', 'updated_by' => auth()->id(), ]); // 서명 순서 유형에 따라 알림 발송 if ($contract->sign_order_type === 'parallel') { // 동시 서명: 모든 서명자에게 발송 foreach ($contract->signers as $s) { $s->update(['status' => 'notified']); Mail::to($s->email)->send(new EsignRequestMail($contract, $s)); } } else { // 순차 서명: 첫 번째 서명자에게만 발송 $nextSigner = $contract->signers()->orderBy('sign_order')->first(); if ($nextSigner) { $nextSigner->update(['status' => 'notified']); Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); } } 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()], 'created_at' => now(), ]); return response()->json(['success' => true, 'message' => '서명 요청이 발송되었습니다.']); } /** * 리마인더 발송 */ 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(); if ($nextSigner) { $nextSigner->update(['status' => 'notified']); Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner, isReminder: true)); } 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, ], 'created_at' => now(), ]); return response()->json([ 'success' => true, 'message' => $nextSigner ? "{$nextSigner->name}에게 리마인더가 발송되었습니다." : '리마인더가 기록되었습니다.', ]); } /** * 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', ]); } // ─── 필드 템플릿 관련 메서드 ─── /** * 템플릿 목록 조회 */ 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', 'items' => 'required|array|min:1', '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.*.is_required' => 'nullable|boolean', ]); $tenantId = session('selected_tenant_id', 1); // items에서 최대 signer_order를 추출하여 signer_count 결정 $items = $request->input('items'); $signerCount = max(array_column($items, 'signer_order')); $template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount) { $template = EsignFieldTemplate::create([ 'tenant_id' => $tenantId, 'name' => $request->input('name'), 'description' => $request->input('description'), 'category' => $request->input('category'), 'signer_count' => $signerCount, 'is_active' => true, 'created_by' => auth()->id(), ]); 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, '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', ]); $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); $template->update([ 'name' => $request->input('name'), 'description' => $request->input('description'), 'category' => $request->input('category'), ]); return response()->json([ 'success' => true, 'message' => '템플릿이 수정되었습니다.', 'data' => $template->fresh()->load('creator:id,name'), ]); } /** * 템플릿 복제 */ 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) { $newTemplate = EsignFieldTemplate::create([ 'tenant_id' => $tenantId, 'name' => $template->name . ' (복사)', 'description' => $template->description, 'category' => $template->category, 'signer_count' => $template->signer_count, 'is_active' => true, 'created_by' => auth()->id(), ]); 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, '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; } DB::transaction(function () use ($contract, $template, $tenantId, $signerMap) { // 기존 필드 삭제 EsignSignField::where('contract_id', $contract->id)->delete(); // 템플릿 아이템 → 필드 생성 foreach ($template->items as $item) { $signerId = $signerMap[$item->signer_order] ?? null; if (!$signerId) continue; 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, '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; } DB::transaction(function () use ($targetContract, $sourceContract, $tenantId, $sourceSignerOrderMap, $targetSignerMap) { // 기존 필드 삭제 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; 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, '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, ]); } }