# SAM E-Sign 구현 가이드

> **프로젝트명**: SAM E-Sign (전자계약 서명 솔루션)
> **구현일**: 2026-02-12
> **버전**: v1.0
> **설계 문서**: [technical-design.md](./technical-design.md)

---

## 1. 구현 개요

모두싸인과 유사한 간편 전자계약 서명 솔루션을 SAM 시스템에 구현했다.
API 프로젝트에 백엔드 로직(모델, 서비스, 컨트롤러, 라우트)을, MNG 프로젝트에 프론트엔드(컨트롤러, React 뷰)를 구축했다.

### 구현 범위

| 영역 | 내용 |
|------|------|
| 데이터베이스 | 마이그레이션 4개 (esign_ 접두사 테이블) |
| 모델 | 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog) |
| API 서비스 | 4개 (Contract, Sign, Pdf, Audit) |
| MNG 서비스 | 2개 (DocxToPdfConverter, PdfSignatureService) |
| API 컨트롤러 | 2개 (Contract 10엔드포인트, Sign 6엔드포인트) |
| FormRequest | 4개 (Store, FieldConfigure, SignSubmit, SignReject) |
| 메일 | 1개 (EsignRequestMail) |
| MNG 컨트롤러 | 2개 (인증 5화면, 공개 3화면) |
| MNG 뷰 | 8개 (React 하이브리드) |
| API 라우트 | 16개 |
| MNG 라우트 | 8개 |
| i18n | message 12키 + error 16키 |

**총 파일**: 신규 29개 + 수정 4개 = 33개

---

## 2. 파일 구조

### API 프로젝트 (`/home/aweso/sam/api`)

```
database/migrations/
├── 2026_02_12_100000_create_esign_contracts_table.php
├── 2026_02_12_110000_create_esign_signers_table.php
├── 2026_02_12_120000_create_esign_sign_fields_table.php
└── 2026_02_12_130000_create_esign_audit_logs_table.php

app/Models/ESign/
├── EsignContract.php
├── EsignSigner.php
├── EsignSignField.php
└── EsignAuditLog.php

app/Services/ESign/
├── EsignContractService.php
├── EsignSignService.php
├── EsignPdfService.php
└── EsignAuditService.php

app/Http/Controllers/Api/V1/ESign/
├── EsignContractController.php
└── EsignSignController.php

app/Http/Requests/ESign/
├── ContractStoreRequest.php
├── FieldConfigureRequest.php
├── SignSubmitRequest.php
└── SignRejectRequest.php

app/Mail/
└── EsignRequestMail.php

resources/views/emails/esign/
└── request.blade.php

routes/api/v1/
└── esign.php

routes/api.php                    # 수정 (esign.php include 추가)
lang/ko/message.php               # 수정 (esign 키 추가)
lang/ko/error.php                  # 수정 (esign 키 추가)
```

### MNG 프로젝트 (`/home/aweso/sam/mng`)

```
app/Http/Controllers/ESign/
├── EsignController.php            # 인증 필요 (5개 메서드)
└── EsignPublicController.php      # 비인증 (3개 메서드)

app/Services/ESign/
├── DocxToPdfConverter.php         # DOCX→PDF 변환 (LibreOffice headless)
└── PdfSignatureService.php        # PDF 서명 합성 (FPDI/TCPDF)

resources/views/esign/
├── dashboard.blade.php            # 대시보드 (통계 + 목록)
├── create.blade.php               # 계약 생성 (PDF 업로드)
├── detail.blade.php               # 계약 상세 (현황 + 로그)
├── fields.blade.php               # 서명 위치 지정 (PDF.js)
├── send.blade.php                 # 서명 요청 발송 확인
└── sign/
    ├── auth.blade.php             # 공개 - OTP 본인인증
    ├── sign.blade.php             # 공개 - 서명 수행
    └── done.blade.php             # 공개 - 서명 완료

routes/web.php                     # 수정 (esign 라우트 추가)
```

---

## 3. 데이터베이스 스키마

