refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\ESign;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
|
|
use App\Models\ESign\EsignContract;
|
|
|
|
|
use App\Models\ESign\EsignSigner;
|
|
|
|
|
use App\Models\ESign\EsignSignField;
|
|
|
|
|
use App\Models\ESign\EsignAuditLog;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
|
|
|
|
|
class EsignApiController extends Controller
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* 상태별 통계
|
|
|
|
|
*/
|
|
|
|
|
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',
|
|
|
|
|
'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([
|
2026-02-12 15:56:41 +09:00
|
|
|
'tenant_id' => $tenantId,
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
'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([
|
2026-02-12 15:56:41 +09:00
|
|
|
'tenant_id' => $tenantId,
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
'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([
|
2026-02-12 16:04:56 +09:00
|
|
|
'tenant_id' => $tenantId,
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
'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([
|
2026-02-12 15:56:41 +09:00
|
|
|
'tenant_id' => $tenantId,
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
'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' => '서명 요청이 발송되었습니다.']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:10:16 +09:00
|
|
|
/**
|
|
|
|
|
* 리마인더 발송
|
|
|
|
|
*/
|
|
|
|
|
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']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}에게 리마인더가 발송되었습니다."
|
|
|
|
|
: '리마인더가 기록되었습니다.',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- 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 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|