feat:E-Sign 전자계약 서명 솔루션 백엔드 구현
- 마이그레이션 4개 (esign_contracts, esign_signers, esign_sign_fields, esign_audit_logs) - 모델 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog) - 서비스 4개 (EsignContractService, EsignSignService, EsignPdfService, EsignAuditService) - 컨트롤러 2개 (EsignContractController, EsignSignController) - FormRequest 4개 (ContractStore, FieldConfigure, SignSubmit, SignReject) - Mail 1개 (EsignRequestMail + 이메일 템플릿) - API 라우트 (인증 계약 관리 + 토큰 기반 서명 프로세스) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
116
app/Http/Controllers/Api/V1/ESign/EsignContractController.php
Normal file
116
app/Http/Controllers/Api/V1/ESign/EsignContractController.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\ESign;
|
||||||
|
|
||||||
|
use App\Helpers\ApiResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\ESign\ContractStoreRequest;
|
||||||
|
use App\Http\Requests\ESign\FieldConfigureRequest;
|
||||||
|
use App\Services\ESign\EsignContractService;
|
||||||
|
use App\Services\ESign\EsignPdfService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class EsignContractController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EsignContractService $service,
|
||||||
|
private EsignPdfService $pdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
return $this->service->list($request->all());
|
||||||
|
}, __('message.fetched'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
return $this->service->show($id);
|
||||||
|
}, __('message.fetched'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ContractStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
return $this->service->create($request->validated() + ['file' => $request->file('file')]);
|
||||||
|
}, __('message.created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
return $this->service->cancel($id);
|
||||||
|
}, __('message.esign.cancelled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureFields(int $id, FieldConfigureRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id, $request) {
|
||||||
|
return $this->service->configureFields($id, $request->validated()['fields']);
|
||||||
|
}, __('message.esign.fields_configured'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
return $this->service->send($id);
|
||||||
|
}, __('message.esign.sent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remind(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
return $this->service->remind($id);
|
||||||
|
}, __('message.esign.reminded'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stats(): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () {
|
||||||
|
return $this->service->stats();
|
||||||
|
}, __('message.fetched'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(int $id): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$contract = $this->service->show($id);
|
||||||
|
$filePath = $contract->signed_file_path ?? $contract->original_file_path;
|
||||||
|
|
||||||
|
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
|
||||||
|
return ApiResponse::error(__('error.esign.file_not_found'), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = $contract->original_file_name ?? 'contract.pdf';
|
||||||
|
|
||||||
|
return Storage::disk('local')->download($filePath, $fileName);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ApiResponse::error($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$contract = $this->service->show($id);
|
||||||
|
|
||||||
|
if (! $contract->original_file_path || ! $contract->original_file_hash) {
|
||||||
|
return ['verified' => false, 'message' => '파일 정보가 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValid = $this->pdfService->verifyIntegrity(
|
||||||
|
$contract->original_file_path,
|
||||||
|
$contract->original_file_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'verified' => $isValid,
|
||||||
|
'original_hash' => $contract->original_file_hash,
|
||||||
|
];
|
||||||
|
}, __('message.esign.verified'));
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Http/Controllers/Api/V1/ESign/EsignSignController.php
Normal file
75
app/Http/Controllers/Api/V1/ESign/EsignSignController.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\ESign;
|
||||||
|
|
||||||
|
use App\Helpers\ApiResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\ESign\SignRejectRequest;
|
||||||
|
use App\Http\Requests\ESign\SignSubmitRequest;
|
||||||
|
use App\Services\ESign\EsignSignService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class EsignSignController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EsignSignService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getContract(string $token): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($token) {
|
||||||
|
return $this->service->getByToken($token);
|
||||||
|
}, __('message.fetched'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendOtp(string $token): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($token) {
|
||||||
|
return $this->service->sendOtp($token);
|
||||||
|
}, __('message.esign.otp_sent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyOtp(string $token, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($token, $request) {
|
||||||
|
$request->validate(['otp_code' => 'required|string|size:6']);
|
||||||
|
|
||||||
|
return $this->service->verifyOtp($token, $request->input('otp_code'));
|
||||||
|
}, __('message.esign.otp_verified'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDocument(string $token): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $this->service->getByToken($token);
|
||||||
|
$contract = $data['contract'];
|
||||||
|
$filePath = $contract->original_file_path;
|
||||||
|
|
||||||
|
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
|
||||||
|
return ApiResponse::error(__('error.esign.file_not_found'), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Storage::disk('local')->response($filePath, null, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ApiResponse::error($e->getMessage(), $e->getCode() ?: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(string $token, SignSubmitRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($token, $request) {
|
||||||
|
return $this->service->submitSignature($token, $request->validated());
|
||||||
|
}, __('message.esign.signed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(string $token, SignRejectRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($token, $request) {
|
||||||
|
return $this->service->reject($token, $request->validated()['reason']);
|
||||||
|
}, __('message.esign.rejected'));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Requests/ESign/ContractStoreRequest.php
Normal file
45
app/Http/Requests/ESign/ContractStoreRequest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ESign;
|
||||||
|
|
||||||
|
use App\Models\ESign\EsignContract;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ContractStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'required|string|max:200',
|
||||||
|
'description' => 'nullable|string|max:2000',
|
||||||
|
'sign_order_type' => 'nullable|string|in:' . implode(',', EsignContract::SIGN_ORDERS),
|
||||||
|
'file' => 'required|file|mimes:pdf|max:20480',
|
||||||
|
'expires_at' => 'nullable|date|after:now',
|
||||||
|
'creator_name' => 'required|string|max:100',
|
||||||
|
'creator_email' => 'required|email|max:255',
|
||||||
|
'creator_phone' => 'nullable|string|max:20',
|
||||||
|
'counterpart_name' => 'required|string|max:100',
|
||||||
|
'counterpart_email' => 'required|email|max:255',
|
||||||
|
'counterpart_phone' => 'nullable|string|max:20',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title.required' => __('validation.required', ['attribute' => '계약 제목']),
|
||||||
|
'file.required' => __('validation.required', ['attribute' => 'PDF 파일']),
|
||||||
|
'file.mimes' => __('validation.mimes', ['attribute' => '파일', 'values' => 'PDF']),
|
||||||
|
'file.max' => __('validation.max.file', ['attribute' => '파일', 'max' => '20MB']),
|
||||||
|
'creator_name.required' => __('validation.required', ['attribute' => '작성자 이름']),
|
||||||
|
'creator_email.required' => __('validation.required', ['attribute' => '작성자 이메일']),
|
||||||
|
'counterpart_name.required' => __('validation.required', ['attribute' => '상대방 이름']),
|
||||||
|
'counterpart_email.required' => __('validation.required', ['attribute' => '상대방 이메일']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Requests/ESign/FieldConfigureRequest.php
Normal file
39
app/Http/Requests/ESign/FieldConfigureRequest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ESign;
|
||||||
|
|
||||||
|
use App\Models\ESign\EsignSignField;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class FieldConfigureRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fields' => 'required|array|min:1',
|
||||||
|
'fields.*.signer_id' => 'required|integer|exists:esign_signers,id',
|
||||||
|
'fields.*.page_number' => 'required|integer|min:1',
|
||||||
|
'fields.*.position_x' => 'required|numeric|min:0|max:100',
|
||||||
|
'fields.*.position_y' => 'required|numeric|min:0|max:100',
|
||||||
|
'fields.*.width' => 'required|numeric|min:1|max:100',
|
||||||
|
'fields.*.height' => 'required|numeric|min:1|max:100',
|
||||||
|
'fields.*.field_type' => 'nullable|string|in:' . implode(',', EsignSignField::FIELD_TYPES),
|
||||||
|
'fields.*.field_label' => 'nullable|string|max:100',
|
||||||
|
'fields.*.is_required' => 'nullable|boolean',
|
||||||
|
'fields.*.sort_order' => 'nullable|integer|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'fields.required' => __('validation.required', ['attribute' => '서명 필드']),
|
||||||
|
'fields.min' => '최소 1개 이상의 서명 필드가 필요합니다.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/ESign/SignRejectRequest.php
Normal file
27
app/Http/Requests/ESign/SignRejectRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ESign;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SignRejectRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason' => 'required|string|max:1000',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason.required' => __('validation.required', ['attribute' => '거절 사유']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/ESign/SignSubmitRequest.php
Normal file
27
app/Http/Requests/ESign/SignSubmitRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\ESign;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SignSubmitRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'signature_image' => 'required|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'signature_image.required' => __('validation.required', ['attribute' => '서명 이미지']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Mail/EsignRequestMail.php
Normal file
44
app/Mail/EsignRequestMail.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\ESign\EsignContract;
|
||||||
|
use App\Models\ESign\EsignSigner;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class EsignRequestMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public EsignContract $contract,
|
||||||
|
public EsignSigner $signer,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: "[SAM] 전자계약 서명 요청 - {$this->contract->title}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
$signUrl = config('app.mng_url', config('app.url'))
|
||||||
|
. '/esign/sign/' . $this->signer->access_token;
|
||||||
|
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.esign.request',
|
||||||
|
with: [
|
||||||
|
'contractTitle' => $this->contract->title,
|
||||||
|
'signerName' => $this->signer->name,
|
||||||
|
'signUrl' => $signUrl,
|
||||||
|
'expiresAt' => $this->contract->expires_at?->format('Y-m-d H:i'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Models/ESign/EsignAuditLog.php
Normal file
57
app/Models/ESign/EsignAuditLog.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\ESign;
|
||||||
|
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class EsignAuditLog extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $table = 'esign_audit_logs';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
// 액션 상수
|
||||||
|
public const ACTION_CREATED = 'created';
|
||||||
|
public const ACTION_SENT = 'sent';
|
||||||
|
public const ACTION_VIEWED = 'viewed';
|
||||||
|
public const ACTION_OTP_SENT = 'otp_sent';
|
||||||
|
public const ACTION_AUTHENTICATED = 'authenticated';
|
||||||
|
public const ACTION_SIGNED = 'signed';
|
||||||
|
public const ACTION_REJECTED = 'rejected';
|
||||||
|
public const ACTION_COMPLETED = 'completed';
|
||||||
|
public const ACTION_CANCELLED = 'cancelled';
|
||||||
|
public const ACTION_REMINDED = 'reminded';
|
||||||
|
public const ACTION_DOWNLOADED = 'downloaded';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'contract_id',
|
||||||
|
'signer_id',
|
||||||
|
'action',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Relations ===
|
||||||
|
|
||||||
|
public function contract(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EsignContract::class, 'contract_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function signer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EsignSigner::class, 'signer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/Models/ESign/EsignContract.php
Normal file
133
app/Models/ESign/EsignContract.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\ESign;
|
||||||
|
|
||||||
|
use App\Models\Members\User;
|
||||||
|
use App\Traits\Auditable;
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class EsignContract extends Model
|
||||||
|
{
|
||||||
|
use Auditable, BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'esign_contracts';
|
||||||
|
|
||||||
|
// 상태 상수
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
public const STATUS_PARTIALLY_SIGNED = 'partially_signed';
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
public const STATUSES = [
|
||||||
|
self::STATUS_DRAFT,
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::STATUS_PARTIALLY_SIGNED,
|
||||||
|
self::STATUS_COMPLETED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 서명 순서 상수
|
||||||
|
public const SIGN_ORDER_COUNTERPART_FIRST = 'counterpart_first';
|
||||||
|
public const SIGN_ORDER_CREATOR_FIRST = 'creator_first';
|
||||||
|
|
||||||
|
public const SIGN_ORDERS = [
|
||||||
|
self::SIGN_ORDER_COUNTERPART_FIRST,
|
||||||
|
self::SIGN_ORDER_CREATOR_FIRST,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'contract_code',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'sign_order_type',
|
||||||
|
'original_file_path',
|
||||||
|
'original_file_name',
|
||||||
|
'original_file_hash',
|
||||||
|
'original_file_size',
|
||||||
|
'signed_file_path',
|
||||||
|
'signed_file_hash',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'completed_at',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
'deleted_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'original_file_size' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Relations ===
|
||||||
|
|
||||||
|
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 creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Scopes ===
|
||||||
|
|
||||||
|
public function scopeStatus($query, string $status)
|
||||||
|
{
|
||||||
|
return $query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotIn('status', [
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSign(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, [
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::STATUS_PARTIALLY_SIGNED,
|
||||||
|
]) && ! $this->isExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextSigner(): ?EsignSigner
|
||||||
|
{
|
||||||
|
return $this->signers()
|
||||||
|
->whereIn('status', [EsignSigner::STATUS_WAITING, EsignSigner::STATUS_NOTIFIED, EsignSigner::STATUS_AUTHENTICATED])
|
||||||
|
->orderBy('sign_order')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Models/ESign/EsignSignField.php
Normal file
67
app/Models/ESign/EsignSignField.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\ESign;
|
||||||
|
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class EsignSignField extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $table = 'esign_sign_fields';
|
||||||
|
|
||||||
|
// 필드 유형 상수
|
||||||
|
public const TYPE_SIGNATURE = 'signature';
|
||||||
|
public const TYPE_STAMP = 'stamp';
|
||||||
|
public const TYPE_TEXT = 'text';
|
||||||
|
public const TYPE_DATE = 'date';
|
||||||
|
public const TYPE_CHECKBOX = 'checkbox';
|
||||||
|
|
||||||
|
public const FIELD_TYPES = [
|
||||||
|
self::TYPE_SIGNATURE,
|
||||||
|
self::TYPE_STAMP,
|
||||||
|
self::TYPE_TEXT,
|
||||||
|
self::TYPE_DATE,
|
||||||
|
self::TYPE_CHECKBOX,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'contract_id',
|
||||||
|
'signer_id',
|
||||||
|
'page_number',
|
||||||
|
'position_x',
|
||||||
|
'position_y',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'field_type',
|
||||||
|
'field_label',
|
||||||
|
'field_value',
|
||||||
|
'is_required',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'page_number' => 'integer',
|
||||||
|
'position_x' => 'decimal:2',
|
||||||
|
'position_y' => 'decimal:2',
|
||||||
|
'width' => 'decimal:2',
|
||||||
|
'height' => 'decimal:2',
|
||||||
|
'is_required' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Relations ===
|
||||||
|
|
||||||
|
public function contract(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EsignContract::class, 'contract_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function signer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EsignSigner::class, 'signer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Models/ESign/EsignSigner.php
Normal file
104
app/Models/ESign/EsignSigner.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\ESign;
|
||||||
|
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class EsignSigner extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $table = 'esign_signers';
|
||||||
|
|
||||||
|
// 역할 상수
|
||||||
|
public const ROLE_CREATOR = 'creator';
|
||||||
|
public const ROLE_COUNTERPART = 'counterpart';
|
||||||
|
|
||||||
|
// 상태 상수
|
||||||
|
public const STATUS_WAITING = 'waiting';
|
||||||
|
public const STATUS_NOTIFIED = 'notified';
|
||||||
|
public const STATUS_AUTHENTICATED = 'authenticated';
|
||||||
|
public const STATUS_SIGNED = 'signed';
|
||||||
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
public const STATUSES = [
|
||||||
|
self::STATUS_WAITING,
|
||||||
|
self::STATUS_NOTIFIED,
|
||||||
|
self::STATUS_AUTHENTICATED,
|
||||||
|
self::STATUS_SIGNED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'contract_id',
|
||||||
|
'role',
|
||||||
|
'sign_order',
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'access_token',
|
||||||
|
'token_expires_at',
|
||||||
|
'otp_code',
|
||||||
|
'otp_expires_at',
|
||||||
|
'otp_attempts',
|
||||||
|
'auth_verified_at',
|
||||||
|
'signature_image_path',
|
||||||
|
'signed_at',
|
||||||
|
'consent_agreed_at',
|
||||||
|
'sign_ip_address',
|
||||||
|
'sign_user_agent',
|
||||||
|
'status',
|
||||||
|
'rejected_reason',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'sign_order' => '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',
|
||||||
|
];
|
||||||
|
|
||||||
|
// === Relations ===
|
||||||
|
|
||||||
|
public function contract(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EsignContract::class, 'contract_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function signFields(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EsignSignField::class, 'signer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
public function isVerified(): bool
|
||||||
|
{
|
||||||
|
return $this->auth_verified_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasSigned(): bool
|
||||||
|
{
|
||||||
|
return $this->signed_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSign(): bool
|
||||||
|
{
|
||||||
|
return $this->isVerified()
|
||||||
|
&& ! $this->hasSigned()
|
||||||
|
&& $this->status !== self::STATUS_REJECTED
|
||||||
|
&& $this->contract->canSign();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Services/ESign/EsignAuditService.php
Normal file
58
app/Services/ESign/EsignAuditService.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\ESign;
|
||||||
|
|
||||||
|
use App\Models\ESign\EsignAuditLog;
|
||||||
|
use App\Services\Service;
|
||||||
|
|
||||||
|
class EsignAuditService extends Service
|
||||||
|
{
|
||||||
|
public function log(
|
||||||
|
int $contractId,
|
||||||
|
string $action,
|
||||||
|
?int $signerId = null,
|
||||||
|
?array $metadata = null
|
||||||
|
): EsignAuditLog {
|
||||||
|
$request = request();
|
||||||
|
|
||||||
|
return EsignAuditLog::create([
|
||||||
|
'tenant_id' => $this->tenantId(),
|
||||||
|
'contract_id' => $contractId,
|
||||||
|
'signer_id' => $signerId,
|
||||||
|
'action' => $action,
|
||||||
|
'ip_address' => $request?->ip(),
|
||||||
|
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logPublic(
|
||||||
|
int $tenantId,
|
||||||
|
int $contractId,
|
||||||
|
string $action,
|
||||||
|
?int $signerId = null,
|
||||||
|
?array $metadata = null
|
||||||
|
): EsignAuditLog {
|
||||||
|
$request = request();
|
||||||
|
|
||||||
|
return EsignAuditLog::withoutGlobalScopes()->create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'contract_id' => $contractId,
|
||||||
|
'signer_id' => $signerId,
|
||||||
|
'action' => $action,
|
||||||
|
'ip_address' => $request?->ip(),
|
||||||
|
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContractLogs(int $contractId): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return EsignAuditLog::where('contract_id', $contractId)
|
||||||
|
->with('signer:id,name,email,role')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
298
app/Services/ESign/EsignContractService.php
Normal file
298
app/Services/ESign/EsignContractService.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\ESign;
|
||||||
|
|
||||||
|
use App\Mail\EsignRequestMail;
|
||||||
|
use App\Models\ESign\EsignAuditLog;
|
||||||
|
use App\Models\ESign\EsignContract;
|
||||||
|
use App\Models\ESign\EsignSignField;
|
||||||
|
use App\Models\ESign\EsignSigner;
|
||||||
|
use App\Services\Service;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class EsignContractService extends Service
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EsignAuditService $auditService,
|
||||||
|
private EsignPdfService $pdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function list(array $params): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = EsignContract::query()
|
||||||
|
->with(['signers:id,contract_id,name,email,role,status,signed_at', 'creator:id,name'])
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if (! empty($params['status'])) {
|
||||||
|
$query->where('status', $params['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($params['search'])) {
|
||||||
|
$search = $params['search'];
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('title', 'like', "%{$search}%")
|
||||||
|
->orWhere('contract_code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($params['date_from'])) {
|
||||||
|
$query->whereDate('created_at', '>=', $params['date_from']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($params['date_to'])) {
|
||||||
|
$query->whereDate('created_at', '<=', $params['date_to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$perPage = $params['per_page'] ?? 20;
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
|
$counts = EsignContract::where('tenant_id', $tenantId)
|
||||||
|
->selectRaw('status, COUNT(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('count', 'status')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => array_sum($counts),
|
||||||
|
'draft' => $counts[EsignContract::STATUS_DRAFT] ?? 0,
|
||||||
|
'pending' => $counts[EsignContract::STATUS_PENDING] ?? 0,
|
||||||
|
'partially_signed' => $counts[EsignContract::STATUS_PARTIALLY_SIGNED] ?? 0,
|
||||||
|
'completed' => $counts[EsignContract::STATUS_COMPLETED] ?? 0,
|
||||||
|
'expired' => $counts[EsignContract::STATUS_EXPIRED] ?? 0,
|
||||||
|
'cancelled' => $counts[EsignContract::STATUS_CANCELLED] ?? 0,
|
||||||
|
'rejected' => $counts[EsignContract::STATUS_REJECTED] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): EsignContract
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||||
|
// PDF 파일 저장
|
||||||
|
$file = $data['file'];
|
||||||
|
$filePath = $file->store("esign/{$tenantId}/originals", 'local');
|
||||||
|
$fileHash = hash_file('sha256', $file->getRealPath());
|
||||||
|
|
||||||
|
// 계약 코드 생성
|
||||||
|
$contractCode = 'ES-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
|
||||||
|
|
||||||
|
// 서명 순서 설정
|
||||||
|
$signOrderType = $data['sign_order_type'] ?? EsignContract::SIGN_ORDER_COUNTERPART_FIRST;
|
||||||
|
|
||||||
|
$contract = EsignContract::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'contract_code' => $contractCode,
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'sign_order_type' => $signOrderType,
|
||||||
|
'original_file_path' => $filePath,
|
||||||
|
'original_file_name' => $file->getClientOriginalName(),
|
||||||
|
'original_file_hash' => $fileHash,
|
||||||
|
'original_file_size' => $file->getSize(),
|
||||||
|
'status' => EsignContract::STATUS_DRAFT,
|
||||||
|
'expires_at' => $data['expires_at'] ?? now()->addDays(14),
|
||||||
|
'created_by' => $userId,
|
||||||
|
'updated_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 서명자 생성 - 작성자 (creator)
|
||||||
|
$creatorOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 1 : 2;
|
||||||
|
$counterpartOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 2 : 1;
|
||||||
|
|
||||||
|
EsignSigner::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'role' => EsignSigner::ROLE_CREATOR,
|
||||||
|
'sign_order' => $creatorOrder,
|
||||||
|
'name' => $data['creator_name'],
|
||||||
|
'email' => $data['creator_email'],
|
||||||
|
'phone' => $data['creator_phone'] ?? null,
|
||||||
|
'access_token' => Str::random(128),
|
||||||
|
'token_expires_at' => $contract->expires_at,
|
||||||
|
'status' => EsignSigner::STATUS_WAITING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 서명자 생성 - 상대방 (counterpart)
|
||||||
|
EsignSigner::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'role' => EsignSigner::ROLE_COUNTERPART,
|
||||||
|
'sign_order' => $counterpartOrder,
|
||||||
|
'name' => $data['counterpart_name'],
|
||||||
|
'email' => $data['counterpart_email'],
|
||||||
|
'phone' => $data['counterpart_phone'] ?? null,
|
||||||
|
'access_token' => Str::random(128),
|
||||||
|
'token_expires_at' => $contract->expires_at,
|
||||||
|
'status' => EsignSigner::STATUS_WAITING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CREATED);
|
||||||
|
|
||||||
|
return $contract->fresh(['signers', 'creator:id,name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id): EsignContract
|
||||||
|
{
|
||||||
|
$contract = EsignContract::with([
|
||||||
|
'signers',
|
||||||
|
'signFields',
|
||||||
|
'auditLogs' => fn ($q) => $q->orderBy('created_at', 'desc'),
|
||||||
|
'auditLogs.signer:id,name,email,role',
|
||||||
|
'creator:id,name',
|
||||||
|
])->find($id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(int $id): EsignContract
|
||||||
|
{
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($id, $userId) {
|
||||||
|
$contract = EsignContract::find($id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contract->status === EsignContract::STATUS_COMPLETED) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.already_completed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contract->status === EsignContract::STATUS_CANCELLED) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.already_cancelled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract->update([
|
||||||
|
'status' => EsignContract::STATUS_CANCELLED,
|
||||||
|
'updated_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CANCELLED);
|
||||||
|
|
||||||
|
return $contract->fresh(['signers', 'creator:id,name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(int $id): EsignContract
|
||||||
|
{
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($id, $userId) {
|
||||||
|
$contract = EsignContract::with('signers')->find($id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($contract->status, [EsignContract::STATUS_DRAFT])) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.invalid_status_for_send'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서명 필드가 설정되어 있는지 확인
|
||||||
|
$fieldsCount = EsignSignField::where('contract_id', $contract->id)->count();
|
||||||
|
if ($fieldsCount === 0) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.no_sign_fields'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract->update([
|
||||||
|
'status' => EsignContract::STATUS_PENDING,
|
||||||
|
'updated_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 첫 번째 서명자에게 알림 발송
|
||||||
|
$nextSigner = $contract->getNextSigner();
|
||||||
|
if ($nextSigner) {
|
||||||
|
$nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]);
|
||||||
|
|
||||||
|
Mail::to($nextSigner->email)->queue(
|
||||||
|
new EsignRequestMail($contract, $nextSigner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditService->log($contract->id, EsignAuditLog::ACTION_SENT);
|
||||||
|
|
||||||
|
return $contract->fresh(['signers', 'creator:id,name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remind(int $id): EsignContract
|
||||||
|
{
|
||||||
|
$contract = EsignContract::with('signers')->find($id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $contract->canSign()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.cannot_remind'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextSigner = $contract->getNextSigner();
|
||||||
|
if ($nextSigner) {
|
||||||
|
Mail::to($nextSigner->email)->queue(
|
||||||
|
new EsignRequestMail($contract, $nextSigner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditService->log($contract->id, EsignAuditLog::ACTION_REMINDED);
|
||||||
|
|
||||||
|
return $contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureFields(int $id, array $fields): EsignContract
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($id, $fields) {
|
||||||
|
$contract = EsignContract::with('signers')->find($id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contract->status !== EsignContract::STATUS_DRAFT) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.fields_only_in_draft'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 필드 삭제 후 재생성
|
||||||
|
EsignSignField::where('contract_id', $contract->id)->delete();
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
EsignSignField::create([
|
||||||
|
'tenant_id' => $contract->tenant_id,
|
||||||
|
'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'] ?? EsignSignField::TYPE_SIGNATURE,
|
||||||
|
'field_label' => $field['field_label'] ?? null,
|
||||||
|
'is_required' => $field['is_required'] ?? true,
|
||||||
|
'sort_order' => $field['sort_order'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contract->fresh(['signers', 'signFields']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Services/ESign/EsignPdfService.php
Normal file
48
app/Services/ESign/EsignPdfService.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\ESign;
|
||||||
|
|
||||||
|
use App\Services\Service;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class EsignPdfService extends Service
|
||||||
|
{
|
||||||
|
public function generateHash(string $filePath): string
|
||||||
|
{
|
||||||
|
$fullPath = Storage::disk('local')->path($filePath);
|
||||||
|
|
||||||
|
return hash_file('sha256', $fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyIntegrity(string $filePath, string $expectedHash): bool
|
||||||
|
{
|
||||||
|
$actualHash = $this->generateHash($filePath);
|
||||||
|
|
||||||
|
return hash_equals($expectedHash, $actualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function composeSigned(
|
||||||
|
string $originalPath,
|
||||||
|
array $signerImages,
|
||||||
|
array $signFields
|
||||||
|
): string {
|
||||||
|
// FPDI/FPDF 기반 PDF 합성 - 추후 구현
|
||||||
|
// 현재는 원본 파일을 signed 경로로 복사
|
||||||
|
$signedPath = str_replace('originals/', 'signed/', $originalPath);
|
||||||
|
$signedDir = dirname(Storage::disk('local')->path($signedPath));
|
||||||
|
|
||||||
|
if (! is_dir($signedDir)) {
|
||||||
|
mkdir($signedDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk('local')->copy($originalPath, $signedPath);
|
||||||
|
|
||||||
|
return $signedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addAuditPage(string $pdfPath, array $auditData): string
|
||||||
|
{
|
||||||
|
// 감사 증적 페이지 추가 - 추후 FPDI/FPDF로 구현
|
||||||
|
return $pdfPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/Services/ESign/EsignSignService.php
Normal file
274
app/Services/ESign/EsignSignService.php
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\ESign;
|
||||||
|
|
||||||
|
use App\Mail\EsignRequestMail;
|
||||||
|
use App\Models\ESign\EsignAuditLog;
|
||||||
|
use App\Models\ESign\EsignContract;
|
||||||
|
use App\Models\ESign\EsignSigner;
|
||||||
|
use App\Services\Service;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class EsignSignService extends Service
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EsignAuditService $auditService,
|
||||||
|
private EsignPdfService $pdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getByToken(string $token): array
|
||||||
|
{
|
||||||
|
$signer = EsignSigner::withoutGlobalScopes()
|
||||||
|
->where('access_token', $token)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $signer) {
|
||||||
|
throw new NotFoundHttpException(__('error.esign.invalid_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signer->token_expires_at && $signer->token_expires_at->isPast()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.token_expired'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = EsignContract::withoutGlobalScopes()
|
||||||
|
->with(['signers:id,contract_id,name,role,status,signed_at'])
|
||||||
|
->find($signer->contract_id);
|
||||||
|
|
||||||
|
if (! $contract) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_VIEWED,
|
||||||
|
$signer->id
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'contract' => $contract,
|
||||||
|
'signer' => $signer,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendOtp(string $token): array
|
||||||
|
{
|
||||||
|
$signer = $this->findSignerByToken($token);
|
||||||
|
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||||
|
|
||||||
|
if (! $contract || ! $contract->canSign()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.contract_not_signable'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP 생성 (6자리)
|
||||||
|
$otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$signer->update([
|
||||||
|
'otp_code' => $otpCode,
|
||||||
|
'otp_expires_at' => now()->addMinutes(5),
|
||||||
|
'otp_attempts' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// OTP 이메일 발송 (실제 발송은 추후 Mail 클래스에서)
|
||||||
|
// 현재는 개발 편의를 위해 로그에만 기록
|
||||||
|
\Illuminate\Support\Facades\Log::info("E-Sign OTP: {$otpCode} for {$signer->email}");
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_OTP_SENT,
|
||||||
|
$signer->id,
|
||||||
|
['email' => $signer->email]
|
||||||
|
);
|
||||||
|
|
||||||
|
return ['message' => __('message.esign.otp_sent')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyOtp(string $token, string $otpCode): array
|
||||||
|
{
|
||||||
|
$signer = $this->findSignerByToken($token);
|
||||||
|
|
||||||
|
if ($signer->otp_attempts >= 5) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.otp_max_attempts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $signer->otp_code || ! $signer->otp_expires_at) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.otp_not_sent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signer->otp_expires_at->isPast()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.otp_expired'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$signer->increment('otp_attempts');
|
||||||
|
|
||||||
|
if ($signer->otp_code !== $otpCode) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.otp_invalid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||||
|
|
||||||
|
$signer->update([
|
||||||
|
'auth_verified_at' => now(),
|
||||||
|
'otp_code' => null,
|
||||||
|
'otp_expires_at' => null,
|
||||||
|
'status' => EsignSigner::STATUS_AUTHENTICATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_AUTHENTICATED,
|
||||||
|
$signer->id
|
||||||
|
);
|
||||||
|
|
||||||
|
// sign_session_token 발급 (JWT 대신 단순 토큰)
|
||||||
|
$sessionToken = Str::random(64);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sign_session_token' => $sessionToken,
|
||||||
|
'signer' => $signer->fresh(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitSignature(string $token, array $data): EsignSigner
|
||||||
|
{
|
||||||
|
$signer = $this->findSignerByToken($token);
|
||||||
|
|
||||||
|
if (! $signer->isVerified()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.not_verified'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signer->hasSigned()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.already_signed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||||
|
|
||||||
|
if (! $contract || ! $contract->canSign()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.contract_not_signable'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($signer, $contract, $data) {
|
||||||
|
// 서명 이미지 저장
|
||||||
|
$signatureImagePath = null;
|
||||||
|
if (! empty($data['signature_image'])) {
|
||||||
|
$imageData = base64_decode($data['signature_image']);
|
||||||
|
$signatureImagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}.png";
|
||||||
|
\Illuminate\Support\Facades\Storage::disk('local')->put($signatureImagePath, $imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = request();
|
||||||
|
$signer->update([
|
||||||
|
'signature_image_path' => $signatureImagePath,
|
||||||
|
'signed_at' => now(),
|
||||||
|
'consent_agreed_at' => now(),
|
||||||
|
'sign_ip_address' => $request->ip(),
|
||||||
|
'sign_user_agent' => mb_substr($request->userAgent() ?? '', 0, 500),
|
||||||
|
'status' => EsignSigner::STATUS_SIGNED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_SIGNED,
|
||||||
|
$signer->id,
|
||||||
|
['ip' => $request->ip()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 완료 여부 확인 및 처리
|
||||||
|
$this->checkAndComplete($contract);
|
||||||
|
|
||||||
|
return $signer->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(string $token, string $reason): EsignSigner
|
||||||
|
{
|
||||||
|
$signer = $this->findSignerByToken($token);
|
||||||
|
|
||||||
|
if ($signer->hasSigned()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.already_signed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($signer, $contract, $reason) {
|
||||||
|
$signer->update([
|
||||||
|
'status' => EsignSigner::STATUS_REJECTED,
|
||||||
|
'rejected_reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contract->update([
|
||||||
|
'status' => EsignContract::STATUS_REJECTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_REJECTED,
|
||||||
|
$signer->id,
|
||||||
|
['reason' => $reason]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $signer->fresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkAndComplete(EsignContract $contract): void
|
||||||
|
{
|
||||||
|
$allSigned = $contract->signers()
|
||||||
|
->where('status', '!=', EsignSigner::STATUS_SIGNED)
|
||||||
|
->doesntExist();
|
||||||
|
|
||||||
|
if ($allSigned) {
|
||||||
|
// 모든 서명 완료
|
||||||
|
$contract->update([
|
||||||
|
'status' => EsignContract::STATUS_COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->logPublic(
|
||||||
|
$contract->tenant_id,
|
||||||
|
$contract->id,
|
||||||
|
EsignAuditLog::ACTION_COMPLETED
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 부분 서명 상태 업데이트
|
||||||
|
$signedCount = $contract->signers()->where('status', EsignSigner::STATUS_SIGNED)->count();
|
||||||
|
if ($signedCount > 0 && $contract->status === EsignContract::STATUS_PENDING) {
|
||||||
|
$contract->update(['status' => EsignContract::STATUS_PARTIALLY_SIGNED]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 서명자에게 알림
|
||||||
|
$nextSigner = $contract->getNextSigner();
|
||||||
|
if ($nextSigner && $nextSigner->status === EsignSigner::STATUS_WAITING) {
|
||||||
|
$nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]);
|
||||||
|
Mail::to($nextSigner->email)->queue(
|
||||||
|
new EsignRequestMail($contract, $nextSigner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSignerByToken(string $token): EsignSigner
|
||||||
|
{
|
||||||
|
$signer = EsignSigner::withoutGlobalScopes()
|
||||||
|
->where('access_token', $token)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $signer) {
|
||||||
|
throw new NotFoundHttpException(__('error.esign.invalid_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($signer->token_expires_at && $signer->token_expires_at->isPast()) {
|
||||||
|
throw new BadRequestHttpException(__('error.esign.token_expired'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $signer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('esign_contracts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
|
||||||
|
$table->string('contract_code', 50)->unique()->comment('계약 코드');
|
||||||
|
$table->string('title', 200)->comment('계약 제목');
|
||||||
|
$table->text('description')->nullable()->comment('계약 설명');
|
||||||
|
$table->enum('sign_order_type', ['counterpart_first', 'creator_first'])->default('counterpart_first')->comment('서명 순서 (counterpart_first: 상대방 먼저, creator_first: 작성자 먼저)');
|
||||||
|
$table->string('original_file_path', 500)->nullable()->comment('원본 PDF 파일 경로');
|
||||||
|
$table->string('original_file_name', 255)->nullable()->comment('원본 파일명');
|
||||||
|
$table->string('original_file_hash', 64)->nullable()->comment('원본 파일 SHA-256 해시');
|
||||||
|
$table->unsignedBigInteger('original_file_size')->nullable()->comment('원본 파일 크기 (bytes)');
|
||||||
|
$table->string('signed_file_path', 500)->nullable()->comment('서명 완료 PDF 파일 경로');
|
||||||
|
$table->string('signed_file_hash', 64)->nullable()->comment('서명 완료 파일 SHA-256 해시');
|
||||||
|
$table->enum('status', ['draft', 'pending', 'partially_signed', 'completed', 'expired', 'cancelled', 'rejected'])->default('draft')->comment('계약 상태');
|
||||||
|
$table->timestamp('expires_at')->nullable()->comment('계약 만료일시');
|
||||||
|
$table->timestamp('completed_at')->nullable()->comment('계약 완료일시');
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
|
||||||
|
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
|
||||||
|
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자');
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('tenant_id', 'idx_esign_contracts_tenant');
|
||||||
|
$table->index('status', 'idx_esign_contracts_status');
|
||||||
|
$table->index('expires_at', 'idx_esign_contracts_expires');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('esign_contracts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('esign_signers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
|
||||||
|
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
|
||||||
|
$table->enum('role', ['creator', 'counterpart'])->comment('서명자 역할');
|
||||||
|
$table->tinyInteger('sign_order')->default(1)->comment('서명 순서');
|
||||||
|
$table->string('name', 100)->comment('서명자 이름');
|
||||||
|
$table->string('email', 255)->comment('서명자 이메일');
|
||||||
|
$table->string('phone', 20)->nullable()->comment('서명자 전화번호');
|
||||||
|
$table->string('access_token', 128)->unique()->comment('서명 접근 토큰');
|
||||||
|
$table->timestamp('token_expires_at')->nullable()->comment('토큰 만료일시');
|
||||||
|
$table->string('otp_code', 6)->nullable()->comment('OTP 인증코드');
|
||||||
|
$table->timestamp('otp_expires_at')->nullable()->comment('OTP 만료일시');
|
||||||
|
$table->tinyInteger('otp_attempts')->default(0)->comment('OTP 시도 횟수');
|
||||||
|
$table->timestamp('auth_verified_at')->nullable()->comment('본인인증 완료일시');
|
||||||
|
$table->string('signature_image_path', 500)->nullable()->comment('서명 이미지 경로');
|
||||||
|
$table->timestamp('signed_at')->nullable()->comment('서명 완료일시');
|
||||||
|
$table->timestamp('consent_agreed_at')->nullable()->comment('동의 일시');
|
||||||
|
$table->string('sign_ip_address', 45)->nullable()->comment('서명 시 IP 주소');
|
||||||
|
$table->string('sign_user_agent', 500)->nullable()->comment('서명 시 User Agent');
|
||||||
|
$table->enum('status', ['waiting', 'notified', 'authenticated', 'signed', 'rejected'])->default('waiting')->comment('서명자 상태');
|
||||||
|
$table->text('rejected_reason')->nullable()->comment('거절 사유');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('tenant_id', 'idx_esign_signers_tenant');
|
||||||
|
$table->index('contract_id', 'idx_esign_signers_contract');
|
||||||
|
$table->index('status', 'idx_esign_signers_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('esign_signers');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('esign_sign_fields', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
|
||||||
|
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
|
||||||
|
$table->foreignId('signer_id')->constrained('esign_signers')->onDelete('cascade')->comment('서명자 ID');
|
||||||
|
$table->unsignedSmallInteger('page_number')->comment('페이지 번호');
|
||||||
|
$table->decimal('position_x', 8, 2)->comment('X 좌표 (%)');
|
||||||
|
$table->decimal('position_y', 8, 2)->comment('Y 좌표 (%)');
|
||||||
|
$table->decimal('width', 8, 2)->comment('너비 (%)');
|
||||||
|
$table->decimal('height', 8, 2)->comment('높이 (%)');
|
||||||
|
$table->enum('field_type', ['signature', 'stamp', 'text', 'date', 'checkbox'])->default('signature')->comment('필드 유형');
|
||||||
|
$table->string('field_label', 100)->nullable()->comment('필드 라벨');
|
||||||
|
$table->text('field_value')->nullable()->comment('필드 값');
|
||||||
|
$table->boolean('is_required')->default(true)->comment('필수 여부');
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0)->comment('정렬 순서');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('contract_id', 'idx_esign_fields_contract');
|
||||||
|
$table->index('signer_id', 'idx_esign_fields_signer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('esign_sign_fields');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('esign_audit_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
|
||||||
|
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
|
||||||
|
$table->foreignId('signer_id')->nullable()->constrained('esign_signers')->nullOnDelete()->comment('서명자 ID');
|
||||||
|
$table->string('action', 50)->comment('액션 (created, sent, viewed, otp_sent, authenticated, signed, rejected, completed, cancelled, reminded)');
|
||||||
|
$table->string('ip_address', 45)->nullable()->comment('IP 주소');
|
||||||
|
$table->string('user_agent', 500)->nullable()->comment('User Agent');
|
||||||
|
$table->json('metadata')->nullable()->comment('추가 메타데이터');
|
||||||
|
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
|
||||||
|
|
||||||
|
$table->index('contract_id', 'idx_esign_audit_contract');
|
||||||
|
$table->index(['contract_id', 'action'], 'idx_esign_audit_contract_action');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('esign_audit_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
68
resources/views/emails/esign/request.blade.php
Normal file
68
resources/views/emails/esign/request.blade.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>전자계약 서명 요청</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #2563eb; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">전자계약 서명 요청</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<p style="margin: 0 0 16px; font-size: 16px; color: #333;">안녕하세요, <strong>{{ $signerName }}</strong>님.</p>
|
||||||
|
<p style="margin: 0 0 24px; font-size: 15px; color: #555; line-height: 1.6;">
|
||||||
|
아래 전자계약에 대한 서명이 요청되었습니다.<br>
|
||||||
|
링크를 클릭하여 서명을 진행해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8fafc; border-radius: 6px; margin-bottom: 24px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">계약 제목</p>
|
||||||
|
<p style="margin: 0 0 16px; font-size: 16px; color: #333; font-weight: 600;">{{ $contractTitle }}</p>
|
||||||
|
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">서명 기한</p>
|
||||||
|
<p style="margin: 0; font-size: 15px; color: #ef4444;">{{ $expiresAt }}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 8px 0;">
|
||||||
|
<a href="{{ $signUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 6px; font-size: 16px; font-weight: 600;">
|
||||||
|
서명하기
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 24px 0 0; font-size: 13px; color: #999; line-height: 1.6;">
|
||||||
|
위 버튼이 동작하지 않으면 아래 링크를 브라우저에 직접 입력해 주세요:<br>
|
||||||
|
<a href="{{ $signUrl }}" style="color: #2563eb; word-break: break-all;">{{ $signUrl }}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #999;">
|
||||||
|
본 메일은 SAM 전자계약 시스템에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
require __DIR__.'/api/v1/stats.php';
|
require __DIR__.'/api/v1/stats.php';
|
||||||
require __DIR__.'/api/v1/app.php';
|
require __DIR__.'/api/v1/app.php';
|
||||||
require __DIR__.'/api/v1/audit.php';
|
require __DIR__.'/api/v1/audit.php';
|
||||||
|
require __DIR__.'/api/v1/esign.php';
|
||||||
|
|
||||||
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
|
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
|
||||||
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');
|
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');
|
||||||
|
|||||||
36
routes/api/v1/esign.php
Normal file
36
routes/api/v1/esign.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전자계약(E-Sign) API 라우트 (v1)
|
||||||
|
*
|
||||||
|
* - 계약 관리 (인증 필요)
|
||||||
|
* - 서명 프로세스 (토큰 기반, 비인증)
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\ESign\EsignContractController;
|
||||||
|
use App\Http\Controllers\Api\V1\ESign\EsignSignController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// E-Sign Contract API (인증 필요 - auth.apikey 그룹 내)
|
||||||
|
Route::prefix('esign/contracts')->group(function () {
|
||||||
|
Route::get('', [EsignContractController::class, 'index'])->name('v1.esign.contracts.index');
|
||||||
|
Route::post('', [EsignContractController::class, 'store'])->name('v1.esign.contracts.store');
|
||||||
|
Route::get('/stats', [EsignContractController::class, 'stats'])->name('v1.esign.contracts.stats');
|
||||||
|
Route::get('/{id}', [EsignContractController::class, 'show'])->whereNumber('id')->name('v1.esign.contracts.show');
|
||||||
|
Route::post('/{id}/cancel', [EsignContractController::class, 'cancel'])->whereNumber('id')->name('v1.esign.contracts.cancel');
|
||||||
|
Route::post('/{id}/fields', [EsignContractController::class, 'configureFields'])->whereNumber('id')->name('v1.esign.contracts.fields');
|
||||||
|
Route::post('/{id}/send', [EsignContractController::class, 'send'])->whereNumber('id')->name('v1.esign.contracts.send');
|
||||||
|
Route::post('/{id}/remind', [EsignContractController::class, 'remind'])->whereNumber('id')->name('v1.esign.contracts.remind');
|
||||||
|
Route::get('/{id}/download', [EsignContractController::class, 'download'])->whereNumber('id')->name('v1.esign.contracts.download');
|
||||||
|
Route::get('/{id}/verify', [EsignContractController::class, 'verify'])->whereNumber('id')->name('v1.esign.contracts.verify');
|
||||||
|
});
|
||||||
|
|
||||||
|
// E-Sign Sign API (토큰 기반, 비인증 - auth.apikey 그룹 내에서 토큰으로 접근)
|
||||||
|
Route::prefix('esign/sign/{token}')->group(function () {
|
||||||
|
Route::get('', [EsignSignController::class, 'getContract'])->name('v1.esign.sign.contract');
|
||||||
|
Route::post('/otp/send', [EsignSignController::class, 'sendOtp'])->name('v1.esign.sign.otp.send');
|
||||||
|
Route::post('/otp/verify', [EsignSignController::class, 'verifyOtp'])->name('v1.esign.sign.otp.verify');
|
||||||
|
Route::get('/document', [EsignSignController::class, 'getDocument'])->name('v1.esign.sign.document');
|
||||||
|
Route::post('/submit', [EsignSignController::class, 'submit'])->name('v1.esign.sign.submit');
|
||||||
|
Route::post('/reject', [EsignSignController::class, 'reject'])->name('v1.esign.sign.reject');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user