### 3.1 esign_contracts (계약 마스터)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | 자동 증가 |
| tenant_id | BIGINT FK | 테넌트 |
| contract_code | VARCHAR(50) UNIQUE | 계약 코드 (ES-YYYYMMDD-RANDOM) |
| title | VARCHAR(200) | 계약 제목 |
| description | TEXT NULL | 계약 설명 |
| sign_order_type | ENUM | counterpart_first, creator_first |
| original_file_path | VARCHAR(500) | 원본 PDF 경로 |
| original_file_name | VARCHAR(255) | 원본 파일명 |
| original_file_hash | VARCHAR(64) | SHA-256 해시 |
| original_file_size | INT UNSIGNED | 파일 크기 (bytes) |
| signed_file_path | VARCHAR(500) NULL | 서명 완료 PDF 경로 |
| signed_file_hash | VARCHAR(64) NULL | 서명 완료 PDF 해시 |
| status | ENUM | draft/pending/partially_signed/completed/expired/cancelled/rejected |
| expires_at | TIMESTAMP | 서명 기한 |
| completed_at | TIMESTAMP NULL | 완료 시각 |
| created_by/updated_by/deleted_by | BIGINT NULL | 감사 필드 |
| timestamps, softDeletes | | |

**인덱스**: tenant_id+status, contract_code(unique), expires_at

### 3.2 esign_signers (서명자)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| role | ENUM | creator, counterpart |
| sign_order | TINYINT | 서명 순서 (1 또는 2) |
| name | VARCHAR(100) | 서명자 이름 |
| email | VARCHAR(255) | 이메일 |
| phone | VARCHAR(20) NULL | 전화번호 |
| access_token | VARCHAR(128) UNIQUE | 서명 링크 토큰 |
| token_expires_at | TIMESTAMP | 토큰 만료 |
| otp_code | VARCHAR(10) NULL | OTP 코드 |
| otp_expires_at | TIMESTAMP NULL | OTP 만료 |
| otp_attempts | TINYINT DEFAULT 0 | OTP 시도 횟수 |
| auth_verified_at | TIMESTAMP NULL | 본인인증 완료 |
| signature_image_path | VARCHAR(500) NULL | 서명 이미지 경로 |
| signed_at | TIMESTAMP NULL | 서명 시각 |
| consent_agreed_at | TIMESTAMP NULL | 동의 시각 |
| sign_ip_address | VARCHAR(45) NULL | 서명 IP |
| sign_user_agent | TEXT NULL | User-Agent |
| status | ENUM | waiting/notified/authenticated/signed/rejected |
| rejected_reason | TEXT NULL | 거절 사유 |

### 3.3 esign_sign_fields (서명 필드)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| signer_id | BIGINT FK | → esign_signers |
| page_number | INT UNSIGNED | PDF 페이지 번호 |
| position_x | DECIMAL(8,4) | X 좌표 (%) |
| position_y | DECIMAL(8,4) | Y 좌표 (%) |
| width | DECIMAL(8,4) | 너비 (%) |
| height | DECIMAL(8,4) | 높이 (%) |
| field_type | ENUM | signature/stamp/text/date/checkbox |
| field_label | VARCHAR(100) NULL | 필드 라벨 |
| field_value | TEXT NULL | 입력된 값 |
| is_required | BOOLEAN DEFAULT true | 필수 여부 |
| sort_order | INT DEFAULT 0 | 정렬 순서 |

### 3.4 esign_audit_logs (감사 로그)

| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| signer_id | BIGINT FK NULL | → esign_signers (NULL = 시스템 이벤트) |
| action | VARCHAR(50) | 액션 코드 |
| ip_address | VARCHAR(45) NULL | IP 주소 |
| user_agent | TEXT NULL | User-Agent |
| metadata | JSON NULL | 추가 데이터 |
| created_at | TIMESTAMP | 생성 시각 (삭제 불가) |

**감사 로그 액션 코드**:

| 코드 | 설명 |
|------|------|
| created | 계약 생성 |
| sent | 서명 요청 발송 |
| viewed | 문서 열람 |
| otp_sent | OTP 발송 |
| authenticated | 본인인증 완료 |
| signed | 서명 완료 |
| rejected | 서명 거절 |
| completed | 계약 완료 |
| cancelled | 계약 취소 |
| reminded | 리마인더 발송 |
| downloaded | 문서 다운로드 |

### ER 다이어그램

