diff --git a/plans/db-trigger-audit-system-plan.md b/plans/db-trigger-audit-system-plan.md
new file mode 100644
index 0000000..62da7d9
--- /dev/null
+++ b/plans/db-trigger-audit-system-plan.md
@@ -0,0 +1,1294 @@
+# DB 트리거 기반 데이터 변경 추적 시스템 계획
+
+> **작성일**: 2026-02-07
+> **목적**: 모든 경로(앱, 직접SQL, AI, phpMyAdmin 등)의 데이터 변경을 DB 레벨에서 추적하고 복구 가능하게 함
+> **기준 문서**: `docs/specs/database-schema.md`, `api/app/Traits/Auditable.php`, `api/config/audit.php`
+> **상태**: 🔄 Phase 1-3 완료, Phase 4 핵심 완료 (4.4~4.6 옵션 잔여)
+
+---
+
+## 📍 현재 진행 상태
+
+| 항목 | 내용 |
+|------|------|
+| **마지막 완료 작업** | Phase 4 핵심 (mng 대시보드 + 목록 + 상세 + 이력 + 롤백) |
+| **다음 작업** | Phase 4.4 트리거 관리 화면 (옵션) |
+| **진행률** | 15/16 (94%) - 핵심 기능 완료, 옵션 3개 잔여 |
+| **마지막 업데이트** | 2026-02-07 |
+
+---
+
+## 1. 개요
+
+### 1.1 배경
+
+SAM 프로젝트에는 이미 Laravel `Auditable` trait 기반 감사 로그가 존재하지만, 이는 **Laravel Eloquent ORM을 통한 변경만 추적**한다. 다음 경로의 변경은 추적 불가:
+
+- AI(Claude 등)가 직접 실행하는 SQL 쿼리
+- phpMyAdmin, DBeaver 등 DB 클라이언트에서의 직접 수정
+- MySQL CLI에서의 직접 쿼리
+- 다른 애플리케이션/스크립트에서의 DB 접근
+- Laravel `DB::statement()` 등 Eloquent 우회 쿼리
+
+**해결책**: MySQL 트리거를 사용하여 DB 엔진 레벨에서 모든 INSERT/UPDATE/DELETE를 포착한다.
+
+### 1.2 기준 원칙
+
+```
++------------------------------------------------------------------+
+| 계층 분리 (Layered Audit) |
++------------------------------------------------------------------+
+| Layer 1: Laravel Audit (기존 유지) |
+| - 비즈니스 액션 (released, cloned, items_replaced 등) |
+| - 사용자 컨텍스트 풍부 (IP, UA, 세션 정보) |
+| - 실패 시 비즈니스 로직 불영향 (try/catch) |
++------------------------------------------------------------------+
+| Layer 2: MySQL Trigger Audit (신규) |
+| - 모든 DML 포착 (직접 쿼리 포함, 누락 불가) |
+| - 컬럼 단위 old/new values JSON 저장 |
+| - 특정 레코드의 특정 시점으로 복원 가능 |
++------------------------------------------------------------------+
+```
+
+### 1.3 변경 승인 정책
+
+| 분류 | 예시 | 승인 |
+|------|------|------|
+| ✅ 즉시 가능 | 트리거 대상 테이블 목록 조정, 제외 컬럼 변경 | 불필요 |
+| ⚠️ 컨펌 필요 | 마이그레이션 실행, 트리거 생성/변경, 미들웨어 추가, 새 API 엔드포인트 | **필수** |
+| 🔴 금지 | 기존 audit_logs 테이블 구조 변경, 기존 Auditable trait 수정 | 별도 협의 |
+
+### 1.4 준수 규칙
+
+- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
+- `docs/standards/quality-checklist.md` - 품질 체크리스트
+- `docs/specs/database-schema.md` - DB 스키마
+- `docs/standards/api-rules.md` - API 규칙 (Audit Logging 섹션)
+
+---
+
+## 2. 대상 범위
+
+### 2.1 Phase 1: DB 기반 구축
+
+| # | 작업 항목 | 상태 | 비고 |
+|---|----------|:----:|------|
+| 1.1 | trigger_audit_logs 테이블 마이그레이션 (파티셔닝 포함) | ✅ | 15개 파티션, 3개 인덱스 |
+| 1.2 | 트리거 대상 테이블 선정 및 확정 | ✅ | 제외 11개 외 전체 적용 |
+| 1.3 | 트리거 자동 생성 (PHP 기반, SP 불가) | ✅ | MySQL CREATE TRIGGER는 PREPARE 미지원 → PHP 마이그레이션으로 전환 |
+| 1.4 | 대상 테이블별 트리거 생성 | ✅ | 789개 트리거 (263 테이블 × 3) |
+| 1.5 | 세션 변수 설정 미들웨어 (Laravel) | ✅ | @sam_actor_id, @sam_session_info |
+
+### 2.2 Phase 2: 복구 메커니즘
+
+| # | 작업 항목 | 상태 | 비고 |
+|---|----------|:----:|------|
+| 2.1 | TriggerAuditLog 모델 | ✅ | casts, scopes, changed_columns accessor |
+| 2.2 | AuditRollbackService 구현 | ✅ | rollback SQL 생성 + 실행 + getRecordStateAt |
+| 2.3 | Trigger Audit 조회 API | ✅ | 6개 엔드포인트 (index, show, stats, history, rollback-preview, rollback) |
+| 2.4 | Rollback API 엔드포인트 | ✅ | POST /api/v1/trigger-audit-logs/{id}/rollback + confirm 필수 |
+
+### 2.3 Phase 3: 관리 도구
+
+| # | 작업 항목 | 상태 | 비고 |
+|---|----------|:----:|------|
+| 3.1 | 통합 조회 뷰 (v_unified_audit) | ✅ | APP 3,108건 + TRIGGER 2,649건 통합, COLLATE 해결 |
+| 3.2 | 파티션 자동 관리 (artisan 커맨드) | ✅ | audit:partitions --add-months --retention-months --drop --dry-run |
+| 3.3 | 트리거 재생성 artisan 커맨드 | ✅ | audit:triggers --table --drop-only --dry-run |
+
+### 2.4 Phase 4: 관리자 대시보드 (mng)
+
+| # | 작업 항목 | 상태 | 비고 |
+|---|----------|:----:|------|
+| 4.1 | 변경 이력 목록 화면 (index) | ✅ | 통계카드+필터+목록+파티션현황+트리거수, 페이지네이션 |
+| 4.2 | 레코드 상세 변경 이력 (show + history) | ✅ | diff 뷰(old/new 비교, 변경 컬럼 하이라이트) + 레코드 타임라인 |
+| 4.3 | 복구 기능 UI (rollback-preview) | ✅ | SQL 미리보기, 확인 체크박스+confirm, @disable_audit_trigger |
+| 4.4 | 트리거 관리 화면 | ⏭️ | 옵션 - artisan audit:triggers 커맨드로 CLI 관리 가능 |
+| 4.5 | 대시보드 통계 | ✅ | index에 통합 (전체/오늘/DML별 통계, 상위 테이블, 파티션, 저장소) |
+| 4.6 | 보관 정책 설정 | ⏭️ | 옵션 - artisan audit:partitions 커맨드로 CLI 관리 가능 |
+
+---
+
+## 3. 작업 절차
+
+### 3.1 아키텍처 다이어그램
+
+```
+[사용자/AI/phpMyAdmin/스크립트]
+ │
+ ▼
+ ┌─────────┐
+ │ MySQL │
+ │ Engine │
+ └────┬────┘
+ │ DML (INSERT/UPDATE/DELETE)
+ ▼
+ ┌─────────────────────────┐
+ │ 대상 테이블 │
+ │ (제외 목록 외 전체 │
+ │ 약 207개) │
+ └────┬────────────────────┘
+ │ AFTER 트리거 발동
+ ▼
+ ┌─────────────────────────┐
+ │ trigger_audit_logs │
+ │ (파티셔닝, 13개월 보관) │
+ │ - table_name │
+ │ - row_id │
+ │ - dml_type │
+ │ - old_values (JSON) │
+ │ - new_values (JSON) │
+ │ - tenant_id │
+ │ - actor_id │ ← @sam_actor_id 세션변수
+ │ - session_info │ ← @sam_session_info 세션변수
+ │ - db_user │ ← CURRENT_USER()
+ │ - created_at │
+ └─────────────────────────┘
+ │
+ ▼
+ ┌─────────────────────────┐
+ │ AuditRollbackService │
+ │ (Laravel) │
+ │ - 이력 조회 │
+ │ - Rollback SQL 생성 │
+ │ - 특정 시점 복원 │
+ └─────────────────────────┘
+```
+
+### 3.2 트리거 대상 테이블
+
+#### 적용 방침: 전체 적용 (제외 목록 방식)
+
+로컬 개발 환경에서 1인 사용이므로, **제외 대상을 제외한 모든 테이블에 트리거를 적용**한다.
+운영 환경 전환 시 필요에 따라 대상을 축소할 수 있다.
+
+SP(`sp_create_audit_triggers`)가 `INFORMATION_SCHEMA.TABLES`에서 samdb의 전체 테이블을 읽고,
+제외 목록에 없는 모든 테이블에 자동으로 트리거를 생성한다.
+
+#### 제외 대상 (트리거 미적용)
+
+| 테이블 패턴 | 사유 |
+|-------------|------|
+| `audit_logs` | 감사 로그 자체 (순환 방지) |
+| `trigger_audit_logs` | 트리거 감사 자체 (순환 방지) |
+| `personal_access_tokens` | Sanctum 토큰 (대량 생성/삭제, 보안 데이터) |
+| `sessions` | 세션 데이터 (빈번한 갱신) |
+| `cache`, `cache_locks` | 캐시 데이터 |
+| `jobs`, `job_batches` | 큐 작업 |
+| `failed_jobs` | 실패 큐 |
+| `migrations` | 마이그레이션 기록 |
+| `password_reset_tokens` | 비밀번호 리셋 토큰 |
+| `telescope_*` | 디버그 도구 (있는 경우) |
+
+> **예상**: samdb 약 219개 테이블 - 제외 약 12개 = **약 207개 테이블 × 3 트리거 = 약 621개 트리거**
+>
+> SP가 `INFORMATION_SCHEMA`에서 동적으로 테이블을 읽으므로, 테이블이 추가/삭제되면
+> `artisan audit:regenerate-triggers` 명령으로 트리거를 재생성하면 된다.
+
+### 3.3 trigger_audit_logs 테이블 구조
+
+```sql
+CREATE TABLE trigger_audit_logs (
+ id BIGINT UNSIGNED AUTO_INCREMENT,
+ table_name VARCHAR(64) NOT NULL COMMENT '변경된 테이블명',
+ row_id VARCHAR(64) NOT NULL COMMENT '변경된 레코드 PK (문자열 지원)',
+ dml_type ENUM('INSERT','UPDATE','DELETE') NOT NULL COMMENT 'DML 유형',
+ old_values JSON DEFAULT NULL COMMENT '변경 전 값 (INSERT시 NULL)',
+ new_values JSON DEFAULT NULL COMMENT '변경 후 값 (DELETE시 NULL)',
+ changed_columns JSON DEFAULT NULL COMMENT 'UPDATE시 변경된 컬럼 목록',
+ tenant_id BIGINT UNSIGNED DEFAULT NULL COMMENT '테넌트 ID',
+ actor_id BIGINT UNSIGNED DEFAULT NULL COMMENT '사용자 ID (세션변수)',
+ session_info VARCHAR(500) DEFAULT NULL COMMENT '세션 정보 JSON (IP, UA 등)',
+ db_user VARCHAR(100) DEFAULT NULL COMMENT 'CURRENT_USER()',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 시각',
+ PRIMARY KEY (id, created_at)
+) ENGINE=InnoDB
+ DEFAULT CHARSET=utf8mb4
+ COLLATE=utf8mb4_unicode_ci
+ COMMENT='DB 트리거 기반 데이터 변경 추적'
+ PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
+ PARTITION p202601 VALUES LESS THAN (UNIX_TIMESTAMP('2026-02-01')),
+ PARTITION p202602 VALUES LESS THAN (UNIX_TIMESTAMP('2026-03-01')),
+ PARTITION p202603 VALUES LESS THAN (UNIX_TIMESTAMP('2026-04-01')),
+ PARTITION p202604 VALUES LESS THAN (UNIX_TIMESTAMP('2026-05-01')),
+ PARTITION p202605 VALUES LESS THAN (UNIX_TIMESTAMP('2026-06-01')),
+ PARTITION p202606 VALUES LESS THAN (UNIX_TIMESTAMP('2026-07-01')),
+ PARTITION p202607 VALUES LESS THAN (UNIX_TIMESTAMP('2026-08-01')),
+ PARTITION p202608 VALUES LESS THAN (UNIX_TIMESTAMP('2026-09-01')),
+ PARTITION p202609 VALUES LESS THAN (UNIX_TIMESTAMP('2026-10-01')),
+ PARTITION p202610 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01')),
+ PARTITION p202611 VALUES LESS THAN (UNIX_TIMESTAMP('2026-12-01')),
+ PARTITION p202612 VALUES LESS THAN (UNIX_TIMESTAMP('2027-01-01')),
+ PARTITION p202701 VALUES LESS THAN (UNIX_TIMESTAMP('2027-02-01')),
+ PARTITION p202702 VALUES LESS THAN (UNIX_TIMESTAMP('2027-03-01')),
+ PARTITION p_future VALUES LESS THAN MAXVALUE
+ );
+
+-- 조회 성능 인덱스
+CREATE INDEX ix_trig_table_row_created
+ ON trigger_audit_logs (table_name, row_id, created_at);
+
+CREATE INDEX ix_trig_tenant_created
+ ON trigger_audit_logs (tenant_id, created_at);
+```
+
+### 3.4 트리거 자동 생성 Stored Procedure
+
+```sql
+-- 특정 테이블에 대해 AFTER INSERT/UPDATE/DELETE 트리거 3개를 자동 생성
+-- INFORMATION_SCHEMA.COLUMNS에서 컬럼 목록을 읽어 JSON_OBJECT 구문 자동 조립
+
+CALL sp_create_audit_triggers('products');
+-- → trg_products_ai (AFTER INSERT)
+-- → trg_products_au (AFTER UPDATE)
+-- → trg_products_ad (AFTER DELETE)
+```
+
+**SP 핵심 로직:**
+1. `INFORMATION_SCHEMA.COLUMNS`에서 대상 테이블의 컬럼 목록 조회
+2. 제외 컬럼 필터링 (`created_at`, `updated_at`, `deleted_at`, `remember_token` 등)
+3. `JSON_OBJECT('col1', NEW.col1, 'col2', NEW.col2, ...)` 구문 자동 조립
+4. UPDATE 트리거: 컬럼별 `OLD.col <> NEW.col` 비교 → changed_columns 배열 생성
+5. 비활성화 플래그 체크 (`@disable_audit_trigger`)
+6. `PREPARE + EXECUTE`로 트리거 DDL 실행
+
+### 3.5 세션 변수 미들웨어
+
+```php
+// app/Http/Middleware/SetAuditSessionVariables.php
+
+class SetAuditSessionVariables
+{
+ public function handle($request, $next)
+ {
+ if (auth()->check()) {
+ DB::statement("SET @sam_actor_id = ?", [auth()->id()]);
+ DB::statement("SET @sam_session_info = ?", [
+ json_encode([
+ 'ip' => $request->ip(),
+ 'ua' => substr($request->userAgent(), 0, 255),
+ 'route' => $request->route()?->getName(),
+ ])
+ ]);
+ }
+
+ return $next($request);
+ }
+}
+```
+
+### 3.6 복구 서비스
+
+```php
+// app/Services/Audit/AuditRollbackService.php
+
+class AuditRollbackService
+{
+ // 특정 audit 레코드에 대한 역방향 SQL 생성
+ public function generateRollbackSQL(int $auditId): string;
+
+ // 실제 복구 실행 (트랜잭션 내에서)
+ public function executeRollback(int $auditId): bool;
+
+ // 특정 레코드의 특정 시점 상태 조회
+ public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array;
+
+ // 특정 레코드의 변경 이력 조회
+ public function getRecordHistory(string $table, string $rowId): Collection;
+}
+```
+
+**복구 로직:**
+
+| 원본 DML | 복구 SQL |
+|----------|---------|
+| INSERT | `DELETE FROM {table} WHERE id = {row_id}` |
+| UPDATE | `UPDATE {table} SET {old_values 각 컬럼} WHERE id = {row_id}` |
+| DELETE | `INSERT INTO {table} ({old_values 컬럼}) VALUES ({old_values 값})` |
+
+---
+
+## 4. 상세 작업 내용
+
+> 각 Phase 진행 후 이 섹션에 상세 내용 추가
+
+### 4.1 Phase 1: DB 기반 구축
+- **상태**: ⏳ 대기
+- **예상 파일**:
+ - `api/database/migrations/YYYY_MM_DD_HHMMSS_create_trigger_audit_logs_table.php`
+ - `api/database/migrations/YYYY_MM_DD_HHMMSS_create_audit_trigger_stored_procedures.php`
+ - `api/database/migrations/YYYY_MM_DD_HHMMSS_create_audit_triggers_for_tables.php`
+ - `api/app/Http/Middleware/SetAuditSessionVariables.php`
+
+### 4.2 Phase 2: 복구 메커니즘
+- **상태**: ⏳ 대기
+- **예상 파일**:
+ - `api/app/Models/Audit/TriggerAuditLog.php`
+ - `api/app/Services/Audit/AuditRollbackService.php`
+ - `api/app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php`
+ - `api/app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php`
+ - `api/app/Http/Requests/Audit/TriggerAuditRollbackRequest.php`
+ - `api/app/Swagger/v1/TriggerAuditLogApi.php`
+
+### 4.3 Phase 3: 관리 도구
+- **상태**: ⏳ 대기
+- **예상 파일**:
+ - `api/database/migrations/YYYY_MM_DD_HHMMSS_create_unified_audit_view.php`
+ - `api/app/Console/Commands/ManageAuditPartitions.php`
+ - `api/app/Console/Commands/RegenerateAuditTriggers.php`
+
+### 4.4 Phase 4: 관리자 대시보드 (mng)
+- **상태**: ⏳ 대기
+- **예상 파일**:
+ - `mng/app/Http/Controllers/Admin/TriggerAuditController.php`
+ - `mng/resources/views/admin/trigger-audit/index.blade.php` (이력 목록)
+ - `mng/resources/views/admin/trigger-audit/show.blade.php` (상세 diff 뷰)
+ - `mng/resources/views/admin/trigger-audit/dashboard.blade.php` (대시보드 통계)
+ - `mng/resources/views/admin/trigger-audit/triggers.blade.php` (트리거 관리)
+ - `mng/resources/views/admin/trigger-audit/settings.blade.php` (보관 정책)
+ - `mng/app/Services/TriggerAuditDashboardService.php`
+
+---
+
+## 5. 컨펌 대기 목록
+
+| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
+|---|------|----------|----------|------|
+| 1 | 트리거 대상 | 제외 목록 외 전체 (약 207개) 적용 | database | ✅ 확정 |
+| 2 | 성능 영향 | 로컬 1인 사용, 제한 없음 | database | ✅ 확정 |
+| 3 | Phase 4 범위 | 풀 관리 대시보드 (조회/복구/트리거관리/통계/정책) | mng | ✅ 확정 |
+
+---
+
+## 6. 변경 이력
+
+| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
+|------|------|----------|------|------|
+| 2026-02-07 | 계획 | 문서 초안 작성 | - | - |
+| 2026-02-07 | 수정 | 피드백 반영: 전체 테이블 적용, Phase 4 대시보드 추가 | - | ✅ |
+| 2026-02-07 | Phase 1 | DB 기반 구축 완료. SP→PHP 전환, 789 트리거 생성, my.cnf 설정 추가 | api/database/migrations/2026_02_07_*, api/app/Http/Middleware/SetAuditSessionVariables.php, docker/mysql/my.cnf | ✅ |
+| 2026-02-07 | Phase 2 | 복구 메커니즘 API 완료. 모델/서비스/컨트롤러/라우트 6개 엔드포인트 | TriggerAuditLog.php, TriggerAuditLogService.php, AuditRollbackService.php, TriggerAuditLogController.php, audit.php(route) | ✅ |
+| 2026-02-07 | Phase 3 | 관리 도구 완료. 통합 뷰(collation 해결), 파티션 관리, 트리거 재생성 커맨드 | v_unified_audit VIEW, ManageAuditPartitions.php, RegenerateAuditTriggers.php | ✅ |
+
+---
+
+## 7. 참고 문서
+
+- **빠른 시작**: `docs/quickstart/quick-start.md`
+- **품질 체크리스트**: `docs/standards/quality-checklist.md`
+- **DB 스키마**: `docs/specs/database-schema.md`
+- **API 규칙**: `docs/standards/api-rules.md` (Audit Logging 섹션)
+- **기존 Auditable**: `api/app/Traits/Auditable.php`
+- **기존 audit 설정**: `api/config/audit.php`
+- **기존 audit 마이그레이션**: `api/database/migrations/2025_09_11_000100_create_audit_logs_table.php`
+
+### 외부 참고자료
+
+- [MySQL 8.0 Trigger Syntax](https://dev.mysql.com/doc/refman/8.0/en/trigger-syntax.html)
+- [MySQL 8.0 Partitioning](https://dev.mysql.com/doc/refman/8.0/en/partitioning.html)
+- [Percona - MySQL Trigger Performance](https://www.percona.com/blog/why-mysql-stored-procedures-functions-triggers-bad-performance/)
+
+---
+
+## 8. 리스크 및 대응 방안
+
+| 리스크 | 영향 | 대응 |
+|--------|------|------|| 트리거 성능 오버헤드 (INSERT 약 40-50%) | 쓰기 성능 저하 | 로컬 1인 사용 환경이므로 무관. 운영 전환 시 대상 축소 가능. Bulk 작업 시 `@disable_audit_trigger=1` |
+| 트리거 실패 시 원본 DML도 롤백 | 비즈니스 중단 | 트리거 로직 최소화, audit 테이블 구조 안정적 유지 |
+| 스키마 변경 시 트리거 유지보수 | 누락 위험 | SP 기반 자동 생성 → `artisan audit:regenerate-triggers` |
+| 저장 용량 증가 | 디스크 사용량 | 월별 파티셔닝 + 13개월 자동 삭제 |
+| 세션 변수 미설정 (CLI, Queue) | actor_id NULL | NULL 허용, db_user로 보완 추적 |
+
+---
+
+## 9. 검증 결과
+
+> 작업 완료 후 이 섹션에 검증 결과 추가
+
+### 9.1 테스트 케이스
+
+| 입력값 | 예상 결과 | 실제 결과 | 상태 |
+|--------|----------|----------|------|
+| Laravel에서 Product 생성 | audit_logs + trigger_audit_logs 모두 기록 | | ⏳ |
+| 직접 SQL로 Product UPDATE | trigger_audit_logs에만 기록 | | ⏳ |
+| phpMyAdmin에서 DELETE | trigger_audit_logs에 기록 (actor_id=NULL, db_user 기록) | | ⏳ |
+| Bulk INSERT 10,000건 (트리거 활성) | trigger_audit_logs에 10,000건 기록, 성능 측정 | | ⏳ |
+| Bulk INSERT 10,000건 (트리거 비활성) | trigger_audit_logs 기록 없음, 기본 성능 | | ⏳ |
+| UPDATE 후 rollback API 호출 | old_values로 복원됨 | | ⏳ |
+| DELETE 후 rollback API 호출 | 삭제된 레코드 복원됨 | | ⏳ |
+| INSERT 후 rollback API 호출 | 삽입된 레코드 삭제됨 | | ⏳ |
+| 13개월 이전 파티션 삭제 | 해당 파티션 DROP, 데이터 제거 | | ⏳ |
+
+### 9.2 성공 기준
+
+| 기준 | 달성 | 비고 |
+|------|------|------|
+| 직접 SQL 변경이 trigger_audit_logs에 기록됨 | ⏳ | |
+| old_values/new_values JSON이 정확히 저장됨 | ⏳ | |
+| 특정 레코드의 특정 시점 복원이 가능함 | ⏳ | |
+| 파티셔닝이 정상 작동함 | ⏳ | |
+| 기존 Laravel audit 시스템에 영향 없음 | ⏳ | |
+| 트리거 비활성화 플래그가 정상 동작함 | ⏳ | |
+| mng 대시보드에서 이력 조회/필터링 가능 | ⏳ | |
+| mng에서 특정 변경 복구(rollback) 가능 | ⏳ | |
+| mng에서 테이블별 트리거 ON/OFF 가능 | ⏳ | |
+
+---
+
+## 10. 자기완결성 점검 결과
+
+### 10.1 체크리스트 검증
+
+| # | 검증 항목 | 상태 | 비고 |
+|---|----------|:----:|------|
+| 1 | 작업 목적이 명확한가? | ✅ | 1.1 배경에 명시 |
+| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 성공 기준 |
+| 3 | 작업 범위가 구체적인가? | ✅ | 2.1~2.4 Phase별 작업 항목 |
+| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 순서, 기존 시스템 참조 |
+| 5 | 참고 파일 경로가 정확한가? | ✅ | 7. 참고 문서 |
+| 6 | 단계별 절차가 실행 가능한가? | ✅ | 3.3~3.6 상세 구현 명세 |
+| 7 | 검증 방법이 명시되어 있는가? | ✅ | 9.1 테스트 케이스 |
+| 8 | 모호한 표현이 없는가? | ✅ | 구체적 수치/조건 명시 |
+
+### 10.2 새 세션 시뮬레이션 테스트
+
+| 질문 | 답변 가능 | 참조 섹션 |
+|------|:--------:|----------|
+| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
+| Q2. 어디서부터 시작해야 하는가? | ✅ | 2.1 Phase 1 → 1.1 테이블 생성 |
+| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 4.1~4.4 예상 파일 목록 |
+| Q4. 작업 완료 확인 방법은? | ✅ | 9.1 테스트 케이스, 9.2 성공 기준 |
+| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |
+
+**결과**: 5/5 통과 → ✅ 자기완결성 확보
+
+---
+
+## 부록 A: 환경 정보
+
+### A.1 프로젝트 구조
+```
+SAM/ ← 프로젝트 루트
+├── api/ ← Laravel 12 REST API (독립 git)
+│ ├── app/
+│ │ ├── Http/
+│ │ │ ├── Controllers/Api/V1/ ← API 컨트롤러
+│ │ │ ├── Middleware/ ← 미들웨어
+│ │ │ └── Requests/ ← FormRequest
+│ │ ├── Models/Audit/ ← 감사 모델 (AuditLog.php)
+│ │ ├── Services/Audit/ ← 감사 서비스 (AuditLogger, AuditLogService)
+│ │ ├── Traits/ ← Auditable.php, BelongsToTenant.php
+│ │ ├── Console/Commands/ ← Artisan 커맨드
+│ │ └── Swagger/v1/ ← Swagger 문서
+│ ├── config/audit.php ← 감사 설정
+│ ├── database/migrations/ ← 마이그레이션
+│ ├── routes/
+│ │ ├── api.php ← 메인 라우트 (v1 prefix → 도메인별 분리)
+│ │ └── api/v1/ ← 도메인별 라우트 파일
+│ └── bootstrap/app.php ← Laravel 12 미들웨어 등록
+├── mng/ ← Laravel 12 관리자 패널 (독립 git)
+│ ├── app/Http/Controllers/ ← Blade 컨트롤러
+│ ├── resources/views/ ← Blade 뷰 (Tailwind + Alpine.js + HTMX)
+│ │ └── layouts/app.blade.php ← 메인 레이아웃
+│ └── routes/web.php ← 웹 라우트 (auth 미들웨어 그룹)
+├── react/ ← Next.js 15 프론트엔드
+└── docs/plans/ ← 이 문서
+```
+
+### A.2 DB 접속 정보
+```
+엔진: MySQL 8.0
+Docker 컨테이너: sam-mysql-1
+데이터베이스: samdb (주), sam_stat (통계)
+호스트: 127.0.0.1 (로컬) / sam-mysql-1 (Docker 내부)
+포트: 3306
+사용자: samuser / sampass (일반), root / root (관리자)
+문자셋: utf8mb4 / utf8mb4_unicode_ci
+타임존: Asia/Seoul
+```
+
+### A.3 주요 명령어
+```bash
+# Docker
+cd /Users/kent/Works/@KD_SAM/SAM
+docker compose up -d mysql
+
+# API 마이그레이션
+cd api && php artisan migrate
+cd api && php artisan migrate:status
+
+# MySQL 직접 접속
+docker exec -it sam-mysql-1 mysql -u root -proot samdb
+
+# MNG Vite 빌드
+cd mng && npm run dev
+```
+
+---
+
+## 부록 B: 기존 감사 시스템 코드 (수정 금지, 참조용)
+
+### B.1 Auditable Trait (`api/app/Traits/Auditable.php`)
+```php
+isFillable('created_by') && ! $model->created_by) {
+ $model->created_by = $actorId;
+ }
+ if ($model->isFillable('updated_by') && ! $model->updated_by) {
+ $model->updated_by = $actorId;
+ }
+ }
+ });
+
+ static::updating(function ($model) {
+ $actorId = static::resolveActorId();
+ if ($actorId && $model->isFillable('updated_by')) {
+ $model->updated_by = $actorId;
+ }
+ });
+
+ static::deleting(function ($model) {
+ $actorId = static::resolveActorId();
+ if ($actorId && $model->isFillable('deleted_by')) {
+ $model->deleted_by = $actorId;
+ $model->saveQuietly();
+ }
+ });
+
+ static::created(function ($model) {
+ $model->logAuditEvent('created', null, $model->toAuditSnapshot());
+ });
+
+ static::updated(function ($model) {
+ $dirty = $model->getChanges();
+ $excluded = $model->getAuditExcludedFields();
+ $changed = array_diff_key($dirty, array_flip($excluded));
+ if (empty($changed)) return;
+
+ $before = [];
+ $after = [];
+ foreach ($changed as $key => $value) {
+ $before[$key] = $model->getOriginal($key);
+ $after[$key] = $value;
+ }
+ $model->logAuditEvent('updated', $before, $after);
+ });
+
+ static::deleted(function ($model) {
+ $model->logAuditEvent('deleted', $model->toAuditSnapshot(), null);
+ });
+ }
+
+ public function getAuditExcludedFields(): array
+ {
+ $defaults = ['created_at','updated_at','deleted_at','created_by','updated_by','deleted_by'];
+ $custom = property_exists($this, 'auditExclude') ? $this->auditExclude : [];
+ return array_merge($defaults, $custom);
+ }
+
+ public function getAuditTargetType(): string
+ {
+ return Str::snake(class_basename(static::class));
+ }
+
+ protected function toAuditSnapshot(): array
+ {
+ return array_diff_key($this->attributesToArray(), array_flip($this->getAuditExcludedFields()));
+ }
+
+ protected function logAuditEvent(string $action, ?array $before, ?array $after): void
+ {
+ try {
+ $tenantId = $this->tenant_id ?? null;
+ if (! $tenantId) return;
+ $request = request();
+ AuditLog::create([
+ 'tenant_id' => $tenantId,
+ 'target_type' => $this->getAuditTargetType(),
+ 'target_id' => $this->getKey(),
+ 'action' => $action,
+ 'before' => $before,
+ 'after' => $after,
+ 'actor_id' => static::resolveActorId(),
+ 'ip' => $request?->ip(),
+ 'ua' => $request?->userAgent(),
+ 'created_at' => now(),
+ ]);
+ } catch (\Throwable $e) {
+ // 감사 로그 실패는 업무 흐름을 방해하지 않음
+ }
+ }
+
+ protected static function resolveActorId(): ?int
+ {
+ return auth()->id();
+ }
+}
+```
+
+### B.2 AuditLog 모델 (`api/app/Models/Audit/AuditLog.php`)
+```php
+ 'array',
+ 'after' => 'array',
+ 'created_at' => 'datetime',
+ ];
+}
+```
+
+### B.3 AuditLogService (`api/app/Services/Audit/AuditLogService.php`)
+```php
+tenantId();
+ $q = AuditLog::query()->where('tenant_id', $tenantId);
+
+ if (! empty($filters['target_type'])) $q->where('target_type', $filters['target_type']);
+ if (! empty($filters['target_id'])) $q->where('target_id', (int) $filters['target_id']);
+ if (! empty($filters['action'])) $q->where('action', $filters['action']);
+ if (! empty($filters['actor_id'])) $q->where('actor_id', (int) $filters['actor_id']);
+ if (! empty($filters['from'])) $q->where('created_at', '>=', $filters['from']);
+ if (! empty($filters['to'])) $q->where('created_at', '<=', $filters['to']);
+
+ $sort = $filters['sort'] ?? 'created_at';
+ $order = $filters['order'] ?? 'desc';
+ $size = (int) ($filters['size'] ?? 20);
+
+ return $q->orderBy($sort, $order)->paginate($size);
+ }
+}
+```
+
+### B.4 Audit Config (`api/config/audit.php`)
+```php
+ env('AUDIT_RETENTION_DAYS', 395), // 13개월
+ 'log_reads' => env('AUDIT_LOG_READS', false),
+];
+```
+
+### B.5 API 컨트롤러 패턴 (`api/app/Http/Controllers/Api/V1/Design/AuditLogController.php`)
+```php
+service->paginate($request->validated());
+ }, __('message.fetched'));
+ }
+}
+```
+
+### B.6 API Kernel (`api/app/Http/Kernel.php`)
+```php
+ [],
+ 'api' => [],
+ ];
+ protected $routeMiddleware = [];
+}
+```
+
+> **참고**: Laravel 12에서 미들웨어 추가 시 `bootstrap/app.php`의 `->withMiddleware()` 또는
+> `Kernel.php`의 `$middleware` / `$middlewareGroups`에 등록한다.
+
+### B.7 API 라우트 패턴 (`api/routes/api.php`)
+```php
+// 도메인별 분리 구조
+Route::prefix('v1')->group(function () {
+ require __DIR__.'/api/v1/auth.php';
+ require __DIR__.'/api/v1/design.php';
+ // ... 기타 도메인
+});
+
+// design.php 내 감사 로그 라우트 예시
+Route::prefix('design')->group(function () {
+ Route::prefix('audit-logs')->group(function () {
+ Route::get('', [DesignAuditLogController::class, 'index']);
+ Route::get('/{id}', [DesignAuditLogController::class, 'show'])->whereNumber('id');
+ });
+});
+```
+
+### B.8 Artisan 커맨드 패턴 (예: `TenantsBootstrap.php`)
+```php
+
+
+
+
+
+ @yield('title', 'Dashboard') - {{ config('app.name') }}
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+ @include('components.sidebar.main')
+
+ @yield('content')
+
+ @stack('scripts')
+
+
+```
+
+### C.3 MNG 컨트롤러 패턴 (기존 `AuditLogController.php` 요약)
+```php
+orderByDesc('created_at');
+
+ // 필터링 (target_type, action, tenant_id, from, to, search)
+ if ($request->filled('target_type')) $query->where('target_type', $request->target_type);
+ if ($request->filled('action')) $query->where('action', $request->action);
+ if ($request->filled('from')) $query->where('created_at', '>=', $request->from.' 00:00:00');
+ if ($request->filled('to')) $query->where('created_at', '<=', $request->to.' 23:59:59');
+
+ // 통계
+ $stats = [...];
+
+ // 페이지네이션
+ $logs = $query->paginate(50)->withQueryString();
+
+ return view('audit-logs.index', compact('logs', 'stats'));
+ }
+
+ public function show(int $id): View
+ {
+ $log = AuditLog::findOrFail($id);
+ return view('audit-logs.show', compact('log'));
+ }
+}
+```
+
+### C.4 MNG 뷰 패턴 (데이터 목록 화면)
+```blade
+@extends('layouts.app')
+@section('title', '페이지 제목')
+@section('content')
+
+{{-- 1. 헤더 --}}
+
+
페이지 제목
+
+
+{{-- 2. 통계 카드 --}}
+
+
+
전체 기록
+
{{ number_format($stats['total']) }}
+
+
+
+{{-- 3. 필터 폼 --}}
+
+
+
+
+{{-- 4. 데이터 테이블 (HTMX 방식 또는 일반 방식) --}}
+
+
+
+
+ | 컬럼 |
+
+
+
+ @foreach($items as $item)
+
+ | {{ $item->field }} |
+
+ @endforeach
+
+
+
{{ $items->links() }}
+
+
+@endsection
+```
+
+### C.5 MNG 라우트 패턴 (`mng/routes/web.php`)
+```php
+// 인증 필수 라우트 그룹
+Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () {
+
+ // 감사 로그 (기존)
+ Route::prefix('audit-logs')->group(function () {
+ Route::get('/', [AuditLogController::class, 'index'])->name('audit-logs.index');
+ Route::get('/{id}', [AuditLogController::class, 'show'])->name('audit-logs.show');
+ });
+
+ // 새 트리거 감사는 여기에 추가:
+ // Route::prefix('trigger-audit')->name('trigger-audit.')->group(function () { ... });
+});
+```
+
+### C.6 MNG 미들웨어 목록
+```
+mng/app/Http/Middleware/
+├── EnsureHQMember.php ← 본사 소속 확인
+├── EnsurePasswordChanged.php ← 비밀번호 변경 확인
+├── EnsureSuperAdmin.php ← 슈퍼관리자 확인
+└── AutoLoginViaRemember.php ← Remember Token 자동 재인증
+```
+
+---
+
+## 부록 D: SP 구현 상세 (Phase 1.3 참조)
+
+### D.1 sp_create_audit_triggers 전체 구현 방향
+
+```sql
+DELIMITER //
+
+DROP PROCEDURE IF EXISTS sp_create_audit_triggers //
+
+CREATE PROCEDURE sp_create_audit_triggers(
+ IN p_table_name VARCHAR(64),
+ IN p_db_name VARCHAR(64)
+)
+BEGIN
+ DECLARE v_col_list TEXT DEFAULT '';
+ DECLARE v_json_new TEXT DEFAULT '';
+ DECLARE v_json_old TEXT DEFAULT '';
+ DECLARE v_change_check TEXT DEFAULT '';
+ DECLARE v_changed_cols TEXT DEFAULT '';
+ DECLARE v_tenant_col VARCHAR(64) DEFAULT NULL;
+ DECLARE v_pk_col VARCHAR(64) DEFAULT 'id';
+ DECLARE v_done INT DEFAULT 0;
+ DECLARE v_col_name VARCHAR(64);
+ DECLARE v_sql TEXT;
+
+ -- 제외 컬럼
+ DECLARE v_exclude_cols TEXT DEFAULT 'created_at,updated_at,deleted_at,remember_token';
+
+ -- 커서: 대상 컬럼 목록
+ DECLARE col_cursor CURSOR FOR
+ SELECT COLUMN_NAME
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = p_db_name
+ AND TABLE_NAME = p_table_name
+ AND FIND_IN_SET(COLUMN_NAME, v_exclude_cols) = 0
+ ORDER BY ORDINAL_POSITION;
+
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
+
+ -- tenant_id 컬럼 존재 확인
+ SELECT COLUMN_NAME INTO v_tenant_col
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = p_db_name
+ AND TABLE_NAME = p_table_name
+ AND COLUMN_NAME = 'tenant_id'
+ LIMIT 1;
+
+ -- PK 컬럼 확인
+ SELECT COLUMN_NAME INTO v_pk_col
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = p_db_name
+ AND TABLE_NAME = p_table_name
+ AND COLUMN_KEY = 'PRI'
+ LIMIT 1;
+
+ -- 컬럼별 JSON_OBJECT 구문 조립
+ OPEN col_cursor;
+ col_loop: LOOP
+ FETCH col_cursor INTO v_col_name;
+ IF v_done THEN LEAVE col_loop; END IF;
+
+ -- JSON 조립
+ IF v_json_new != '' THEN
+ SET v_json_new = CONCAT(v_json_new, ',');
+ SET v_json_old = CONCAT(v_json_old, ',');
+ END IF;
+ SET v_json_new = CONCAT(v_json_new, '''', v_col_name, ''', NEW.`', v_col_name, '`');
+ SET v_json_old = CONCAT(v_json_old, '''', v_col_name, ''', OLD.`', v_col_name, '`');
+
+ -- UPDATE 변경 감지 조립 (NULL-safe 비교)
+ IF v_change_check != '' THEN
+ SET v_change_check = CONCAT(v_change_check, ' OR ');
+ SET v_changed_cols = CONCAT(v_changed_cols, ',');
+ END IF;
+ SET v_change_check = CONCAT(v_change_check,
+ 'NOT(OLD.`', v_col_name, '` <=> NEW.`', v_col_name, '`)');
+ SET v_changed_cols = CONCAT(v_changed_cols,
+ 'IF(NOT(OLD.`', v_col_name, '` <=> NEW.`', v_col_name, '`),''', v_col_name, ''',NULL)');
+ END LOOP;
+ CLOSE col_cursor;
+
+ -- tenant_id 참조
+ SET @tenant_expr = IF(v_tenant_col IS NOT NULL,
+ CONCAT('NEW.`', v_tenant_col, '`'), 'NULL');
+ SET @tenant_expr_old = IF(v_tenant_col IS NOT NULL,
+ CONCAT('OLD.`', v_tenant_col, '`'), 'NULL');
+
+ -- 1. 기존 트리거 삭제
+ SET @drop1 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_ai');
+ SET @drop2 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_au');
+ SET @drop3 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_ad');
+ PREPARE s FROM @drop1; EXECUTE s; DEALLOCATE PREPARE s;
+ PREPARE s FROM @drop2; EXECUTE s; DEALLOCATE PREPARE s;
+ PREPARE s FROM @drop3; EXECUTE s; DEALLOCATE PREPARE s;
+
+ -- 2. AFTER INSERT 트리거
+ SET v_sql = CONCAT(
+ 'CREATE TRIGGER trg_', p_table_name, '_ai AFTER INSERT ON `', p_table_name, '` ',
+ 'FOR EACH ROW BEGIN ',
+ 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
+ 'INSERT INTO trigger_audit_logs(table_name,row_id,dml_type,old_values,new_values,tenant_id,actor_id,session_info,db_user) ',
+ 'VALUES(''', p_table_name, ''',NEW.`', v_pk_col, '`,''INSERT'',NULL,',
+ 'JSON_OBJECT(', v_json_new, '),',
+ @tenant_expr, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
+ 'END IF; END'
+ );
+ SET @s = v_sql;
+ PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+ -- 3. AFTER UPDATE 트리거 (변경 있을 때만)
+ SET v_sql = CONCAT(
+ 'CREATE TRIGGER trg_', p_table_name, '_au AFTER UPDATE ON `', p_table_name, '` ',
+ 'FOR EACH ROW BEGIN ',
+ 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
+ 'IF ', v_change_check, ' THEN ',
+ 'INSERT INTO trigger_audit_logs(table_name,row_id,dml_type,old_values,new_values,changed_columns,tenant_id,actor_id,session_info,db_user) ',
+ 'VALUES(''', p_table_name, ''',NEW.`', v_pk_col, '`,''UPDATE'',',
+ 'JSON_OBJECT(', v_json_old, '),',
+ 'JSON_OBJECT(', v_json_new, '),',
+ 'JSON_REMOVE(JSON_ARRAY(', v_changed_cols, '),',
+ -- NULL 값 제거 (변경 안 된 컬럼)
+ '''$[0]''),', -- 간소화: 실제 구현 시 NULL 필터링 로직 보강 필요
+ @tenant_expr, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
+ 'END IF; END IF; END'
+ );
+ SET @s = v_sql;
+ PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+ -- 4. AFTER DELETE 트리거
+ SET v_sql = CONCAT(
+ 'CREATE TRIGGER trg_', p_table_name, '_ad AFTER DELETE ON `', p_table_name, '` ',
+ 'FOR EACH ROW BEGIN ',
+ 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
+ 'INSERT INTO trigger_audit_logs(table_name,row_id,dml_type,old_values,new_values,tenant_id,actor_id,session_info,db_user) ',
+ 'VALUES(''', p_table_name, ''',OLD.`', v_pk_col, '`,''DELETE'',',
+ 'JSON_OBJECT(', v_json_old, '),NULL,',
+ @tenant_expr_old, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
+ 'END IF; END'
+ );
+ SET @s = v_sql;
+ PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+END //
+
+DELIMITER ;
+```
+
+> **주의**: 위 코드는 구현 방향을 보여주는 참조 코드이다.
+> 실제 구현 시 changed_columns의 NULL 필터링, 복합 PK 처리, 에러 핸들링 등을 보강해야 한다.
+
+### D.2 전체 테이블 일괄 트리거 생성 프로시저
+
+```sql
+DELIMITER //
+
+CREATE PROCEDURE sp_create_all_audit_triggers(IN p_db_name VARCHAR(64))
+BEGIN
+ DECLARE v_tbl VARCHAR(64);
+ DECLARE v_done INT DEFAULT 0;
+ DECLARE v_count INT DEFAULT 0;
+
+ -- 제외 테이블 목록
+ DECLARE v_exclude TEXT DEFAULT
+ 'audit_logs,trigger_audit_logs,personal_access_tokens,sessions,'
+ 'cache,cache_locks,jobs,job_batches,failed_jobs,migrations,'
+ 'password_reset_tokens';
+
+ DECLARE tbl_cursor CURSOR FOR
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = p_db_name
+ AND TABLE_TYPE = 'BASE TABLE'
+ AND TABLE_NAME NOT LIKE 'telescope_%'
+ AND FIND_IN_SET(TABLE_NAME, v_exclude) = 0
+ ORDER BY TABLE_NAME;
+
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
+
+ OPEN tbl_cursor;
+ tbl_loop: LOOP
+ FETCH tbl_cursor INTO v_tbl;
+ IF v_done THEN LEAVE tbl_loop; END IF;
+
+ CALL sp_create_audit_triggers(v_tbl, p_db_name);
+ SET v_count = v_count + 1;
+ END LOOP;
+ CLOSE tbl_cursor;
+
+ SELECT CONCAT('Created triggers for ', v_count, ' tables') AS result;
+END //
+
+DELIMITER ;
+
+-- 실행:
+-- CALL sp_create_all_audit_triggers('samdb');
+```
+
+---
+
+## 부록 E: 복구 서비스 구현 상세 (Phase 2.2 참조)
+
+```php
+dml_type) {
+ 'INSERT' => $this->buildDeleteSQL($log),
+ 'UPDATE' => $this->buildRevertUpdateSQL($log),
+ 'DELETE' => $this->buildReinsertSQL($log),
+ };
+ }
+
+ /**
+ * 복구 실행 (트랜잭션)
+ */
+ public function executeRollback(int $auditId): bool
+ {
+ $log = TriggerAuditLog::findOrFail($auditId);
+
+ // 트리거 감사 비활성화 (복구 작업 자체는 기록 안 함)
+ DB::statement('SET @disable_audit_trigger = 1');
+
+ try {
+ DB::transaction(function () use ($log) {
+ $sql = $this->generateRollbackSQL($log->id);
+ DB::statement($sql);
+ });
+ return true;
+ } finally {
+ DB::statement('SET @disable_audit_trigger = NULL');
+ }
+ }
+
+ /**
+ * 특정 레코드의 특정 시점 상태 조회
+ */
+ public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array
+ {
+ // 해당 시점 이전의 가장 마지막 상태를 추적
+ $log = TriggerAuditLog::where('table_name', $table)
+ ->where('row_id', $rowId)
+ ->where('created_at', '<=', $at)
+ ->orderByDesc('created_at')
+ ->first();
+
+ if (! $log) return null;
+
+ return match ($log->dml_type) {
+ 'INSERT', 'UPDATE' => $log->new_values,
+ 'DELETE' => null, // 해당 시점에 삭제된 상태
+ };
+ }
+
+ /**
+ * 특정 레코드의 변경 이력
+ */
+ public function getRecordHistory(string $table, string $rowId): Collection
+ {
+ return TriggerAuditLog::where('table_name', $table)
+ ->where('row_id', $rowId)
+ ->orderByDesc('created_at')
+ ->get();
+ }
+
+ private function buildDeleteSQL(TriggerAuditLog $log): string
+ {
+ return "DELETE FROM `{$log->table_name}` WHERE `id` = " . DB::getPdo()->quote($log->row_id);
+ }
+
+ private function buildRevertUpdateSQL(TriggerAuditLog $log): string
+ {
+ $sets = collect($log->old_values)
+ ->map(fn($val, $col) => "`{$col}` = " . ($val === null ? 'NULL' : DB::getPdo()->quote($val)))
+ ->implode(', ');
+
+ return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = " . DB::getPdo()->quote($log->row_id);
+ }
+
+ private function buildReinsertSQL(TriggerAuditLog $log): string
+ {
+ $cols = collect($log->old_values)->keys()->map(fn($c) => "`{$c}`")->implode(', ');
+ $vals = collect($log->old_values)->values()
+ ->map(fn($v) => $v === null ? 'NULL' : DB::getPdo()->quote($v))
+ ->implode(', ');
+
+ return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})";
+ }
+}
+```
+
+---
+
+## 부록 F: 세션 시작 가이드 (새 세션용)
+
+### 이 문서로 작업을 시작하는 방법
+
+```
+1. Serena 메모리 로드
+ → read_memory("db-trigger-audit-state") : 진행 상태 확인
+
+2. 이 문서의 "📍 현재 진행 상태" 확인
+ → 마지막 완료 작업, 다음 작업 확인
+
+3. 해당 Phase의 "대상 범위" (섹션 2) 확인
+ → 구체적 작업 항목과 상태 확인
+
+4. 해당 작업의 구현 코드는 "작업 절차" (섹션 3) + "부록" 참조
+ → 부록 B: 기존 코드 패턴 (수정 금지)
+ → 부록 C: MNG 패턴 (Phase 4용)
+ → 부록 D: SP 구현 상세 (Phase 1.3용)
+ → 부록 E: 복구 서비스 상세 (Phase 2.2용)
+
+5. 작업 완료 후
+ → 이 문서의 진행 상태 업데이트
+ → Serena 메모리 저장: write_memory("db-trigger-audit-state", ...)
+```
+
+### 환경 확인 명령어
+
+```bash
+# Docker MySQL 실행 확인
+docker ps | grep sam-mysql
+
+# 마이그레이션 상태
+cd /Users/kent/Works/@KD_SAM/SAM/api && php artisan migrate:status
+
+# 현재 트리거 목록 확인
+docker exec -it sam-mysql-1 mysql -u root -proot samdb -e "SHOW TRIGGERS"
+
+# trigger_audit_logs 레코드 수
+docker exec -it sam-mysql-1 mysql -u root -proot samdb -e "SELECT COUNT(*) FROM trigger_audit_logs"
+```
+
+---
+
+*이 문서는 /sc:plan 스킬로 생성되었습니다.*
\ No newline at end of file
diff --git a/plans/intermediate-inspection-report-plan.md b/plans/intermediate-inspection-report-plan.md
new file mode 100644
index 0000000..d0b7d6c
--- /dev/null
+++ b/plans/intermediate-inspection-report-plan.md
@@ -0,0 +1,1001 @@
+# 중간검사 성적서 시스템 구현 계획
+
+> **작성일**: 2026-02-07
+> **목적**: 작업자 화면에서 개소별 중간검사 데이터를 저장하고, 검사성적서보기 시 문서 템플릿 기반 성적서로 합쳐서 표시하는 시스템 구축
+> **기준 문서**: `docs/plans/document-management-system-plan.md`, `docs/specs/database-schema.md`
+> **상태**: 📋 계획 수립 완료 → 사용자 검토 대기
+
+---
+
+## 🚀 새 세션 시작 가이드
+
+> **이 섹션은 새 세션에서 이 문서만 보고 작업을 시작할 수 있도록 작성되었습니다.**
+
+### 프로젝트 정보
+
+| 항목 | 내용 |
+|------|------|
+| **작업 프로젝트** | `react` (프론트엔드) + `api` (백엔드) |
+| **react 절대 경로** | `/Users/kent/Works/@KD_SAM/SAM/react/` |
+| **api 절대 경로** | `/Users/kent/Works/@KD_SAM/SAM/api/` |
+| **mng 절대 경로** | `/Users/kent/Works/@KD_SAM/SAM/mng/` (양식 관리 참조) |
+| **기술 스택** | Next.js 15 (react) / Laravel 12 (api) |
+| **로컬 URL** | `https://dev.sam.kr/production/worker-screen` |
+| **관련 계획서** | `docs/plans/document-management-system-plan.md` (문서관리 시스템 80% 완료) |
+
+### Git 저장소
+
+```bash
+# react (프론트엔드) - 독립 Git 저장소
+cd /Users/kent/Works/@KD_SAM/SAM/react
+git status && git branch
+
+# api (백엔드) - 독립 Git 저장소
+cd /Users/kent/Works/@KD_SAM/SAM/api
+git status && git branch
+```
+
+> **주의**: SAM/ 루트는 Git 저장소가 아님. api/, mng/, react/ 각각 독립 Git 저장소.
+
+### 세션 시작 체크리스트
+
+```
+1. 이 문서를 읽는다 (📍 현재 진행 상태 섹션 확인)
+2. react/CLAUDE.md 를 읽는다 (프론트엔드 프로젝트 규칙)
+3. 마지막 완료 작업 확인 → 다음 작업 결정
+4. 해당 Phase의 상세 절차(섹션 5)를 읽는다
+5. 작업 시작 전 사용자에게 "Phase X.X 시작할까요?" 확인
+```
+
+### 핵심 파일 (작업 빈도순)
+
+**Frontend (react)**
+
+| 파일 | 설명 |
+|------|------|
+| `react/src/components/production/WorkerScreen/index.tsx` | 작업자 화면 메인 (중간검사 버튼 핸들러) |
+| `react/src/components/production/WorkerScreen/InspectionInputModal.tsx` | 중간검사 입력 모달 (개소별 데이터 입력) |
+| `react/src/components/production/WorkerScreen/types.ts` | InspectionData, WorkItemData, InspectionDataMap 타입 |
+| `react/src/components/production/WorkerScreen/actions.ts` | 작업자 화면 서버 액션 |
+| `react/src/components/production/WorkOrders/documents/InspectionReportModal.tsx` | 중간검사 성적서 모달 (문서 래퍼) |
+| `react/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx` | 스크린 검사 성적서 콘텐츠 |
+| `react/src/components/production/WorkOrders/documents/SlatInspectionContent.tsx` | 슬랫 검사 성적서 콘텐츠 |
+| `react/src/components/production/WorkOrders/documents/BendingInspectionContent.tsx` | 절곡 검사 성적서 콘텐츠 |
+| `react/src/components/production/WorkOrders/actions.ts` | saveInspectionData 서버 액션 (628-668줄) |
+| `react/src/components/document-system/configs/qms/index.ts` | QMS 문서 Config 6종 |
+
+**Backend (api)**
+
+| 파일 | 설명 |
+|------|------|
+| `api/app/Http/Controllers/Api/V1/InspectionController.php` | 검사 CRUD API (89줄) |
+| `api/app/Services/InspectionService.php` | 검사 비즈니스 로직 (402줄) |
+| `api/routes/api/v1/production.php` | 생산 관련 라우트 |
+
+**MNG (양식 관리)**
+
+| 파일 | 설명 |
+|------|------|
+| `mng/app/Models/DocumentTemplate.php` | 양식 템플릿 모델 |
+| `mng/app/Models/Documents/Document.php` | 문서 인스턴스 모델 |
+| `mng/resources/views/document-templates/edit.blade.php` | 양식 편집 UI |
+
+### 현재 코드 구조 (핵심 타입/인터페이스)
+
+**InspectionData (프론트 - 검사 입력 데이터)**
+
+```typescript
+// react/src/components/production/WorkerScreen/InspectionInputModal.tsx:35-56
+export type InspectionProcessType = 'screen' | 'slat' | 'slat_jointbar' | 'bending' | 'bending_wip';
+
+export interface InspectionData {
+ productName: string;
+ specification: string;
+ bendingStatus?: 'good' | 'bad' | null; // 절곡상태
+ processingStatus?: 'good' | 'bad' | null; // 가공상태
+ sewingStatus?: 'good' | 'bad' | null; // 재봉상태
+ assemblyStatus?: 'good' | 'bad' | null; // 조립상태
+ length?: number | null;
+ width?: number | null;
+ height1?: number | null;
+ height2?: number | null;
+ length3?: number | null;
+ gap4?: number | null;
+ gapStatus?: 'ok' | 'ng' | null;
+ gapPoints?: { left: number | null; right: number | null }[];
+ judgment: 'pass' | 'fail' | null;
+ nonConformingContent: string;
+}
+```
+
+**InspectionDataMap (프론트 - 아이템별 검사 데이터 맵)**
+
+```typescript
+// react/src/components/production/WorkOrders/documents/InspectionReportModal.tsx:37
+export type InspectionDataMap = Map;
+// key: workItem.id (또는 selectedOrder.id), value: InspectionData
+```
+
+**WorkItemData (프론트 - 작업 아이템)**
+
+```typescript
+// react/src/components/production/WorkerScreen/types.ts:32-58
+export interface WorkItemData {
+ id: string;
+ itemNo: number;
+ itemCode: string;
+ itemName: string;
+ floor: string;
+ code: string;
+ width: number;
+ height: number;
+ quantity: number;
+ processType: ProcessTab; // 'screen' | 'slat' | 'bending'
+ steps: WorkStepData[];
+ isWip?: boolean;
+ isJointBar?: boolean;
+ cuttingInfo?: CuttingInfo; // 스크린 전용
+ slatInfo?: SlatInfo; // 슬랫 전용
+ slatJointBarInfo?: SlatJointBarInfo; // 조인트바 전용
+ bendingInfo?: BendingInfo; // 절곡 전용
+ wipInfo?: WipInfo; // 재공품 전용
+ materialInputs?: MaterialListItem[];
+}
+```
+
+**WorkOrderItem 모델 (백엔드)**
+
+```php
+// api/app/Models/Production/WorkOrderItem.php
+class WorkOrderItem extends Model {
+ use Auditable, BelongsToTenant;
+
+ protected $fillable = [
+ 'tenant_id', 'work_order_id', 'source_order_item_id',
+ 'item_id', 'item_name', 'specification',
+ 'quantity', 'unit', 'sort_order', 'status', 'options',
+ ];
+
+ protected $casts = ['options' => 'array']; // ← JSON 컬럼, inspection_data 저장 대상
+
+ // options['result'] 패턴이 이미 존재 (작업 완료 결과 저장)
+ // 동일 패턴으로 options['inspection_data'] 추가 예정
+ public function getResult(): ?array { return $this->options['result'] ?? null; }
+ public function setResult(array $result): void {
+ $options = $this->options ?? [];
+ $options['result'] = array_merge($options['result'] ?? [], $result);
+ $this->options = $options;
+ }
+}
+```
+
+**InspectionReportModal - 공정별 라우팅 로직**
+
+```typescript
+// react/src/components/production/WorkOrders/documents/InspectionReportModal.tsx:185-201
+switch (processType) {
+ case 'screen':
+ return ;
+ case 'slat':
+ if (isJointBar || order.items?.some(item => item.productName?.includes('조인트바'))) {
+ return ;
+ }
+ return ;
+ case 'bending':
+ return ;
+ case 'bending_wip':
+ return ;
+}
+```
+
+**WorkerScreen - 검사 핸들러 (핵심 흐름)**
+
+```typescript
+// react/src/components/production/WorkerScreen/index.tsx
+// 1) 중간검사 입력: handleInspectionClick (802줄) → InspectionInputModal 오픈
+// 2) 검사 완료: handleInspectionComplete (862줄) → inspectionDataMap에 저장 (메모리만!)
+// 3) 성적서 보기: handleInspection (851줄) → InspectionReportModal 오픈
+// - workItems + inspectionDataMap을 props로 전달
+
+const [inspectionDataMap, setInspectionDataMap] = useState