Files
sam-api/app/Services/ESign/EsignContractService.php
김보곤 6958be1fd8 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>
2026-02-12 07:02:39 +09:00

299 lines
11 KiB
PHP

<?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']);
});
}
}