```
esign_contracts (1) ──── (N) esign_signers
       │                         │
      (1)                       (1)
       │                         │
      (N)                       (N)
esign_sign_fields          esign_audit_logs
```

---

## 4. 모델 계층

### 4.1 EsignContract

**파일**: `app/Models/ESign/EsignContract.php`

```php
use Auditable, BelongsToTenant, SoftDeletes;

// 상수
STATUS_DRAFT, STATUS_PENDING, STATUS_PARTIALLY_SIGNED,
STATUS_COMPLETED, STATUS_EXPIRED, STATUS_CANCELLED, STATUS_REJECTED
SIGN_ORDER_COUNTERPART_FIRST, SIGN_ORDER_CREATOR_FIRST

// 관계
signers()    → HasMany(EsignSigner)
signFields() → HasMany(EsignSignField)
auditLogs()  → HasMany(EsignAuditLog)
creator()    → BelongsTo(User, 'created_by')

// 스코프
scopeStatus($query, $status)
scopeActive($query) // pending, partially_signed

// 헬퍼
isExpired(): bool       // expires_at < now
canSign(): bool         // pending 또는 partially_signed + 미만료
getNextSigner(): ?EsignSigner  // sign_order 기준 다음 서명자
```

### 4.2 EsignSigner

**파일**: `app/Models/ESign/EsignSigner.php`

```php
use BelongsToTenant;

// 상수
ROLE_CREATOR, ROLE_COUNTERPART
STATUS_WAITING, STATUS_NOTIFIED, STATUS_AUTHENTICATED, STATUS_SIGNED, STATUS_REJECTED

// Hidden 필드: access_token, otp_code

// 관계
contract()   → BelongsTo(EsignContract)
signFields() → HasMany(EsignSignField)

// 헬퍼
isVerified(): bool  // auth_verified_at !== null
hasSigned(): bool   // signed_at !== null
canSign(): bool     // !hasSigned + !rejected + contract.canSign
```

### 4.3 EsignSignField

**파일**: `app/Models/ESign/EsignSignField.php`

```php
use BelongsToTenant;

// 상수
TYPE_SIGNATURE, TYPE_STAMP, TYPE_TEXT, TYPE_DATE, TYPE_CHECKBOX

// 관계
contract() → BelongsTo(EsignContract)
signer()   → BelongsTo(EsignSigner)
```

### 4.4 EsignAuditLog

**파일**: `app/Models/ESign/EsignAuditLog.php`

```php
use BelongsToTenant;

$timestamps = false;  // created_at만 사용
$casts = ['metadata' => 'array'];

// 상수
ACTION_CREATED, ACTION_SENT, ACTION_VIEWED, ACTION_OTP_SENT,
ACTION_AUTHENTICATED, ACTION_SIGNED, ACTION_REJECTED,
ACTION_COMPLETED, ACTION_CANCELLED, ACTION_REMINDED, ACTION_DOWNLOADED

// 관계
contract() → BelongsTo(EsignContract)
signer()   → BelongsTo(EsignSigner)
```

---

## 5. 서비스 계층

모든 서비스는 `App\Services\Service`를 상속하며, `tenantId()`와 `apiUserId()`를 사용한다.

### 5.1 EsignContractService

**파일**: `app/Services/ESign/EsignContractService.php`
**의존성**: EsignAuditService, EsignPdfService

| 메서드 | 설명 |
|--------|------|
| `list(array $params)` | 필터/페이지네이션 목록 (status, search, date_from/to, per_page) |
| `stats()` | 상태별 통계 (GROUP BY status) |
| `create(array $data)` | 계약 생성 + PDF 저장 + 해시 + 서명자 2인 생성 |
| `show(int $id)` | 상세 조회 (signers, signFields, auditLogs eager loading) |
| `cancel(int $id)` | 계약 취소 (draft/pending만 가능) |
| `send(int $id)` | 서명 요청 발송 (상태 → pending, 이메일 발송) |
| `remind(int $id)` | 리마인더 발송 (다음 서명자에게 이메일 재발송) |
| `configureFields(int $id, array $fields)` | 서명 위치 설정 (기존 삭제 후 재생성, draft만) |

