From 279a15bf0dd77e9224d0bd5c00bee485fc90e65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Feb 2026 10:24:09 +0900 Subject: [PATCH] =?UTF-8?q?refactor:E-Sign=20=EC=99=B8=EB=B6=80=20API=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20MNG=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Finance 패턴과 동일하게 MNG 직접 DB 접근 방식으로 변경 - MNG 모델 4개 추가: EsignContract, EsignSigner, EsignSignField, EsignAuditLog - EsignApiController 추가: stats, index, show, store, cancel, configureFields, send, download - 모든 뷰(dashboard, create, detail, fields, send)에서 외부 API URL 제거 - 기존 X-API-Key/Bearer 인증 대신 MNG 세션 인증(CSRF) 사용 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 293 ++++++++++++++++++ app/Models/ESign/EsignAuditLog.php | 38 +++ app/Models/ESign/EsignContract.php | 61 ++++ app/Models/ESign/EsignSignField.php | 46 +++ app/Models/ESign/EsignSigner.php | 65 ++++ resources/views/esign/create.blade.php | 26 +- resources/views/esign/dashboard.blade.php | 19 +- resources/views/esign/detail.blade.php | 12 +- resources/views/esign/fields.blade.php | 15 +- resources/views/esign/send.blade.php | 10 +- routes/web.php | 14 + 11 files changed, 552 insertions(+), 47 deletions(-) create mode 100644 app/Http/Controllers/ESign/EsignApiController.php create mode 100644 app/Models/ESign/EsignAuditLog.php create mode 100644 app/Models/ESign/EsignContract.php create mode 100644 app/Models/ESign/EsignSignField.php create mode 100644 app/Models/ESign/EsignSigner.php diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php new file mode 100644 index 00000000..6f0ad46d --- /dev/null +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -0,0 +1,293 @@ +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([ + '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([ + '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([ + '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(), + ]); + + // 첫 번째 서명자 상태 변경 + $nextSigner = $contract->signers()->orderBy('sign_order')->first(); + if ($nextSigner) { + $nextSigner->update(['status' => 'notified']); + } + + EsignAuditLog::create([ + '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' => '서명 요청이 발송되었습니다.']); + } + + /** + * 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', + ]); + } +} diff --git a/app/Models/ESign/EsignAuditLog.php b/app/Models/ESign/EsignAuditLog.php new file mode 100644 index 00000000..6179f780 --- /dev/null +++ b/app/Models/ESign/EsignAuditLog.php @@ -0,0 +1,38 @@ + 'array', + 'created_at' => 'datetime', + ]; + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signer(): BelongsTo + { + return $this->belongsTo(EsignSigner::class, 'signer_id'); + } +} diff --git a/app/Models/ESign/EsignContract.php b/app/Models/ESign/EsignContract.php new file mode 100644 index 00000000..8abe5a62 --- /dev/null +++ b/app/Models/ESign/EsignContract.php @@ -0,0 +1,61 @@ + 'integer', + 'expires_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function signers(): HasMany + { + return $this->hasMany(EsignSigner::class, 'contract_id'); + } + + public function signFields(): HasMany + { + return $this->hasMany(EsignSignField::class, 'contract_id'); + } + + public function auditLogs(): HasMany + { + return $this->hasMany(EsignAuditLog::class, 'contract_id'); + } + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/app/Models/ESign/EsignSignField.php b/app/Models/ESign/EsignSignField.php new file mode 100644 index 00000000..31eff2af --- /dev/null +++ b/app/Models/ESign/EsignSignField.php @@ -0,0 +1,46 @@ + 'integer', + 'position_x' => 'decimal:4', + 'position_y' => 'decimal:4', + 'width' => 'decimal:4', + 'height' => 'decimal:4', + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signer(): BelongsTo + { + return $this->belongsTo(EsignSigner::class, 'signer_id'); + } +} diff --git a/app/Models/ESign/EsignSigner.php b/app/Models/ESign/EsignSigner.php new file mode 100644 index 00000000..2260571d --- /dev/null +++ b/app/Models/ESign/EsignSigner.php @@ -0,0 +1,65 @@ + 'integer', + 'otp_attempts' => 'integer', + 'token_expires_at' => 'datetime', + 'otp_expires_at' => 'datetime', + 'auth_verified_at' => 'datetime', + 'signed_at' => 'datetime', + 'consent_agreed_at' => 'datetime', + ]; + + protected $hidden = [ + 'access_token', + 'otp_code', + ]; + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signFields(): HasMany + { + return $this->hasMany(EsignSignField::class, 'signer_id'); + } + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index 6ff96c0a..b080c48d 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -15,17 +15,7 @@