- 테넌트(고객) 검색 API 추가 (searchTenants) - 계약번호 자동 채번 API 추가 (CONTRACT-YYYYMMDD-N 형식) - 고객 서비스이용 계약서 선택 시 "고객 불러오기" 버튼 표시 - 고객 선택 시 상호/사업자등록번호/주소/전화번호 자동 채움 - 총개발비 기본값 20,000,000 / 월구독료 기본값 500,000 자동 세팅 - TenantSearchModal 컴포넌트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1672 lines
64 KiB
PHP
1672 lines
64 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\ESign;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Mail\EsignRequestMail;
|
|
use App\Models\Barobill\BarobillMember;
|
|
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\Tenants\Tenant;
|
|
use App\Models\Tenants\TenantSetting;
|
|
use App\Services\Barobill\BarobillService;
|
|
use App\Services\GoogleCloudStorageService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
class EsignApiController extends Controller
|
|
{
|
|
/**
|
|
* 영업파트너 검색
|
|
*/
|
|
public function searchPartners(Request $request): JsonResponse
|
|
{
|
|
$q = trim($request->input('q', ''));
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$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']));
|
|
})
|
|
->with(['salesPartner', 'userRoles' => function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)->with('role');
|
|
}]);
|
|
|
|
if ($q !== '') {
|
|
$query->where(function ($w) use ($q) {
|
|
$w->where('name', 'like', "%{$q}%")
|
|
->orWhere('email', 'like', "%{$q}%")
|
|
->orWhere('phone', 'like', "%{$q}%");
|
|
});
|
|
}
|
|
|
|
$users = $query->orderBy('name')->limit(20)->get();
|
|
|
|
$roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저'];
|
|
|
|
$data = $users->map(function ($user) use ($roleLabels) {
|
|
$sp = $user->salesPartner;
|
|
$roles = $user->userRoles->map(fn($ur) => $roleLabels[$ur->role?->name] ?? null)->filter()->values();
|
|
return [
|
|
'id' => $user->id,
|
|
'name' => $user->name,
|
|
'phone' => $user->phone,
|
|
'email' => $user->email,
|
|
'company_name' => $sp?->company_name,
|
|
'biz_no' => $sp?->biz_no,
|
|
'address' => $sp?->address,
|
|
'position' => $roles->implode('/'),
|
|
];
|
|
});
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
}
|
|
|
|
/**
|
|
* 고객(테넌트) 검색
|
|
*/
|
|
public function searchTenants(Request $request): JsonResponse
|
|
{
|
|
$q = trim($request->input('q', ''));
|
|
|
|
$query = Tenant::whereNull('deleted_at');
|
|
|
|
if ($q !== '') {
|
|
$query->where(function ($w) use ($q) {
|
|
$w->where('company_name', 'like', "%{$q}%")
|
|
->orWhere('business_num', 'like', "%{$q}%")
|
|
->orWhere('phone', 'like', "%{$q}%")
|
|
->orWhere('ceo_name', 'like', "%{$q}%");
|
|
});
|
|
}
|
|
|
|
$tenants = $query->orderBy('company_name')->limit(20)->get();
|
|
|
|
$data = $tenants->map(fn($t) => [
|
|
'id' => $t->id,
|
|
'company_name' => $t->company_name,
|
|
'business_num' => $t->business_num,
|
|
'ceo_name' => $t->ceo_name,
|
|
'address' => $t->address,
|
|
'phone' => $t->phone,
|
|
'email' => $t->email,
|
|
]);
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
}
|
|
|
|
/**
|
|
* 계약번호 자동 채번 (CONTRACT-YYYY-MMDD-N)
|
|
*/
|
|
public function generateContractNumber(): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$today = now()->format('Ymd');
|
|
$prefix = "CONTRACT-{$today}-";
|
|
|
|
$lastContract = EsignContract::where('tenant_id', $tenantId)
|
|
->where('contract_code', 'like', "{$prefix}%")
|
|
->orderByRaw("CAST(SUBSTRING(contract_code, ?) AS UNSIGNED) DESC", [strlen($prefix) + 1])
|
|
->first();
|
|
|
|
$seq = 1;
|
|
if ($lastContract) {
|
|
$lastSeq = (int) str_replace($prefix, '', $lastContract->contract_code);
|
|
$seq = $lastSeq + 1;
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => ['contract_number' => $prefix . $seq],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 법인도장 조회
|
|
*/
|
|
public function getStamp(): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$setting = TenantSetting::where('tenant_id', $tenantId)
|
|
->where('setting_group', 'esign')
|
|
->where('setting_key', 'company_stamp')
|
|
->first();
|
|
|
|
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) {
|
|
return response()->json(['success' => true, 'data' => null]);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'image_url' => $signedUrl,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 법인도장 업로드
|
|
*/
|
|
public function uploadStamp(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'stamp_image_data' => 'required|string',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$imageData = base64_decode($request->input('stamp_image_data'));
|
|
|
|
if (!$imageData) {
|
|
return response()->json(['success' => false, 'message' => '이미지 데이터가 올바르지 않습니다.'], 422);
|
|
}
|
|
|
|
$gcs = app(GoogleCloudStorageService::class);
|
|
if (!$gcs->isAvailable()) {
|
|
return response()->json(['success' => false, 'message' => 'GCS 설정이 되어 있지 않습니다.'], 500);
|
|
}
|
|
|
|
// 기존 GCS 파일 삭제
|
|
$existing = TenantSetting::where('tenant_id', $tenantId)
|
|
->where('setting_group', 'esign')
|
|
->where('setting_key', 'company_stamp')
|
|
->first();
|
|
if ($existing && !empty($existing->setting_value['gcs_object'])) {
|
|
$gcs->delete($existing->setting_value['gcs_object']);
|
|
}
|
|
|
|
// 임시 파일에 저장 후 GCS 업로드
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'stamp_');
|
|
file_put_contents($tmpFile, $imageData);
|
|
|
|
$gcsObject = "esign/{$tenantId}/stamps/company_stamp.png";
|
|
$gcsUri = $gcs->upload($tmpFile, $gcsObject);
|
|
unlink($tmpFile);
|
|
|
|
if (!$gcsUri) {
|
|
return response()->json(['success' => false, 'message' => 'GCS 업로드에 실패했습니다.'], 500);
|
|
}
|
|
|
|
TenantSetting::updateOrCreate(
|
|
[
|
|
'tenant_id' => $tenantId,
|
|
'setting_group' => 'esign',
|
|
'setting_key' => 'company_stamp',
|
|
],
|
|
[
|
|
'setting_value' => ['gcs_object' => $gcsObject],
|
|
'updated_by' => auth()->id(),
|
|
]
|
|
);
|
|
|
|
$signedUrl = $gcs->getSignedUrl($gcsObject, 60);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '법인도장이 등록되었습니다.',
|
|
'data' => [
|
|
'image_url' => $signedUrl,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 법인도장 삭제
|
|
*/
|
|
public function deleteStamp(): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$setting = TenantSetting::where('tenant_id', $tenantId)
|
|
->where('setting_group', 'esign')
|
|
->where('setting_key', 'company_stamp')
|
|
->first();
|
|
|
|
if ($setting) {
|
|
$gcsObject = $setting->setting_value['gcs_object'] ?? null;
|
|
if ($gcsObject) {
|
|
$gcs = app(GoogleCloudStorageService::class);
|
|
$gcs->delete($gcsObject);
|
|
}
|
|
$setting->delete();
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '법인도장이 삭제되었습니다.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 상태별 통계
|
|
*/
|
|
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',
|
|
'template_id' => 'nullable|integer',
|
|
'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',
|
|
'metadata' => 'nullable|array',
|
|
'metadata.*' => 'nullable|string|max:500',
|
|
'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480',
|
|
]);
|
|
|
|
$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')) {
|
|
// 사용자가 직접 업로드한 파일 우선 사용 (Word면 PDF 자동 변환)
|
|
$file = $request->file('file');
|
|
$converter = new DocxToPdfConverter();
|
|
$result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts");
|
|
$filePath = $result['path'];
|
|
$fileName = $result['name'];
|
|
$fileHash = $result['hash'];
|
|
$fileSize = $result['size'];
|
|
} elseif ($request->input('template_id')) {
|
|
// 템플릿에 PDF가 있으면 복사
|
|
$template = EsignFieldTemplate::forTenant($tenantId)
|
|
->where('is_active', true)
|
|
->find($request->input('template_id'));
|
|
|
|
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}";
|
|
Storage::disk('local')->copy($template->file_path, $newPath);
|
|
|
|
$filePath = $newPath;
|
|
$fileName = $template->file_name;
|
|
$fileHash = $template->file_hash;
|
|
$fileSize = $template->file_size;
|
|
}
|
|
}
|
|
|
|
$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',
|
|
'metadata' => $request->input('metadata'),
|
|
'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',
|
|
]);
|
|
}
|
|
|
|
// 법인도장 자동 적용: GCS에서 다운로드 → 로컬 저장 → signer에 설정
|
|
$stampSetting = TenantSetting::where('tenant_id', $tenantId)
|
|
->where('setting_group', 'esign')
|
|
->where('setting_key', 'company_stamp')
|
|
->first();
|
|
|
|
if ($stampSetting && !empty($stampSetting->setting_value['gcs_object'])) {
|
|
$creatorSigner = EsignSigner::withoutGlobalScopes()
|
|
->where('contract_id', $contract->id)
|
|
->where('role', 'creator')
|
|
->first();
|
|
|
|
if ($creatorSigner) {
|
|
$gcs = app(GoogleCloudStorageService::class);
|
|
$signedUrl = $gcs->getSignedUrl($stampSetting->setting_value['gcs_object'], 5);
|
|
|
|
if ($signedUrl) {
|
|
$imageData = @file_get_contents($signedUrl);
|
|
if ($imageData) {
|
|
$localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
|
|
Storage::disk('local')->put($localPath, $imageData);
|
|
$creatorSigner->update(['signature_image_path' => $localPath]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 템플릿 자동 적용 (template_id가 있으면 필드 자동 생성)
|
|
$autoApplied = false;
|
|
if ($request->input('template_id')) {
|
|
$template = EsignFieldTemplate::forTenant($tenantId)
|
|
->where('is_active', true)->with('items')
|
|
->find($request->input('template_id'));
|
|
|
|
if ($template && $template->items->isNotEmpty()) {
|
|
$contract->loadMissing('signers');
|
|
// 템플릿 signer_order를 역할(role)로 매핑: 1=creator(갑/회사), 2=counterpart(을/파트너)
|
|
$signerMap = [];
|
|
foreach ($contract->signers as $signer) {
|
|
$templateOrder = $signer->role === 'creator' ? 1 : 2;
|
|
$signerMap[$templateOrder] = $signer->id;
|
|
}
|
|
|
|
$variableValues = $this->buildVariableMap($contract, $template);
|
|
|
|
foreach ($template->items as $item) {
|
|
$signerId = $signerMap[$item->signer_order] ?? null;
|
|
if (!$signerId) continue;
|
|
|
|
$fieldValue = null;
|
|
if ($item->field_variable && isset($variableValues[$item->field_variable])) {
|
|
$fieldValue = $variableValues[$item->field_variable];
|
|
}
|
|
|
|
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,
|
|
'field_variable' => $item->field_variable,
|
|
'font_size' => $item->font_size,
|
|
'text_align' => $item->text_align ?? 'L',
|
|
'field_value' => $fieldValue,
|
|
'is_required' => $item->is_required,
|
|
'sort_order' => $item->sort_order,
|
|
]);
|
|
}
|
|
$autoApplied = true;
|
|
}
|
|
}
|
|
|
|
// 감사 로그
|
|
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'),
|
|
'auto_applied' => $autoApplied,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 계약 취소
|
|
*/
|
|
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' => '계약이 취소되었습니다.']);
|
|
}
|
|
|
|
/**
|
|
* 계약 삭제 - 휴지통으로 이동 (SoftDelete)
|
|
*/
|
|
public function destroy(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'ids' => 'required|array|min:1',
|
|
'ids.*' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$ids = $request->input('ids');
|
|
|
|
$contracts = EsignContract::forTenant($tenantId)->whereIn('id', $ids)->get();
|
|
|
|
if ($contracts->isEmpty()) {
|
|
return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
// 진행 중인 계약(pending, partially_signed) 차단
|
|
$activeContracts = $contracts->filter(fn ($c) => in_array($c->status, ['pending', 'partially_signed']));
|
|
if ($activeContracts->isNotEmpty()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.',
|
|
], 422);
|
|
}
|
|
|
|
$deletedCount = 0;
|
|
foreach ($contracts as $contract) {
|
|
$contract->update(['deleted_by' => auth()->id()]);
|
|
$contract->delete(); // SoftDelete → deleted_at 설정
|
|
$deletedCount++;
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "{$deletedCount}건의 계약이 휴지통으로 이동되었습니다.",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 휴지통 목록
|
|
*/
|
|
public function trashed(Request $request): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$query = EsignContract::onlyTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['signers:id,contract_id,name,role,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('deleted_at', 'desc')->paginate($perPage);
|
|
|
|
return response()->json(['success' => true, 'data' => $data]);
|
|
}
|
|
|
|
/**
|
|
* 휴지통에서 복구
|
|
*/
|
|
public function restore(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'ids' => 'required|array|min:1',
|
|
'ids.*' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$contracts = EsignContract::onlyTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $request->input('ids'))
|
|
->get();
|
|
|
|
if ($contracts->isEmpty()) {
|
|
return response()->json(['success' => false, 'message' => '복구할 계약을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
$restoredCount = 0;
|
|
foreach ($contracts as $contract) {
|
|
$contract->update(['deleted_by' => null]);
|
|
$contract->restore();
|
|
$restoredCount++;
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "{$restoredCount}건의 계약이 복구되었습니다.",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 영구 삭제
|
|
*/
|
|
public function forceDestroy(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'ids' => 'required|array|min:1',
|
|
'ids.*' => 'required|integer',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$contracts = EsignContract::onlyTrashed()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('id', $request->input('ids'))
|
|
->get();
|
|
|
|
if ($contracts->isEmpty()) {
|
|
return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404);
|
|
}
|
|
|
|
$deletedCount = 0;
|
|
foreach ($contracts as $contract) {
|
|
// 관련 파일 삭제
|
|
if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) {
|
|
Storage::disk('local')->delete($contract->original_file_path);
|
|
}
|
|
if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) {
|
|
Storage::disk('local')->delete($contract->signed_file_path);
|
|
}
|
|
|
|
// 서명 이미지 파일 삭제
|
|
$signers = EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->get();
|
|
foreach ($signers as $signer) {
|
|
if ($signer->signature_image_path && Storage::disk('local')->exists($signer->signature_image_path)) {
|
|
Storage::disk('local')->delete($signer->signature_image_path);
|
|
}
|
|
}
|
|
|
|
// 관련 레코드 영구 삭제
|
|
EsignSignField::where('contract_id', $contract->id)->delete();
|
|
EsignAuditLog::where('contract_id', $contract->id)->delete();
|
|
EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->delete();
|
|
$contract->forceDelete();
|
|
$deletedCount++;
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "{$deletedCount}건의 계약이 영구 삭제되었습니다.",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 서명 위치 설정
|
|
*/
|
|
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.*.field_variable' => 'nullable|string|max:50',
|
|
'fields.*.font_size' => 'nullable|integer|min:6|max:72',
|
|
'fields.*.text_align' => 'nullable|string|in:L,C,R',
|
|
'fields.*.is_required' => 'nullable|boolean',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id);
|
|
|
|
// 변수 맵 구성 (field_variable → 실제 값 매핑)
|
|
$variableValues = $this->buildVariableMap($contract);
|
|
|
|
// 기존 필드 삭제 후 새로 생성
|
|
EsignSignField::where('contract_id', $contract->id)->delete();
|
|
|
|
foreach ($request->input('fields') as $i => $field) {
|
|
// field_variable이 있으면 변수 맵에서 값 조회
|
|
$fieldValue = null;
|
|
$fieldVariable = $field['field_variable'] ?? null;
|
|
if ($fieldVariable && isset($variableValues[$fieldVariable])) {
|
|
$fieldValue = $variableValues[$fieldVariable];
|
|
}
|
|
|
|
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,
|
|
'field_variable' => $fieldVariable,
|
|
'font_size' => $field['font_size'] ?? null,
|
|
'text_align' => $field['text_align'] ?? 'L',
|
|
'field_value' => $fieldValue,
|
|
'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);
|
|
}
|
|
|
|
$sendMethod = $request->input('send_method', 'alimtalk');
|
|
$smsFallback = $request->boolean('sms_fallback', true);
|
|
|
|
$contract->update([
|
|
'status' => 'pending',
|
|
'send_method' => $sendMethod,
|
|
'sms_fallback' => $smsFallback,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
// 발송 대상 서명자 결정
|
|
if ($contract->sign_order_type === 'parallel') {
|
|
$targetSigners = $contract->signers;
|
|
} else {
|
|
$first = $contract->signers()->orderBy('sign_order')->first();
|
|
$targetSigners = $first ? collect([$first]) : collect();
|
|
}
|
|
|
|
$notificationResults = [];
|
|
foreach ($targetSigners as $signer) {
|
|
$signer->update(['status' => 'notified']);
|
|
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
|
|
$notificationResults[] = [
|
|
'signer_id' => $signer->id,
|
|
'signer_name' => $signer->name,
|
|
'results' => $results,
|
|
];
|
|
}
|
|
|
|
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(),
|
|
'send_method' => $sendMethod,
|
|
'notification_results' => $notificationResults,
|
|
],
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
// 실패한 알림 확인
|
|
$failures = [];
|
|
foreach ($notificationResults as $nr) {
|
|
foreach ($nr['results'] as $r) {
|
|
if (!$r['success']) {
|
|
$failures[] = "{$nr['signer_name']}: {$r['channel']} 실패 ({$r['error']})";
|
|
}
|
|
}
|
|
}
|
|
|
|
$message = '서명 요청이 발송되었습니다.';
|
|
if (!empty($failures)) {
|
|
$message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')';
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => $message,
|
|
'notification_results' => $notificationResults,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 리마인더 발송
|
|
*/
|
|
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();
|
|
|
|
$notificationResults = [];
|
|
if ($nextSigner) {
|
|
$nextSigner->update(['status' => 'notified']);
|
|
$results = $this->dispatchNotification(
|
|
$contract, $nextSigner,
|
|
$contract->send_method ?? 'alimtalk',
|
|
$contract->sms_fallback ?? true,
|
|
isReminder: true,
|
|
);
|
|
$notificationResults[] = [
|
|
'signer_id' => $nextSigner->id,
|
|
'signer_name' => $nextSigner->name,
|
|
'results' => $results,
|
|
];
|
|
}
|
|
|
|
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,
|
|
'notification_results' => $notificationResults,
|
|
],
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
// 실패 확인
|
|
$failures = [];
|
|
foreach ($notificationResults as $nr) {
|
|
foreach ($nr['results'] as $r) {
|
|
if (!$r['success']) {
|
|
$failures[] = "{$r['channel']} 실패 ({$r['error']})";
|
|
}
|
|
}
|
|
}
|
|
|
|
$message = $nextSigner
|
|
? "{$nextSigner->name}에게 리마인더가 발송되었습니다."
|
|
: '리마인더가 기록되었습니다.';
|
|
if (!empty($failures)) {
|
|
$message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')';
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => $message,
|
|
'notification_results' => $notificationResults,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 발송 방식에 따라 알림톡/이메일 분기 발송
|
|
*/
|
|
private function dispatchNotification(
|
|
EsignContract $contract,
|
|
EsignSigner $signer,
|
|
string $sendMethod,
|
|
bool $smsFallback,
|
|
bool $isReminder = false,
|
|
): array {
|
|
$results = [];
|
|
|
|
// 알림톡 발송
|
|
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
|
|
$results[] = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
|
|
}
|
|
|
|
// 이메일 발송 (email/both 선택 시, 또는 알림톡인데 번호 없으면 폴백)
|
|
if (in_array($sendMethod, ['email', 'both']) || ($sendMethod === 'alimtalk' && !$signer->phone)) {
|
|
if ($signer->email) {
|
|
try {
|
|
Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder));
|
|
$results[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
|
} catch (\Throwable $e) {
|
|
\Log::warning('E-Sign 이메일 발송 실패', [
|
|
'contract_id' => $contract->id,
|
|
'signer_id' => $signer->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$results[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* 알림톡 발송
|
|
*/
|
|
private function sendAlimtalk(
|
|
EsignContract $contract,
|
|
EsignSigner $signer,
|
|
bool $smsFallback = true,
|
|
bool $isReminder = false,
|
|
): array {
|
|
try {
|
|
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
|
if (!$member) {
|
|
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
|
}
|
|
|
|
$barobill = app(BarobillService::class);
|
|
$barobill->setServerMode($member->is_test_mode ? 'test' : 'production');
|
|
|
|
$signUrl = config('app.url') . '/esign/sign/' . $signer->access_token;
|
|
$expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
|
|
|
$templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청';
|
|
|
|
$message = $isReminder
|
|
? "안녕하세요, {$signer->name}님.\n아직 서명이 완료되지 않은 전자계약이 있습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$expires}\n\n기한 내에 서명을 완료해 주세요."
|
|
: "안녕하세요, {$signer->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$expires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.";
|
|
|
|
$smsMessage = $smsFallback
|
|
? "[SAM] {$signer->name}님, 전자계약 서명 요청이 도착했습니다. {$signUrl}"
|
|
: '';
|
|
|
|
$result = $barobill->sendATKakaotalkEx(
|
|
corpNum: $member->corp_num,
|
|
senderId: $member->kakaotalk_sender_id ?? '',
|
|
templateName: $templateName,
|
|
receiverName: $signer->name,
|
|
receiverNum: preg_replace('/[^0-9]/', '', $signer->phone),
|
|
title: $isReminder ? '전자계약 리마인드' : '전자계약 서명 요청',
|
|
message: $message,
|
|
buttons: [
|
|
[
|
|
'Name' => $isReminder ? '서명하기' : '계약서 확인하기',
|
|
'ButtonType' => 'WL',
|
|
'Url1' => $signUrl,
|
|
'Url2' => $signUrl,
|
|
],
|
|
],
|
|
smsMessage: $smsMessage,
|
|
);
|
|
|
|
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 호출 실패'];
|
|
}
|
|
|
|
return ['success' => true, 'channel' => 'alimtalk', 'error' => null];
|
|
} catch (\Throwable $e) {
|
|
\Log::warning('E-Sign 알림톡 발송 실패', [
|
|
'contract_id' => $contract->id,
|
|
'signer_id' => $signer->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* PDF 업로드 (PDF 없이 생성된 계약에 나중에 업로드)
|
|
*/
|
|
public function uploadPdf(Request $request, int $id): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'file' => 'required|file|mimes:pdf,doc,docx|max:20480',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$contract = EsignContract::forTenant($tenantId)->findOrFail($id);
|
|
|
|
if ($contract->original_file_path) {
|
|
return response()->json(['success' => false, 'message' => '이미 PDF 파일이 존재합니다.'], 422);
|
|
}
|
|
|
|
$file = $request->file('file');
|
|
$converter = new DocxToPdfConverter();
|
|
$result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts");
|
|
|
|
$contract->update([
|
|
'original_file_path' => $result['path'],
|
|
'original_file_name' => $result['name'],
|
|
'original_file_hash' => $result['hash'],
|
|
'original_file_size' => $result['size'],
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'PDF 파일이 업로드되었습니다.',
|
|
'data' => ['path' => $result['path'], 'name' => $result['name']],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 PDF 다운로드
|
|
*/
|
|
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)) {
|
|
abort(404, 'PDF 파일을 찾을 수 없습니다.');
|
|
}
|
|
|
|
$fileName = $template->file_name ?: 'template.pdf';
|
|
|
|
return Storage::disk('local')->download($template->file_path, $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',
|
|
'include_pdf' => 'nullable|boolean',
|
|
'contract_id' => 'nullable|integer',
|
|
'signer_count' => 'nullable|integer|min:1|max:6',
|
|
'variables' => 'nullable|array',
|
|
'items' => 'nullable|array',
|
|
'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.*.field_variable' => 'nullable|string|max:50',
|
|
'items.*.font_size' => 'nullable|integer|min:6|max:72',
|
|
'items.*.text_align' => 'nullable|string|in:L,C,R',
|
|
'items.*.is_required' => 'nullable|boolean',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
// signer_count 결정: 직접 지정 > items에서 추출 > 기본값 2
|
|
$items = $request->input('items', []);
|
|
$signerCount = $request->input('signer_count')
|
|
?: (count($items) > 0 ? max(array_column($items, 'signer_order')) : 2);
|
|
|
|
// PDF 포함 여부 확인
|
|
$includePdf = $request->boolean('include_pdf');
|
|
$contractId = $request->input('contract_id');
|
|
$sourceContract = null;
|
|
|
|
if ($includePdf && $contractId) {
|
|
$sourceContract = EsignContract::forTenant($tenantId)->find($contractId);
|
|
if (!$sourceContract || !$sourceContract->original_file_path || !Storage::disk('local')->exists($sourceContract->original_file_path)) {
|
|
$sourceContract = null;
|
|
}
|
|
}
|
|
|
|
$template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount, $sourceContract) {
|
|
$fileData = [];
|
|
|
|
if ($sourceContract) {
|
|
$timestamp = now()->format('YmdHis');
|
|
$ext = pathinfo($sourceContract->original_file_path, PATHINFO_EXTENSION) ?: 'pdf';
|
|
$newPath = "esign/{$tenantId}/templates/{$timestamp}.{$ext}";
|
|
Storage::disk('local')->copy($sourceContract->original_file_path, $newPath);
|
|
|
|
$fileData = [
|
|
'file_path' => $newPath,
|
|
'file_name' => $sourceContract->original_file_name,
|
|
'file_hash' => $sourceContract->original_file_hash,
|
|
'file_size' => $sourceContract->original_file_size,
|
|
];
|
|
}
|
|
|
|
$template = EsignFieldTemplate::create(array_merge([
|
|
'tenant_id' => $tenantId,
|
|
'name' => $request->input('name'),
|
|
'description' => $request->input('description'),
|
|
'category' => $request->input('category'),
|
|
'signer_count' => $signerCount,
|
|
'variables' => $request->input('variables', []),
|
|
'is_active' => true,
|
|
'created_by' => auth()->id(),
|
|
], $fileData));
|
|
|
|
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,
|
|
'field_variable' => $item['field_variable'] ?? null,
|
|
'font_size' => $item['font_size'] ?? null,
|
|
'text_align' => $item['text_align'] ?? 'L',
|
|
'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',
|
|
'variables' => 'nullable|array',
|
|
'variables.*.key' => 'required|string|max:50',
|
|
'variables.*.label' => 'required|string|max:100',
|
|
'variables.*.type' => 'nullable|in:text,number,date',
|
|
'variables.*.default' => 'nullable|string|max:500',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
|
|
|
|
$updateData = [
|
|
'name' => $request->input('name'),
|
|
'description' => $request->input('description'),
|
|
'category' => $request->input('category'),
|
|
];
|
|
|
|
if ($request->has('variables')) {
|
|
$updateData['variables'] = $request->input('variables');
|
|
}
|
|
|
|
$template->update($updateData);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '템플릿이 수정되었습니다.',
|
|
'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 PDF 교체
|
|
*/
|
|
public function uploadTemplatePdf(Request $request, int $id): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'file' => 'required|file|mimes:pdf,doc,docx|max:20480',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
|
|
|
|
// 기존 파일 삭제
|
|
if ($template->file_path && Storage::disk('local')->exists($template->file_path)) {
|
|
Storage::disk('local')->delete($template->file_path);
|
|
}
|
|
|
|
$file = $request->file('file');
|
|
$converter = new DocxToPdfConverter();
|
|
$result = $converter->convertAndStore($file, "esign/{$tenantId}/templates");
|
|
|
|
$template->update([
|
|
'file_path' => $result['path'],
|
|
'file_name' => $result['name'],
|
|
'file_hash' => $result['hash'],
|
|
'file_size' => $result['size'],
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'PDF가 교체되었습니다.',
|
|
'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 PDF 제거
|
|
*/
|
|
public function removeTemplatePdf(int $id): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
|
|
|
|
if ($template->file_path && Storage::disk('local')->exists($template->file_path)) {
|
|
Storage::disk('local')->delete($template->file_path);
|
|
}
|
|
|
|
$template->update([
|
|
'file_path' => null,
|
|
'file_name' => null,
|
|
'file_hash' => null,
|
|
'file_size' => null,
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'PDF가 제거되었습니다.',
|
|
'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 필드 아이템 삭제
|
|
*/
|
|
public function destroyTemplateItem(int $templateId, int $itemId): JsonResponse
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId);
|
|
$item = EsignFieldTemplateItem::where('template_id', $template->id)->findOrFail($itemId);
|
|
|
|
$item->delete();
|
|
|
|
// signer_count 재계산
|
|
$maxOrder = EsignFieldTemplateItem::where('template_id', $template->id)->max('signer_order');
|
|
$template->update(['signer_count' => $maxOrder ?: 0]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '필드가 삭제되었습니다.',
|
|
'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 필드 아이템 일괄 저장 (에디터에서 사용)
|
|
*/
|
|
public function updateTemplateItems(Request $request, int $templateId): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'items' => 'present|array',
|
|
'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.*.field_variable' => 'nullable|string|max:50',
|
|
'items.*.font_size' => 'nullable|integer|min:6|max:72',
|
|
'items.*.text_align' => 'nullable|string|in:L,C,R',
|
|
'items.*.is_required' => 'nullable|boolean',
|
|
]);
|
|
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId);
|
|
|
|
$items = $request->input('items', []);
|
|
|
|
DB::transaction(function () use ($template, $items) {
|
|
// 기존 아이템 삭제
|
|
EsignFieldTemplateItem::where('template_id', $template->id)->delete();
|
|
|
|
// 새 아이템 생성
|
|
foreach ($items as $i => $itemData) {
|
|
EsignFieldTemplateItem::create([
|
|
'template_id' => $template->id,
|
|
'signer_order' => $itemData['signer_order'],
|
|
'page_number' => $itemData['page_number'],
|
|
'position_x' => round($itemData['position_x'], 2),
|
|
'position_y' => round($itemData['position_y'], 2),
|
|
'width' => round($itemData['width'], 2),
|
|
'height' => round($itemData['height'], 2),
|
|
'field_type' => $itemData['field_type'],
|
|
'field_label' => $itemData['field_label'] ?? '',
|
|
'field_variable' => $itemData['field_variable'] ?? null,
|
|
'font_size' => $itemData['font_size'] ?? null,
|
|
'text_align' => $itemData['text_align'] ?? 'L',
|
|
'is_required' => $itemData['is_required'] ?? true,
|
|
'sort_order' => $i,
|
|
]);
|
|
}
|
|
|
|
// signer_count 업데이트
|
|
$maxOrder = collect($items)->max('signer_order') ?: 0;
|
|
$template->update(['signer_count' => $maxOrder]);
|
|
});
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '템플릿 필드가 저장되었습니다.',
|
|
'data' => $template->fresh()->load('items'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 복제
|
|
*/
|
|
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) {
|
|
$fileData = [];
|
|
if ($template->file_path && Storage::disk('local')->exists($template->file_path)) {
|
|
$timestamp = now()->format('YmdHis');
|
|
$ext = pathinfo($template->file_path, PATHINFO_EXTENSION) ?: 'pdf';
|
|
$newPath = "esign/{$tenantId}/templates/{$timestamp}_copy.{$ext}";
|
|
Storage::disk('local')->copy($template->file_path, $newPath);
|
|
|
|
$fileData = [
|
|
'file_path' => $newPath,
|
|
'file_name' => $template->file_name,
|
|
'file_hash' => $template->file_hash,
|
|
'file_size' => $template->file_size,
|
|
];
|
|
}
|
|
|
|
$newTemplate = EsignFieldTemplate::create(array_merge([
|
|
'tenant_id' => $tenantId,
|
|
'name' => $template->name . ' (복사)',
|
|
'description' => $template->description,
|
|
'category' => $template->category,
|
|
'signer_count' => $template->signer_count,
|
|
'variables' => $template->variables,
|
|
'is_active' => true,
|
|
'created_by' => auth()->id(),
|
|
], $fileData));
|
|
|
|
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,
|
|
'field_variable' => $item->field_variable,
|
|
'font_size' => $item->font_size,
|
|
'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;
|
|
}
|
|
|
|
// 변수 해석용 맵 구성
|
|
$variableValues = $this->buildVariableMap($contract, $template);
|
|
|
|
DB::transaction(function () use ($contract, $template, $tenantId, $signerMap, $variableValues) {
|
|
// 기존 필드 삭제
|
|
EsignSignField::where('contract_id', $contract->id)->delete();
|
|
|
|
// 템플릿 아이템 → 필드 생성
|
|
foreach ($template->items as $item) {
|
|
$signerId = $signerMap[$item->signer_order] ?? null;
|
|
if (!$signerId) continue;
|
|
|
|
// 변수가 바인딩된 필드는 자동 채움
|
|
$fieldValue = null;
|
|
if ($item->field_variable && isset($variableValues[$item->field_variable])) {
|
|
$fieldValue = $variableValues[$item->field_variable];
|
|
}
|
|
|
|
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,
|
|
'field_variable' => $item->field_variable,
|
|
'font_size' => $item->font_size,
|
|
'field_value' => $fieldValue,
|
|
'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;
|
|
}
|
|
|
|
// 대상 계약 기준 변수 맵 구성
|
|
$variableValues = $this->buildVariableMap($targetContract);
|
|
|
|
DB::transaction(function () use ($targetContract, $sourceContract, $tenantId, $sourceSignerOrderMap, $targetSignerMap, $variableValues) {
|
|
// 기존 필드 삭제
|
|
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;
|
|
|
|
// field_variable이 있으면 대상 계약의 변수 맵에서 값 조회
|
|
$fieldValue = null;
|
|
if ($field->field_variable && isset($variableValues[$field->field_variable])) {
|
|
$fieldValue = $variableValues[$field->field_variable];
|
|
}
|
|
|
|
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,
|
|
'field_variable' => $field->field_variable,
|
|
'font_size' => $field->font_size,
|
|
'field_value' => $fieldValue,
|
|
'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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 변수 해석 맵 구성 (시스템 변수 + 커스텀 변수)
|
|
*/
|
|
private function buildVariableMap(EsignContract $contract, ?EsignFieldTemplate $template = null): array
|
|
{
|
|
$map = [];
|
|
|
|
// 시스템 변수: 서명자 정보 (역할 기반: 1=creator/갑/회사, 2=counterpart/을/파트너)
|
|
$signers = $contract->signers->sortBy(fn($s) => $s->role === 'creator' ? 1 : 2);
|
|
$idx = 1;
|
|
foreach ($signers as $signer) {
|
|
$map["signer{$idx}_name"] = $signer->name;
|
|
$map["signer{$idx}_email"] = $signer->email;
|
|
$map["signer{$idx}_phone"] = $signer->phone ?? '';
|
|
$idx++;
|
|
}
|
|
|
|
// 시스템 변수: 계약 정보
|
|
$map['contract_title'] = $contract->title ?? '';
|
|
$map['current_date'] = now()->format('Y년 n월 j일');
|
|
$map['expires_at'] = $contract->expires_at ? $contract->expires_at->format('Y년 n월 j일') : '';
|
|
|
|
// 커스텀 변수: contract.metadata에서 조회
|
|
$metadata = $contract->metadata ?? [];
|
|
foreach ($metadata as $key => $value) {
|
|
$map[$key] = $value ?? '';
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
}
|