**계약 코드 생성 규칙**: `ES-YYYYMMDD-RANDOM6` (예: ES-20260212-A3F2K9)

### 5.2 EsignSignService

**파일**: `app/Services/ESign/EsignSignService.php`
**의존성**: EsignAuditService, EsignPdfService
**특이사항**: 모든 쿼리에서 `withoutGlobalScopes()` 사용 (토큰 기반 공개 접근)

| 메서드 | 설명 |
|--------|------|
| `getByToken(string $token)` | 토큰으로 계약+서명자 조회 (만료/상태 검증) |
| `sendOtp(string $token)` | 6자리 OTP 생성 + 5분 만료 + 이메일 발송 |
| `verifyOtp(string $token, string $otpCode)` | OTP 검증 (최대 5회) → sign_session_token JWT 발급 |
| `submitSignature(string $token, array $data)` | 서명 이미지 저장 + 자동 완료 체크 |
| `reject(string $token, string $reason)` | 서명 거절 → 계약 상태 rejected |
| `checkAndComplete(EsignContract)` | (private) 양쪽 서명 완료 시 자동 completed 처리 |

**OTP 보안 규칙**:
- 6자리 숫자
- 5분 유효 (`otp_expires_at`)
- 최대 5회 시도 (`otp_attempts`)
- 초과 시 토큰 무효화

### 5.3 EsignPdfService (API)

**파일**: `api/app/Services/ESign/EsignPdfService.php`

| 메서드 | 설명 | 상태 |
|--------|------|------|
| `generateHash(string $filePath)` | SHA-256 해시 생성 | 구현 완료 |
| `verifyIntegrity(string $filePath, string $expectedHash)` | 해시 비교 검증 (hash_equals) | 구현 완료 |
| `composeSigned(...)` | 원본 PDF + 서명 이미지 합성 | 스텁 (MNG로 이관) |
| `addAuditPage(...)` | 감사 증적 페이지 추가 | 스텁 (추후) |

### 5.5 DocxToPdfConverter (MNG)

**파일**: `mng/app/Services/ESign/DocxToPdfConverter.php`
**의존성**: LibreOffice (headless), 나눔 폰트

| 메서드 | 설명 | 상태 |
|--------|------|------|
| `convertAndStore(UploadedFile $file)` | DOCX/DOC 파일을 LibreOffice로 PDF 변환 후 저장 | 구현 완료 |

**동작 방식**:
- Word 파일(.doc, .docx) 업로드 시 자동 감지
- `libreoffice --headless --convert-to pdf` 명령으로 변환
- 나눔 폰트로 한글 정상 렌더링 지원
- 변환된 PDF를 `storage/app/private/esign/{tenant_id}/originals/` 에 저장

### 5.6 PdfSignatureService (MNG)

**파일**: `mng/app/Services/ESign/PdfSignatureService.php`
**의존성**: FPDI, TCPDF, GD 확장

| 메서드 | 설명 | 상태 |
|--------|------|------|
| `mergeSignatures(EsignContract $contract)` | 원본 PDF에 모든 서명 이미지 오버레이 합성 | 구현 완료 |

**동작 방식**:
- FPDI로 원본 PDF 임포트
- 서명 필드를 페이지별로 그룹핑
- 필드 타입별 렌더링: signature/stamp(이미지), date(텍스트), text(텍스트), checkbox(체크마크)
- 서명된 PDF를 `storage/app/private/esign/{tenant_id}/signed/` 에 저장

### 5.4 EsignAuditService

**파일**: `app/Services/ESign/EsignAuditService.php`

| 메서드 | 설명 |
|--------|------|
| `log(int $contractId, string $action, ...)` | 감사 로그 기록 (인증 컨텍스트) |
| `logPublic(int $tenantId, int $contractId, ...)` | 감사 로그 기록 (공개 접근, withoutGlobalScopes) |
| `getContractLogs(int $contractId)` | 계약별 감사 로그 조회 (signer 포함) |

---

## 6. API 엔드포인트

### 6.1 계약 관리 API (인증 필요)

미들웨어: `auth.apikey`

| Method | Path | Controller | 설명 |
|--------|------|-----------|------|
| GET | `/api/v1/esign/contracts` | index | 계약 목록 (필터/페이지네이션) |
| POST | `/api/v1/esign/contracts` | store | 계약 생성 (multipart/form-data) |
| GET | `/api/v1/esign/contracts/stats` | stats | 상태별 통계 |
| GET | `/api/v1/esign/contracts/{id}` | show | 계약 상세 |
| POST | `/api/v1/esign/contracts/{id}/cancel` | cancel | 계약 취소 |
| POST | `/api/v1/esign/contracts/{id}/fields` | configureFields | 서명 위치 설정 |
| POST | `/api/v1/esign/contracts/{id}/send` | send | 서명 요청 발송 |
| POST | `/api/v1/esign/contracts/{id}/remind` | remind | 리마인더 발송 |
| GET | `/api/v1/esign/contracts/{id}/download` | download | PDF 다운로드 |
| GET | `/api/v1/esign/contracts/{id}/verify` | verify | 무결성 검증 |

### 6.2 서명 수행 API (토큰 기반, 비인증)

미들웨어: API 키만 필요 (테넌트 인증 없음)

| Method | Path | Controller | 설명 |
|--------|------|-----------|------|
| GET | `/api/v1/esign/sign/{token}` | getContract | 계약 정보 조회 |
| POST | `/api/v1/esign/sign/{token}/otp/send` | sendOtp | OTP 발송 |
| POST | `/api/v1/esign/sign/{token}/otp/verify` | verifyOtp | OTP 검증 |
| GET | `/api/v1/esign/sign/{token}/document` | getDocument | PDF 스트리밍 |
| POST | `/api/v1/esign/sign/{token}/submit` | submit | 서명 제출 |
| POST | `/api/v1/esign/sign/{token}/reject` | reject | 서명 거절 |

### 6.3 응답 패턴

모든 API는 `ApiResponse::handle()` 패턴을 따른다:

```json
// 성공
{
  "success": true,
  "message": "처리 완료",
  "data": { ... }
}

// 실패
{
  "success": false,
  "message": "오류 메시지",
  "error": { ... }
}
```

---

## 7. MNG 화면 구성

### 7.1 라우트 구조

**인증 필요** (미들웨어: auth):

| 경로 | 컨트롤러 | 뷰 |
|------|---------|-----|
| GET `/esign` | EsignController@dashboard | esign/dashboard |
| GET `/esign/create` | EsignController@create | esign/create |
| GET `/esign/{id}` | EsignController@detail | esign/detail |
| GET `/esign/{id}/fields` | EsignController@fields | esign/fields |
| GET `/esign/{id}/send` | EsignController@send | esign/send |

**공개** (비인증):

| 경로 | 컨트롤러 | 뷰 |
|------|---------|-----|
| GET `/esign/sign/{token}` | EsignPublicController@auth | esign/sign/auth |
| GET `/esign/sign/{token}/sign` | EsignPublicController@sign | esign/sign/sign |
| GET `/esign/sign/{token}/done` | EsignPublicController@done | esign/sign/done |

### 7.2 뷰 기술 스택

| 뷰 | 레이아웃 | 기술 |
|-----|---------|------|
| dashboard | layouts.app | React 18 + Babel |
| create | layouts.app | React 18 + Babel |
| detail | layouts.app | React 18 + Babel |
| fields | layouts.app | React 18 + PDF.js + Babel |
| send | layouts.app | React 18 + Babel |
| sign/auth | Standalone HTML | React 18 + Babel |
| sign/sign | Standalone HTML | React 18 + SignaturePad + Babel |
| sign/done | Standalone HTML | React 18 + Babel |

**공통 패턴**:
- 인증 화면: `@extends('layouts.app')` + HX-Redirect 패턴
- 공개 화면: 독립 HTML (layouts.app 미사용, Tailwind CSS만 로드)
- API 호출: `window.SAM_CONFIG?.apiBaseUrl` 또는 `config('services.api.base_url')`
- 인증 토큰: `sessionStorage.getItem('api_access_token')` (인증), `esign_session_token` (공개)

### 7.3 화면별 기능

**대시보드** (`dashboard.blade.php`):
- 상태별 통계 카드 (전체/진행중/대기/완료/만료)
- 상태 필터, 검색, 날짜 범위 필터
- 계약 목록 테이블 (페이지네이션)
- 상태 배지 (색상 구분)

**계약 생성** (`create.blade.php`):
- 계약 정보 입력 (제목, 설명, 서명 순서, 기한)
- PDF 파일 업로드 (드래그&드롭)
- 작성자 정보, 상대방 정보 입력
- multipart/form-data 제출

**계약 상세** (`detail.blade.php`):
- 계약 기본 정보
- 서명자 현황 (작성자/상대방 상태, 서명 시각)
- 감사 로그 타임라인
- 액션 버튼 (발송, 리마인더, 취소, 다운로드, 검증)

**서명 위치 지정** (`fields.blade.php`):
- PDF.js로 PDF 렌더링
- 페이지 네비게이션
- 서명자별 필드 추가 (클릭으로 위치 지정)
- 필드 드래그/리사이즈
- 필드 타입 설정 (서명/도장/텍스트/날짜/체크박스)
- 좌표값 (% 기반)

**서명 요청 발송** (`send.blade.php`):
- 발송 전 체크리스트 (서명 필드 설정 여부)
- 서명 순서 확인
- 최종 발송 버튼

**본인인증** (`sign/auth.blade.php`):
- 계약 정보 표시 (제목, 서명자, 기한)
- OTP 발송 버튼
- 6자리 OTP 입력 폼
- 재발송 기능

**서명 수행** (`sign/sign.blade.php`):
- 3단계: 문서 확인 → 서명 입력 → 서명 확인
- 문서 확인: PDF 다운로드 링크 + 동의 체크박스
- 서명 입력: SignaturePad 캔버스 (터치/마우스)
- 서명 확인: 미리보기 + 제출/다시서명 선택
- 거절 기능 (사유 입력)

**서명 완료** (`sign/done.blade.php`):
- 서명 완료/거절/기타 상태 분기 표시
- 계약 정보, 서명자 이름, 서명 일시 표시

---

## 8. 핵심 플로우

### 8.1 계약 생성 → 발송

```
[관리자]
  1. /esign/create 접속
  2. 계약 정보 + PDF 업로드 + 서명자 정보 입력
  3. POST /api/v1/esign/contracts
     → EsignContractService::create()
     → PDF 저장, SHA-256 해시, 계약 코드 생성
     → 서명자 2인 생성 (access_token 128자)
     → 상태: DRAFT
  4. /esign/{id}/fields 에서 서명 위치 지정
     → POST /api/v1/esign/contracts/{id}/fields
     → configureFields() 호출
  5. /esign/{id}/send 에서 발송 확인
     → POST /api/v1/esign/contracts/{id}/send
     → 상태: DRAFT → PENDING
     → 첫 서명자: WAITING → NOTIFIED
     → EsignRequestMail 이메일 발송
```

### 8.2 서명 수행

```
[서명자 B - 이메일 링크 클릭]
  1. /esign/sign/{token} 접속 → auth 화면
  2. GET /api/v1/esign/sign/{token} → 계약 정보 확인
  3. POST /api/v1/esign/sign/{token}/otp/send → OTP 발송
  4. POST /api/v1/esign/sign/{token}/otp/verify → OTP 검증
     → sign_session_token 발급
     → sessionStorage에 저장
  5. /esign/sign/{token}/sign 이동 → sign 화면
  6. 문서 확인 + 동의 체크 + 서명 입력 (SignaturePad)
  7. POST /api/v1/esign/sign/{token}/submit
     → 서명 이미지 저장 (base64 → PNG 파일)
     → 상태: AUTHENTICATED → SIGNED
     → checkAndComplete() 호출
  8. /esign/sign/{token}/done 이동 → done 화면
```

### 8.3 자동 완료 처리

```
EsignSignService::checkAndComplete()
  1. 모든 서명자 상태 확인
  2. 전원 SIGNED → 계약 상태 COMPLETED + completed_at 설정
  3. 아직 미서명자 있음 → PARTIALLY_SIGNED
     → 다음 서명 순서 서명자에게 이메일 발송
     → 다음 서명자 상태: NOTIFIED
```

### 8.4 상태 전이

**계약 상태**:
```
DRAFT → PENDING (send)
PENDING → PARTIALLY_SIGNED (첫 서명 완료)
PARTIALLY_SIGNED → COMPLETED (모든 서명 완료)
DRAFT/PENDING → CANCELLED (관리자 취소)
PENDING/PARTIALLY_SIGNED → REJECTED (서명자 거절)
PENDING/PARTIALLY_SIGNED → EXPIRED (기한 초과, 추후 스케줄러)
```

**서명자 상태**:
```
WAITING → NOTIFIED (이메일 발송)
NOTIFIED → AUTHENTICATED (OTP 인증)
AUTHENTICATED → SIGNED (서명 완료)
NOTIFIED/AUTHENTICATED → REJECTED (거절)
```

---

## 9. 보안 설계

### 9.1 토큰 기반 접근

| 토큰 | 용도 | 생성 | 유효기간 |
|------|------|------|---------|
| access_token | 서명 링크 URL | `Str::random(128)` | 계약 만료일까지 |
| sign_session_token | OTP 인증 후 세션 | `Str::random(64)` | 별도 관리 없음 |

### 9.2 OTP 인증

- 6자리 숫자 (`random_int(100000, 999999)`)
- 5분 유효 (`otp_expires_at = now + 5min`)
- 최대 5회 시도 (`otp_attempts`)
- 초과 시 에러 반환 (토큰 재사용 불가 상태)

### 9.3 파일 무결성

- 업로드 시 SHA-256 해시 계산 → `original_file_hash`에 저장
- 검증 시 `hash_equals()` 사용 (타이밍 공격 방지)
- 서명 완료 PDF도 별도 해시 저장 (`signed_file_hash`)

### 9.4 멀티테넌트

- 모든 모델에 `BelongsToTenant` 트레이트 적용
- 공개 서명 API에서는 `withoutGlobalScopes()` 사용
- 감사 로그 기록 시 `logPublic()` 메서드로 tenant_id 명시

### 9.5 감사 추적

- 모든 주요 행위를 `esign_audit_logs`에 기록
- IP 주소, User-Agent 자동 수집
- 감사 로그는 삭제 불가 (`SoftDeletes` 미적용)

---

## 10. i18n (국제화)

### message 키 (`lang/ko/message.php` → `esign` 배열)

| 키 | 메시지 |
|----|--------|
| created | 전자계약이 생성되었습니다 |
| cancelled | 전자계약이 취소되었습니다 |
| sent | 서명 요청이 발송되었습니다 |
| reminded | 리마인더가 발송되었습니다 |
| fields_configured | 서명 필드가 설정되었습니다 |
| otp_sent | 인증 코드가 발송되었습니다 |
| otp_verified | 본인인증이 완료되었습니다 |
| signed | 서명이 완료되었습니다 |
| rejected | 서명이 거절되었습니다 |
| completed | 전자계약이 완료되었습니다 |
| verified | 문서 무결성이 확인되었습니다 |
| downloaded | 문서가 다운로드되었습니다 |

### error 키 (`lang/ko/error.php` → `esign` 배열)

| 키 | 메시지 |
|----|--------|
| invalid_token | 유효하지 않은 서명 링크입니다 |
| token_expired | 서명 링크가 만료되었습니다 |
| contract_not_signable | 현재 서명할 수 없는 계약입니다 |
| already_completed | 이미 완료된 계약입니다 |
| already_cancelled | 이미 취소된 계약입니다 |
| already_signed | 이미 서명이 완료되었습니다 |
| invalid_status_for_send | 발송할 수 없는 상태입니다 |
| no_sign_fields | 서명 필드가 설정되지 않았습니다 |
| cannot_remind | 리마인더를 발송할 수 없는 상태입니다 |
| fields_only_in_draft | 초안 상태에서만 필드를 설정할 수 있습니다 |
| not_verified | 본인인증이 필요합니다 |
| otp_max_attempts | 인증 시도 횟수를 초과했습니다 |
| otp_not_sent | 인증 코드가 발송되지 않았습니다 |
| otp_expired | 인증 코드가 만료되었습니다 |
| otp_invalid | 인증 코드가 올바르지 않습니다 |
| file_not_found | 파일을 찾을 수 없습니다 |

---

## 11. 재사용 패턴

### API 프로젝트

| 패턴 | 파일 | 용도 |
|------|------|------|
| Service 베이스 | `app/Services/Service.php` | tenantId(), apiUserId(), setContext() |
| ApiResponse | `app/Helpers/ApiResponse.php` | handle() 패턴으로 일관된 응답 |
| Auditable Trait | `app/Traits/Auditable.php` | created_by, updated_by, deleted_by 자동 관리 |
| BelongsToTenant | `app/Traits/BelongsToTenant.php` | TenantScope 글로벌 스코프 |

### MNG 프로젝트

| 패턴 | 파일 | 용도 |
|------|------|------|
| 레이아웃 | `views/layouts/app.blade.php` | 사이드바 + 콘텐츠 영역 |
| React 하이브리드 | `views/finance/journal-entries.blade.php` | CDN React + Babel 패턴 참고 |
| HX-Redirect | 컨트롤러 패턴 | HTMX 부분 로드 시 전체 리로드 |
| SidebarMenu | `app/Services/SidebarMenuService.php` | DB 기반 메뉴 |
| DOCX→PDF | `app/Services/ESign/DocxToPdfConverter.php` | LibreOffice headless 변환 |
| PDF 서명 합성 | `app/Services/ESign/PdfSignatureService.php` | FPDI/TCPDF 서명 오버레이 |

---

## 12. 추후 구현 예정

| 항목 | 우선순위 | 설명 |
|------|---------|------|
| ~~PDF 합성 (FPDI)~~ | ~~높음~~ | ~~원본 PDF에 서명 이미지 오버레이~~ → **구현 완료** (MNG PdfSignatureService) |
| ~~DOCX→PDF 변환~~ | ~~높음~~ | ~~Word 문서 지원~~ → **구현 완료** (MNG DocxToPdfConverter + LibreOffice) |
| 감사 증적 페이지 | 높음 | 완료 PDF 마지막에 감사 정보 페이지 추가 |
| 파일 암호화 (AES-256) | 중간 | 원본 PDF 암호화 저장 |
| 만료 자동 처리 | 중간 | 스케줄러로 expires_at 초과 계약 expired 처리 |
| 리마인더 자동 발송 | 낮음 | 만료 3일 전 자동 리마인드 |
| SMS OTP | 낮음 | 이메일 외 SMS 인증 지원 |
| OTP bcrypt 해싱 | 중간 | 현재 평문 저장 → bcrypt 해싱 |
| PDF 서명 텍스트 한글 | 낮음 | TCPDF CJK 폰트 추가 (현재 helvetica만 사용) |

---

## 13. 메뉴 추가 안내

메뉴 시더 실행 금지 규칙에 따라, 아래 tinker 명령어를 사용자가 직접 실행해야 합니다:

```bash
# 1. 상위 메뉴 "전자계약 (E-Sign)" 추가
ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\"
\\\$menu = App\\\\Models\\\\Commons\\\\Menu::create([
    'tenant_id' => 1,
    'parent_id' => null,
    'name' => '전자계약',
    'url' => '/esign',
    'icon' => 'ri-quill-pen-line',
    'sort_order' => 25,
    'is_active' => true,
]);
echo 'Created menu ID: ' . \\\$menu->id;
\""

# 2. 하위 메뉴 추가 (parent_id를 위에서 생성된 ID로 교체)
ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\"
App\\\\Models\\\\Commons\\\\Menu::create([
    'tenant_id' => 1,
    'parent_id' => <상위메뉴ID>,
    'name' => '계약 대시보드',
    'url' => '/esign',
    'icon' => 'ri-dashboard-line',
    'sort_order' => 1,
    'is_active' => true,
]);
App\\\\Models\\\\Commons\\\\Menu::create([
    'tenant_id' => 1,
    'parent_id' => <상위메뉴ID>,
    'name' => '새 계약 생성',
    'url' => '/esign/create',
    'icon' => 'ri-add-line',
    'sort_order' => 2,
    'is_active' => true,
]);
\""
```

---

*이 문서는 SAM E-Sign v1.0 구현 결과를 기록한 것입니다. 추후 기능 추가 시 업데이트됩니다.*
