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) + + + + @endforeach + +
컬럼
{{ $item->field }}
+
{{ $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>(new Map()); + +const handleInspectionComplete = useCallback((data: InspectionData) => { + if (selectedOrder) { + setInspectionDataMap((prev) => { + const next = new Map(prev); + next.set(selectedOrder.id, data); // ← 현재: 메모리에만 저장, API 호출 없음! + return next; + }); + } +}, [selectedOrder]); +``` + +**기존 saveInspectionData 서버 액션 (미완성)** + +```typescript +// react/src/components/production/WorkOrders/actions.ts:628-668 +export async function saveInspectionData( + workOrderId: string, processType: string, data: unknown +): Promise<{ success: boolean; error?: string }> { + // POST /api/v1/work-orders/{workOrderId}/inspection + // ⚠️ 문제: 백엔드에 이 엔드포인트가 존재하지 않음! + // production.php 라우트에 /work-orders/{id}/inspection 없음 +} +``` + +### 백엔드 라우트 구조 (현재) + +```php +// api/routes/api/v1/production.php (43-74줄) +Route::prefix('work-orders')->group(function () { + // 기본 CRUD: index, stats, store, show, update, destroy + // 상태 관리: updateStatus, assign, toggleBendingField + // 이슈: addIssue, resolveIssue + // 품목: updateItemStatus + // 자재: materials, registerMaterialInput, materialInputHistory + // 단계 진행: stepProgress, toggleStepProgress + // ⚠️ 검사(inspection) 관련 라우트 없음 → Phase 1에서 추가 필요 +}); + +// 별도 검사 API (InspectionController) - 범용 검사, 작업지시와 직접 연결 아님 +Route::prefix('inspections')->group(function () { + Route::get('', [InspectionController::class, 'index']); // 목록 + Route::post('', [InspectionController::class, 'store']); // 생성 + Route::get('/{id}', [InspectionController::class, 'show']); // 상세 + // ... +}); +``` + +### 백엔드 컨트롤러/서비스 구조 (현재) + +```php +// WorkOrderController: 18개 메서드 (inspection 관련 없음) +// WorkOrderService: 16개 메서드 (1493줄, inspection 관련 없음) +// → Phase 1에서 3개 메서드 추가 필요: +// storeItemInspection, getInspectionData, getInspectionReport +``` + +### 문서 템플릿 DB 구조 (이미 존재) + +``` +document_templates # 양식 마스터 +├── document_template_approval_lines # 결재라인 (작성/검토/승인) +├── document_template_basic_fields # 기본필드 (품명, LOT NO 등) +├── document_template_sections # 섹션 (검사기준서 섹션) +│ └── document_template_section_items # 섹션 항목 (검사항목) +└── document_template_columns # 데이터 테이블 컬럼 + +documents # 문서 인스턴스 +├── document_approvals # 결재 이력 +├── document_data # 필드 데이터 (EAV, field_key/field_value) +└── document_attachments # 첨부 파일 +``` + +--- + +## 📍 현재 진행 상태 + +| 항목 | 내용 | +|------|------| +| **마지막 완료 작업** | 분석 완료 - 중간검사 모달/문서관리 시스템/성적서 컴포넌트 전체 분석 | +| **다음 작업** | Phase 1.1 - 백엔드 API 설계 | +| **진행률** | 0/14 (0%) | +| **마지막 업데이트** | 2026-02-07 | + +--- + +## 1. 개요 + +### 1.1 배경 + +현재 중간검사 시스템은 다음 상태입니다: + +**개소별 검사 입력 (InspectionInputModal)** +- 스타일 수입검사 모달과 통일 완료 (2026-02-07) +- 공정별 입력 항목: 스크린(6항목), 슬랫(5항목), 조인트바(6항목), 절곡(3항목+간격5포인트), 재공품(3항목+간격) +- 데이터 저장: **프론트 메모리(InspectionDataMap)에만 보관**, 백엔드 저장 미구현 + +**검사 성적서 보기 (InspectionReportModal)** +- 4종 하드코딩 컴포넌트: Screen/Slat/Bending/BendingWip InspectionContent +- 조인트바 자동 감지 (SlatJointBarInspectionContent) +- 문서 템플릿 시스템 미활용 (레이아웃 코드 내 고정) +- workItems 기반 동적 행 생성 + inspectionDataMap에서 데이터 매핑 + +**문서관리 시스템 (80% 완료)** +- mng.sam.kr/document-templates에서 양식 CRUD 가능 +- API: documents/resolve (카테고리+아이템 기반 조회), documents/upsert (저장) +- EAV 패턴: document_data 테이블 (field_key/field_value) +- 수입검사 성적서는 이미 문서관리 시스템 연동 완료 + +**문제점** +1. 개소별 검사 데이터가 프론트 메모리에만 존재 → 새로고침 시 소실 +2. 성적서 레이아웃이 하드코딩 → 양식 변경 시 코드 수정 필요 +3. 검사 이력 관리 불가 → 언제 누가 어떤 데이터를 입력했는지 추적 불가 + +### 1.2 핵심 원칙 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 하이브리드 방식 │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. 개소별 데이터 → work_order_items.options JSON에 저장 │ +│ 2. 성적서 레이아웃 → 문서 템플릿 시스템(mng)에서 관리 │ +│ 3. 성적서 보기 → 템플릿 레이아웃 + 개소 데이터 결합하여 렌더링 │ +│ 4. 기존 InspectionContent 컴포넌트 → Config 기반으로 점진 전환 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 수입검사 성적서와의 비교 + +| 구분 | 수입검사 성적서 | 중간검사 성적서 (목표) | +|------|----------------|----------------------| +| **데이터 소스** | 1입고 = 1문서 | N개소 = 1문서 (합산) | +| **데이터 수집** | 한 번에 입력 | 개소별로 따로 입력 후 합산 | +| **데이터 저장** | document_data (EAV) | work_order_items.options (JSON) | +| **레이아웃** | 문서 템플릿 시스템 | 문서 템플릿 시스템 (동일) | +| **측정 항목** | 자재별 고정 | 공정별 다름 (스크린/슬랫/절곡/조인트바) | +| **자동 판정** | 있음 (N1~Nn 탭) | 있음 (행별 + 종합) | +| **문서 유형** | 1종 | 5종 (스크린/슬랫/조인트바/절곡/재공품) | + +### 1.4 변경 승인 정책 + +| 분류 | 예시 | 승인 | +|------|------|------| +| ✅ 즉시 가능 | 프론트 컴포넌트 수정, 서버 액션 추가, 타입 정의 | 불필요 | +| ⚠️ 컨펌 필요 | API 엔드포인트 추가, work_order_items.options 구조 변경, 문서 템플릿 등록 | **필수** | +| 🔴 금지 | 테이블 구조 변경, 기존 API 삭제, 수입검사 로직 변경 | 별도 협의 | + +### 1.5 준수 규칙 + +- `docs/quickstart/quick-start.md` - 빠른 시작 가이드 +- `docs/standards/quality-checklist.md` - 품질 체크리스트 +- `react/CLAUDE.md` - 프론트엔드 프로젝트 규칙 +- `docs/plans/document-management-system-plan.md` - 문서관리 시스템 계획서 + +--- + +## 2. 현황 분석 + +### 2.1 현재 동작하는 검사 흐름 (문제점 포함) + +``` +[현재 흐름 - 데이터가 메모리에만 존재] + +1. 작업자 화면 진입 (dev.sam.kr/production/worker-screen) + ↓ +2. 작업지시 선택 → 개소(아이템) 카드 목록 표시 + ↓ +3. 개소 카드의 "중간검사" 단계(pill) 클릭 + ↓ handleInspectionClick (index.tsx:802) +4. InspectionInputModal 오픈 (공정별 입력 항목) + ↓ 작업자가 검사 데이터 입력 후 "검사 완료" +5. handleInspectionComplete (index.tsx:862) + ↓ inspectionDataMap.set(selectedOrder.id, data) + ⚠️ 메모리에만 저장! → 새로고침하면 소실 + ↓ +6. "검사성적서 보기" 버튼 클릭 + ↓ handleInspection (index.tsx:851) +7. InspectionReportModal 오픈 + - workItems + inspectionDataMap props 전달 + - 공정별 InspectionContent 컴포넌트 렌더링 + ↓ +8. DocumentViewer로 문서 형태 표시/인쇄 +``` + +### 2.2 기존 코드에서 활용할 수 있는 패턴 + +| 패턴 | 위치 | 설명 | +|------|------|------| +| `options['result']` 패턴 | WorkOrderItem.php:119-132 | options JSON에 구조화된 데이터 저장/조회 | +| `saveInspectionData` 서버 액션 | WorkOrders/actions.ts:628-668 | POST 구조 이미 존재 (백엔드 미구현) | +| `InspectionDataMap` | InspectionReportModal.tsx:37 | 아이템ID→검사데이터 Map 구조 | +| `InspectionContentRef.getInspectionData()` | InspectionReportModal.tsx:143 | 성적서에서 데이터 추출 인터페이스 | +| `DocumentViewer` preset="inspection" | InspectionReportModal.tsx:216 | 검사 문서 뷰어 프리셋 | + +### 2.3 관련 API 현황 + +| API | 상태 | 비고 | +|-----|------|------| +| `GET /work-orders` | ✅ 존재 | 목록 조회 | +| `GET /work-orders/{id}` | ✅ 존재 | 상세 (items 포함) | +| `PATCH /work-orders/{id}/items/{itemId}/status` | ✅ 존재 | 품목 상태 변경 | +| `POST /work-orders/{id}/inspection` | ❌ 없음 | saveInspectionData가 호출하려는 URL | +| `POST /work-orders/{id}/items/{itemId}/inspection` | ❌ 없음 | Phase 1.2에서 구현 | +| `GET /work-orders/{id}/inspection-data` | ❌ 없음 | Phase 1.3에서 구현 | +| `GET /work-orders/{id}/inspection-report` | ❌ 없음 | Phase 1.4에서 구현 | +| `GET /documents/resolve` | ✅ 존재 | 문서 템플릿 조회 (Phase 1.4에서 활용) | + +--- + +## 3. 대상 범위 + +### Phase 의존 관계 + +``` +Phase 1 (백엔드 API) + ↓ Phase 2가 Phase 1에 의존 (API가 있어야 프론트 연동) +Phase 2 (프론트 저장 연동) + ↓ Phase 3은 Phase 1-2와 독립 (mng에서 양식 등록) +Phase 3 (mng 템플릿 등록) + ↓ Phase 4가 Phase 1+3에 의존 (API + 템플릿 모두 필요) +Phase 4 (프론트 성적서 연동) +``` + +### Phase 1: 백엔드 - 개소별 검사 데이터 저장 API + +| # | 작업 항목 | 상태 | 완료 기준 | 비고 | +|---|----------|:----:|----------|------| +| 1.1 | API 엔드포인트 설계 | ⏳ | 엔드포인트 목록 + 요청/응답 스키마 확정 | ⚠️ 컨펌 필요 | +| 1.2 | 개소별 검사 데이터 저장 API 구현 | ⏳ | POST /work-orders/{id}/items/{itemId}/inspection 동작 | work_order_items.options에 inspection_data 필드 추가 | +| 1.3 | 개소별 검사 데이터 조회 API 구현 | ⏳ | GET /work-orders/{id}/inspection-data 동작 | 전체 개소 검사 데이터 한번에 반환 | +| 1.4 | 성적서 문서 데이터 조회 API 구현 | ⏳ | GET /work-orders/{id}/inspection-report 동작 | 템플릿 + 개소 데이터 결합 응답 | + +### Phase 2: 프론트 - 개소별 검사 데이터 저장 연동 + +| # | 작업 항목 | 상태 | 완료 기준 | 비고 | +|---|----------|:----:|----------|------| +| 2.1 | 서버 액션 추가 (저장/조회) | ⏳ | saveItemInspection, getInspectionData 서버 액션 동작 | | +| 2.2 | InspectionInputModal - 저장 연동 | ⏳ | "검사 완료" 클릭 시 API 호출 + 성공/실패 피드백 | | +| 2.3 | WorkerScreen - 저장된 데이터 로드 | ⏳ | 화면 진입 시 기존 검사 데이터 자동 로드 | inspectionDataMap 초기화 | +| 2.4 | InspectionInputModal - 기존 데이터 표시 | ⏳ | 이미 검사한 개소 재클릭 시 저장된 데이터 표시 | | + +### Phase 3: 문서 템플릿 등록 (mng) + +| # | 작업 항목 | 상태 | 완료 기준 | 비고 | +|---|----------|:----:|----------|------| +| 3.1 | 스크린 중간검사 양식 등록 | ⏳ | mng.sam.kr/document-templates에서 양식 확인 | ⚠️ 컨펌 필요 | +| 3.2 | 슬랫 중간검사 양식 등록 | ⏳ | 위와 동일 | | +| 3.3 | 절곡 중간검사 양식 등록 | ⏳ | 위와 동일 | | +| 3.4 | 조인트바 중간검사 양식 등록 | ⏳ | 위와 동일 | | + +### Phase 4: 프론트 - 성적서 보기 템플릿 연동 + +| # | 작업 항목 | 상태 | 완료 기준 | 비고 | +|---|----------|:----:|----------|------| +| 4.1 | InspectionReportModal - API 연동 | ⏳ | 템플릿 + 검사 데이터 API에서 로드 | | +| 4.2 | InspectionContent 컴포넌트 리팩토링 | ⏳ | Config 기반으로 전환 (기존 하드코딩 제거) | 점진적 전환 | + +--- + +## 4. 아키텍처 설계 + +### 3.1 데이터 흐름 + +``` +[개소별 중간검사] [검사 성적서 보기] + +작업자 화면 작업자 화면 + ↓ "중간검사" 클릭 ↓ "검사성적서보기" 클릭 +InspectionInputModal InspectionReportModal + ↓ 검사 완료 ↓ +POST /work-orders/{id}/ GET /work-orders/{id}/ + items/{itemId}/inspection inspection-report + ↓ ↓ +work_order_items.options { + .inspection_data = { template: { 레이아웃 JSON }, + processingStatus: 'good', items: [ + sewingStatus: 'good', { itemId, itemName, inspectionData }, + length: 1200, { itemId, itemName, inspectionData }, + judgment: 'pass', ... + ... ], + } summary: { total, pass, fail } + } + ↓ + 공정별 InspectionContent 렌더링 + (템플릿 레이아웃 + 개소 데이터 결합) +``` + +### 3.2 work_order_items.options JSON 구조 (확장) + +```json +{ + "floor": "3F", + "code": "SC-001", + "width": 1200, + "height": 800, + "cutting_info": { ... }, + "slat_info": { ... }, + "bending_info": { ... }, + "wip_info": { ... }, + "inspection_data": { + "inspected_at": "2026-02-07T14:30:00Z", + "inspected_by": "user_id", + "inspected_by_name": "홍길동", + "process_type": "screen", + "data": { + "processingStatus": "good", + "sewingStatus": "good", + "assemblyStatus": "good", + "length": 1200, + "width": 800, + "gapStatus": "ok", + "gapPoints": [ + { "left": 5.0, "right": 5.0 } + ] + }, + "judgment": "pass", + "non_conforming_content": "" + } +} +``` + +### 3.3 API 설계 + +**1) 개소별 검사 데이터 저장** + +``` +POST /api/v1/work-orders/{workOrderId}/items/{itemId}/inspection + +Request: +{ + "process_type": "screen", + "inspection_data": { + "processingStatus": "good", + "sewingStatus": "good", + "assemblyStatus": "good", + "length": 1200, + "width": 800, + "gapStatus": "ok", + "gapPoints": [{ "left": 5.0, "right": 5.0 }] + }, + "judgment": "pass", + "non_conforming_content": "" +} + +Response: +{ + "success": true, + "data": { + "item_id": "item-uuid", + "inspection_data": { ... }, + "inspected_at": "2026-02-07T14:30:00Z" + } +} +``` + +**2) 작업지시 전체 검사 데이터 조회** + +``` +GET /api/v1/work-orders/{workOrderId}/inspection-data + +Response: +{ + "success": true, + "data": { + "work_order_id": "wo-uuid", + "process_type": "screen", + "items": [ + { + "item_id": "item-1", + "item_name": "SC-001-3F", + "has_inspection": true, + "inspection_data": { ... }, + "judgment": "pass", + "inspected_at": "2026-02-07T14:30:00Z", + "inspected_by_name": "홍길동" + }, + { + "item_id": "item-2", + "item_name": "SC-002-3F", + "has_inspection": false, + "inspection_data": null, + "judgment": null, + "inspected_at": null, + "inspected_by_name": null + } + ], + "summary": { + "total": 6, + "inspected": 4, + "pass": 3, + "fail": 1, + "pending": 2 + } + } +} +``` + +**3) 검사 성적서 데이터 조회 (문서 형태)** + +``` +GET /api/v1/work-orders/{workOrderId}/inspection-report + +Response: +{ + "success": true, + "data": { + "work_order_id": "wo-uuid", + "process_type": "screen", + "template": { + "id": "template-uuid", + "title": "스크린 중간검사 성적서", + "approval_lines": [...], + "basic_fields": [...], + "sections": [...], + "columns": [...] + }, + "document_data": { + "basic_fields": { + "product_name": "블라인드 A형", + "specification": "1200x800", + "lot_no": "LOT-2026-001", + "inspection_date": "2026-02-07", + "inspector": "홍길동" + }, + "inspection_rows": [ + { + "row_no": 1, + "item_id": "item-1", + "item_name": "SC-001-3F", + "processing_status": "양호", + "sewing_status": "양호", + "assembly_status": "양호", + "length_design": 1200, + "length_measured": 1200, + "width_design": 800, + "width_measured": 800, + "gap_standard": "5±1", + "gap_result": "OK", + "judgment": "적" + } + ], + "summary": { + "total": 6, + "pass": 5, + "fail": 1, + "overall_judgment": "합격", + "non_conforming_content": "item-3: 길이 규격 초과" + } + }, + "inspection_setting": { + "schematic_image": "/img/inspection/screen-schematic.png", + "inspection_standard_image": "/img/inspection/screen-standard.png" + } + } +} +``` + +### 3.4 공정별 검사 항목 매핑 + +**스크린 (screen)** + +| 검사항목 | 입력 타입 | options.inspection_data 키 | 성적서 컬럼 | +|---------|----------|---------------------------|------------| +| 가공상태 결모양 | good/bad | processingStatus | 가공상태 | +| 재봉상태 결모양 | good/bad | sewingStatus | 재봉상태 | +| 조립상태 | good/bad | assemblyStatus | 조립상태 | +| 길이 | number | length | 길이 (도면치수 vs 측정값) | +| 나비 | number | width | 나비 (도면치수 vs 측정값) | +| 간격 | ok/ng | gapStatus | 간격 (기준치 vs OK/NG) | + +**슬랫 (slat)** + +| 검사항목 | 입력 타입 | options.inspection_data 키 | 성적서 컬럼 | +|---------|----------|---------------------------|------------| +| 가공상태 | good/bad | processingStatus | 가공상태 | +| 조립상태 | good/bad | assemblyStatus | 조립상태 | +| ① 높이 | number | height1 | 높이① (16.5±1) | +| ② 높이 | number | height2 | 높이② (14.5±1) | +| 길이 | number | length | 길이 | + +**조인트바 (slat_jointbar)** + +| 검사항목 | 입력 타입 | options.inspection_data 키 | 성적서 컬럼 | +|---------|----------|---------------------------|------------| +| 가공상태 | good/bad | processingStatus | 가공상태 | +| 조립상태 | good/bad | assemblyStatus | 조립상태 | +| ① 높이 | number | height1 | 높이① | +| ② 높이 | number | height2 | 높이② | +| ③ 길이 | number | length | 길이 | +| ④ 간격 | number | gapValue | 간격 | + +**절곡 (bending)** + +| 검사항목 | 입력 타입 | options.inspection_data 키 | 성적서 컬럼 | +|---------|----------|---------------------------|------------| +| 절곡상태 | good/bad | bendingStatus | 절곡상태 | +| 길이 | number | length | 길이 | +| 간격 (5포인트) | number x 10 | gapPoints[].left/right | 간격 좌1~좌5, 우1~우5 | + +**재공품 (bending_wip)** + +| 검사항목 | 입력 타입 | options.inspection_data 키 | 성적서 컬럼 | +|---------|----------|---------------------------|------------| +| 절곡상태 | good/bad | bendingStatus | 절곡상태 | +| 길이 | number | length | 길이 | +| 나비 | number | width | 나비 | +| 간격 | ok/ng | gapStatus | 간격 | + +--- + +## 5. 기술 결정사항 + +### 4.1 확정 결정 + +| # | 결정 사항 | 선택 | 근거 | +|---|----------|------|------| +| 1 | 데이터 저장 위치 | work_order_items.options JSON | 이미 공정별 데이터(cutting_info, slat_info 등) 저장에 사용 중, 추가 테이블 불필요 | +| 2 | 레이아웃 관리 | 문서 템플릿 시스템 (mng) | 문서관리 시스템 80% 완료, 양식 변경 시 코드 수정 없이 가능 | +| 3 | 성적서 렌더링 | 기존 InspectionContent 컴포넌트 유지 + API 데이터 주입 | 이미 동작하는 렌더링 로직 활용, 점진적으로 Config 기반 전환 | +| 4 | 자동 판정 로직 | 프론트엔드에서 계산 (현재 방식 유지) | 공정별 판정 기준이 프론트에 이미 구현됨 | +| 5 | 검사 이력 | inspection_data에 inspected_at, inspected_by 포함 | 별도 이력 테이블 불필요, JSON 내에서 추적 | + +### 4.2 검토 필요 항목 + +| # | 항목 | 선택지 | 현재 판단 | 비고 | +|---|------|--------|----------|------| +| 1 | Phase 3 (템플릿 등록) 시점 | A) Phase 1-2와 병행 / B) Phase 1-2 완료 후 | B | Phase 1-2로 데이터 저장/조회 먼저 안정화 | +| 2 | 검사 기준 이미지 관리 | A) mng에서 등록 / B) API에서 등록 | A | 문서관리 시스템 계획서 Phase 3.4에서 이미 처리 | +| 3 | 기존 하드코딩 컴포넌트 전환 범위 | A) 전체 전환 / B) 점진적 전환 | B | Phase 4에서 점진적 전환, 기존 기능 유지 우선 | + +--- + +## 6. 상세 작업 절차 + +### Phase 1: 백엔드 - 개소별 검사 데이터 저장 API + +#### 1.1 API 엔드포인트 설계 + +**작업 내용:** +1. `api/routes/api/v1/production.php`에 라우트 추가: + - `POST /work-orders/{workOrderId}/items/{itemId}/inspection` + - `GET /work-orders/{workOrderId}/inspection-data` + - `GET /work-orders/{workOrderId}/inspection-report` +2. 요청/응답 스키마 확정 (섹션 3.3 참조) +3. FormRequest 클래스 생성 + +**관련 파일:** +- `api/routes/api/v1/production.php` (라우트 추가) +- `api/app/Http/Requests/Api/V1/` (FormRequest 생성) + +#### 1.2 개소별 검사 데이터 저장 API 구현 + +**작업 내용:** +1. WorkOrderController에 `storeItemInspection` 메서드 추가 +2. WorkOrderService에 `saveItemInspection` 메서드 추가: + - work_order_items 조회 (workOrderId + itemId) + - options JSON에서 기존 데이터 읽기 + - inspection_data 필드 추가/업데이트 + - inspected_at, inspected_by 자동 기록 +3. 유효성 검증: process_type 필수, inspection_data 공정별 스키마 검증 + +**관련 파일:** +- `api/app/Http/Controllers/Api/V1/WorkOrderController.php` +- `api/app/Services/WorkOrderService.php` + +#### 1.3 개소별 검사 데이터 조회 API 구현 + +**작업 내용:** +1. WorkOrderController에 `getInspectionData` 메서드 추가 +2. WorkOrderService에 `getInspectionData` 메서드 추가: + - 해당 작업지시의 모든 work_order_items 조회 + - 각 item의 options.inspection_data 추출 + - 요약 정보 계산 (total, inspected, pass, fail, pending) + +#### 1.4 성적서 문서 데이터 조회 API 구현 + +**작업 내용:** +1. WorkOrderController에 `getInspectionReport` 메서드 추가 +2. WorkOrderService에 `getInspectionReport` 메서드 추가: + - 공정 타입에 맞는 문서 템플릿 조회 (documents/resolve API 활용) + - work_order_items의 inspection_data 수집 + - 템플릿 레이아웃 + 검사 데이터 + 기본 정보 결합 + - inspection_setting (도해/검사기준 이미지) 포함 + +### Phase 2: 프론트 - 개소별 검사 데이터 저장 연동 + +#### 2.1 서버 액션 추가 + +**작업 내용:** +1. `react/src/components/production/WorkerScreen/actions.ts`에 추가: + - `saveItemInspection(workOrderId, itemId, data)` - 개소별 저장 + - `getWorkOrderInspectionData(workOrderId)` - 전체 검사 데이터 조회 + - `getInspectionReport(workOrderId)` - 성적서 데이터 조회 + +**관련 파일:** +- `react/src/components/production/WorkerScreen/actions.ts` + +#### 2.2 InspectionInputModal - 저장 연동 + +**작업 내용:** +1. onComplete 콜백에서 saveItemInspection 서버 액션 호출 +2. 저장 성공/실패 toast 알림 +3. 저장 중 로딩 상태 표시 +4. 에러 시 재시도 가능 + +**관련 파일:** +- `react/src/components/production/WorkerScreen/InspectionInputModal.tsx` +- `react/src/components/production/WorkerScreen/index.tsx` (handleInspectionClick) + +#### 2.3 WorkerScreen - 저장된 데이터 로드 + +**작업 내용:** +1. 화면 진입 시 getWorkOrderInspectionData 호출 +2. 응답 데이터로 inspectionDataMap 초기화 +3. 이미 검사 완료된 개소 시각적 표시 (아이콘/배지) + +**관련 파일:** +- `react/src/components/production/WorkerScreen/index.tsx` + +#### 2.4 InspectionInputModal - 기존 데이터 표시 + +**작업 내용:** +1. inspectionDataMap에 해당 itemId 데이터가 있으면 폼에 자동 채움 +2. 기존 검사 데이터 수정 가능 (재검사) +3. 최초 검사 vs 재검사 구분 표시 + +**관련 파일:** +- `react/src/components/production/WorkerScreen/InspectionInputModal.tsx` + +### Phase 3: 문서 템플릿 등록 (mng) + +#### 3.1~3.4 공정별 양식 등록 + +**작업 내용 (공정별 동일 패턴):** +1. mng.sam.kr/document-templates에서 새 양식 생성 +2. 카테고리: `intermediate-inspection` / 서브카테고리: `screen` (또는 slat/bending/jointbar) +3. 결재라인 설정: 작성자 → 검토 → 승인 +4. 기본필드: 제품명, 규격, 수주처, 현장명, LOT NO, 검사일자, 검사자 +5. 섹션1: 중간검사 기준서 (도해 이미지 + 검사항목 테이블) +6. 섹션2: 중간검사 DATA (동적 행, 공정별 컬럼 정의) +7. 섹션3: 부적합 내용 + 종합 판정 +8. 도해/검사기준 이미지 등록 + +### Phase 4: 프론트 - 성적서 보기 템플릿 연동 + +#### 4.1 InspectionReportModal - API 연동 + +**작업 내용:** +1. getInspectionReport 서버 액션으로 데이터 로드 +2. 템플릿 레이아웃 정보 활용 (결재라인, 기본필드, 섹션 구조) +3. 기존 props 기반 데이터 → API 응답 데이터로 전환 +4. 로딩/에러 상태 처리 + +**관련 파일:** +- `react/src/components/production/WorkOrders/documents/InspectionReportModal.tsx` + +#### 4.2 InspectionContent 컴포넌트 리팩토링 + +**작업 내용:** +1. 기존 하드코딩된 레이아웃을 템플릿 데이터 기반으로 전환 +2. 공통 렌더링 로직 추출 (테이블 생성, 판정 로직, 결재란) +3. 공정별 차이점만 Config로 분리 +4. 기존 기능 100% 유지 (회귀 방지) + +**관련 파일:** +- `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/documents/BendingWipInspectionContent.tsx` +- `react/src/components/production/WorkOrders/documents/SlatJointBarInspectionContent.tsx` + +--- + +## 7. 컨펌 대기 목록 + +| # | 항목 | 변경 내용 | 영향 범위 | 상태 | +|---|------|----------|----------|------| +| 1 | API 엔드포인트 3개 추가 | work-orders/{id}/items/{itemId}/inspection, inspection-data, inspection-report | api | ⏳ Phase 1.1에서 확정 | +| 2 | work_order_items.options 구조 확장 | inspection_data 필드 추가 | api, react | ⏳ Phase 1.2에서 확정 | +| 3 | 중간검사 문서 템플릿 4종 등록 | mng 양식 관리에서 등록 | mng | ⏳ Phase 3에서 확정 | + +--- + +## 8. 변경 이력 + +| 날짜 | 항목 | 변경 내용 | 파일 | 승인 | +|------|------|----------|------|------| +| 2026-02-07 | 초안 | 계획 문서 초안 작성 | - | - | +| 2026-02-07 | 보완 | 자기완결성 보강: Git 정보, 코드 스니펫(타입/모델/라우트/핸들러), 현황 분석(동작 흐름/활용 패턴/API 현황), Phase 의존 관계 추가 | - | - | + +--- + +## 9. 참고 문서 + +- **문서관리 시스템 계획**: `docs/plans/document-management-system-plan.md` (80% 완료) +- **수입검사 양식 계획**: `docs/plans/incoming-inspection-templates-plan.md` +- **수입검사 연동 계획**: `docs/plans/incoming-inspection-document-integration-plan.md` +- **DB 스키마**: `docs/specs/database-schema.md` +- **빠른 시작**: `docs/quickstart/quick-start.md` +- **품질 체크리스트**: `docs/standards/quality-checklist.md` + +### 기존 코드 참조 + +- **수입검사 성적서 (참고 모델)**: `react/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx` +- **문서 뷰어**: `react/src/components/document-system/DocumentViewer.tsx` +- **QMS Config**: `react/src/components/document-system/configs/qms/index.ts` +- **검사 서비스**: `api/app/Services/InspectionService.php` + +--- + +## 10. 검증 결과 + +> 작업 완료 후 이 섹션에 검증 결과 추가 + +### 9.1 테스트 케이스 + +| # | 시나리오 | 입력 | 예상 결과 | 실제 결과 | 상태 | +|---|---------|------|----------|----------|------| +| 1 | 스크린 개소 검사 저장 | 모든 항목 양호 입력 | API 200, options.inspection_data 저장됨 | | ⏳ | +| 2 | 저장된 검사 데이터 로드 | 화면 재진입 | inspectionDataMap에 기존 데이터 표시 | | ⏳ | +| 3 | 이미 검사한 개소 재클릭 | 검사 완료된 item 클릭 | 기존 데이터 폼에 표시 | | ⏳ | +| 4 | 검사성적서보기 | 모든 개소 검사 완료 후 클릭 | 템플릿 레이아웃 + 전체 데이터 표시 | | ⏳ | +| 5 | 일부 개소만 검사 후 성적서 | 6개 중 3개만 검사 | 검사된 3개만 데이터, 3개는 빈 행 | | ⏳ | +| 6 | 종합 판정 자동 계산 | 1개 부적 + 나머지 적합 | 종합: 불합격, 부적합 내용 표시 | | ⏳ | + +### 9.2 성공 기준 + +| 기준 | 달성 | 비고 | +|------|------|------| +| 개소별 검사 데이터가 서버에 저장됨 | ⏳ | 새로고침 후에도 유지 | +| 저장된 데이터가 InspectionInputModal에 자동 로드됨 | ⏳ | | +| 검사성적서보기에서 모든 개소 데이터가 합쳐져 표시됨 | ⏳ | | +| 문서 템플릿 레이아웃 적용됨 | ⏳ | 결재란, 기본정보, 섹션 구조 | +| 기존 하드코딩 성적서와 동일한 출력물 | ⏳ | 회귀 방지 | +| 자동 판정 로직 정상 동작 | ⏳ | 행별 + 종합 | + +--- + +## 11. 자기완결성 점검 결과 + +### 10.1 체크리스트 검증 + +| # | 검증 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1 | 작업 목적이 명확한가? | ✅ | 섹션 1.1 배경에 명시 | +| 2 | 성공 기준이 정의되어 있는가? | ✅ | 섹션 10.2 성공 기준 6개 | +| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1-4, 14개 작업 항목 | +| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 의존 관계 + 문서관리 시스템 80% 완료 전제 | +| 5 | 참고 파일 경로가 정확한가? | ✅ | 절대 경로 포함 핵심 파일 목록 | +| 6 | 단계별 절차가 실행 가능한가? | ✅ | 섹션 6 상세 작업 절차 | +| 7 | 검증 방법이 명시되어 있는가? | ✅ | 섹션 10.1 테스트 케이스 6개 | +| 8 | 모호한 표현이 없는가? | ✅ | API 스키마, 데이터 구조, 검사 항목 매핑, 코드 스니펫 모두 구체적 | +| 9 | 현재 코드 구조가 이해 가능한가? | ✅ | 핵심 타입/인터페이스 + 백엔드 모델/라우트 코드 포함 | +| 10 | 현재 동작 흐름이 파악 가능한가? | ✅ | 섹션 2.1 현재 흐름도 + 문제점 명시 | + +### 11.2 새 세션 시뮬레이션 테스트 + +| 질문 | 답변 가능 | 참조 섹션 | +|------|:--------:|----------| +| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 | +| Q2. 현재 시스템이 어떻게 동작하는가? | ✅ | 2.1 현재 동작 흐름 + 코드 스니펫 | +| Q3. 어디서부터 시작해야 하는가? | ✅ | 📍 현재 진행 상태 + 6. 상세 작업 절차 | +| Q4. 어떤 파일을 수정해야 하는가? | ✅ | 핵심 파일 목록 + Phase별 관련 파일 | +| Q5. 기존 코드의 타입/인터페이스는? | ✅ | 현재 코드 구조 (InspectionData, WorkItemData, WorkOrderItem) | +| Q6. 백엔드에 뭐가 있고 뭐가 없는가? | ✅ | 2.3 API 현황 + 백엔드 라우트/컨트롤러 구조 | +| Q7. 작업 완료 확인 방법은? | ✅ | 10.1 테스트 케이스 + 10.2 성공 기준 | +| Q8. 막혔을 때 참고 문서는? | ✅ | 9. 참고 문서 | + +**결과**: 8/8 통과 → ✅ 자기완결성 확보 + +--- + +## 12. 세션 및 메모리 관리 정책 + +### 11.1 세션 시작 시 + +``` +1. 이 문서의 📍 현재 진행 상태 확인 +2. 해당 Phase 상세 절차 읽기 +3. 관련 파일 읽기 +4. "Phase X.X 시작할까요?" 확인 +``` + +### 11.2 작업 중 + +``` +- 변경 이력 섹션에 실시간 기록 +- Phase/항목별 상태 업데이트 (⏳ → 🔄 → ✅) +- 컨펌 필요사항 → 컨펌 대기 목록에 추가 +``` + +### 11.3 세션 종료 시 + +``` +- 📍 현재 진행 상태 업데이트 +- 변경 이력에 최종 업데이트 기록 +``` + +--- + +*이 문서는 /sc:plan 스킬로 생성되었습니다. (2026-02-07)* \ No newline at end of file diff --git a/plans/mng-numbering-rule-management-plan.md b/plans/mng-numbering-rule-management-plan.md new file mode 100644 index 0000000..fe2950e --- /dev/null +++ b/plans/mng-numbering-rule-management-plan.md @@ -0,0 +1,407 @@ +# MNG 채번 규칙 관리 UI 계획 + +> **작성일**: 2026-02-07 +> **목적**: MNG 관리자 패널에서 테넌트별 채번 규칙(견적번호, 수주로트번호 등)을 CRUD 관리하는 UI 구현 +> **기준 문서**: `docs/plans/tenant-numbering-system-plan.md` (API 채번 시스템) +> **상태**: 대기 + +--- + +## 1. 개요 + +### 1.1 배경 +- API에 채번 규칙 시스템(`numbering_rules`, `numbering_sequences` 테이블)이 이미 구현됨 +- 현재는 Seeder로만 규칙 등록 가능 → MNG에서 관리 UI가 필요 +- 테넌트별로 견적, 수주, 원자재수입검사 등 문서유형별 채번 패턴을 설정/수정/삭제할 수 있어야 함 + +### 1.2 기준 원칙 +``` +- MNG 독립 모델 사용 (API 테이블 참조, 마이그레이션 생성 금지) +- MNG 기존 패턴 준수: Controller(Blade) + Api Controller(HTMX/JSON) + Service + FormRequest +- HTMX + Alpine.js로 SPA 유사 UX 제공 +- JSON 패턴 편집을 위한 동적 폼 (세그먼트 추가/삭제/정렬) +``` + +### 1.3 변경 승인 정책 + +| 분류 | 예시 | 승인 | +|------|------|------| +| 즉시 가능 | MNG 모델/서비스/컨트롤러/뷰 생성 | 불필요 | +| 컨펌 필요 | routes/web.php 수정, 사이드바 메뉴 추가 | **필수** | +| 금지 | mng/database/migrations/ 파일 생성, API 테이블 구조 변경 | 별도 협의 | + +--- + +## 2. 기술 스택 & 패턴 + +### 2.1 MNG 프로젝트 스택 +| 항목 | 기술 | +|------|------| +| Backend | Laravel 12, PHP 8.4+ | +| Template | Blade (Plain Laravel, React/Vue 없음) | +| CSS | Tailwind CSS | +| 비동기 | HTMX 1.9 (페이지 새로고침 없이 테이블/폼 업데이트) | +| JS 프레임워크 | Alpine.js (동적 폼, 탭, 모달) | +| 인증 | Session 기반 (middleware: auth, tenant) | +| Multi-tenant | `session('selected_tenant_id')` 기반 | + +### 2.2 참고 패턴 (부서관리 CRUD) +``` +mng/app/Http/Controllers/DepartmentController.php ← Blade 렌더링만 +mng/app/Http/Controllers/Api/Admin/DepartmentController.php ← CRUD 로직 (HTMX/JSON) +mng/app/Services/DepartmentService.php ← 비즈니스 로직 +mng/app/Http/Requests/StoreDepartmentRequest.php ← 검증 +mng/resources/views/departments/index.blade.php ← 목록 (HTMX 테이블) +mng/resources/views/departments/create.blade.php ← 생성 폼 +mng/resources/views/departments/edit.blade.php ← 수정 폼 +mng/resources/views/departments/partials/table.blade.php ← HTMX 파셜 +``` + +--- + +## 3. 대상 범위 + +### 3.1 Phase 1: 백엔드 (Model + Service + Controller + FormRequest + Route) + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1.1 | NumberingRule 모델 생성 | ⏳ | API 테이블 참조, BelongsToTenant | +| 1.2 | NumberingRuleService 생성 | ⏳ | CRUD + 미리보기 | +| 1.3 | NumberingRuleController (페이지) 생성 | ⏳ | Blade 렌더링 | +| 1.4 | Api/Admin/NumberingRuleController 생성 | ⏳ | HTMX/JSON CRUD | +| 1.5 | FormRequest 생성 | ⏳ | JSON 패턴 검증 | +| 1.6 | routes/web.php 라우트 추가 | ⏳ | ⚠️ 컨펌 필요 | + +### 3.2 Phase 2: 프론트엔드 (Blade Views) + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 2.1 | index.blade.php (목록) | ⏳ | HTMX 테이블, 필터 | +| 2.2 | partials/table.blade.php | ⏳ | HTMX 파셜 | +| 2.3 | create.blade.php (생성) | ⏳ | Alpine.js 동적 세그먼트 폼 | +| 2.4 | edit.blade.php (수정) | ⏳ | 기존 패턴 로드 + 편집 | +| 2.5 | partials/segment-form.blade.php | ⏳ | 세그먼트 편집 컴포넌트 | +| 2.6 | partials/preview.blade.php | ⏳ | 실시간 미리보기 | + +### 3.3 Phase 3: 통합 & 검증 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 3.1 | 사이드바 메뉴 추가 | ⏳ | ⚠️ 컨펌 필요 | +| 3.2 | 기능 테스트 | ⏳ | CRUD + 미리보기 | +| 3.3 | 기존 시더 데이터 확인 | ⏳ | tenant_id=287 규칙 편집 가능 확인 | + +--- + +## 4. 상세 설계 + +### 4.1 파일 구조 (생성할 파일 목록) + +``` +mng/ +├── app/ +│ ├── Models/ +│ │ └── NumberingRule.php ← NEW +│ ├── Services/ +│ │ └── NumberingRuleService.php ← NEW +│ ├── Http/ +│ │ ├── Controllers/ +│ │ │ ├── NumberingRuleController.php ← NEW (Blade) +│ │ │ └── Api/Admin/ +│ │ │ └── NumberingRuleController.php ← NEW (HTMX/JSON) +│ │ └── Requests/ +│ │ ├── StoreNumberingRuleRequest.php ← NEW +│ │ └── UpdateNumberingRuleRequest.php ← NEW +├── resources/views/ +│ └── numbering/ +│ ├── index.blade.php ← NEW +│ ├── create.blade.php ← NEW +│ ├── edit.blade.php ← NEW +│ └── partials/ +│ ├── table.blade.php ← NEW +│ ├── segment-form.blade.php ← NEW +│ └── preview.blade.php ← NEW +└── routes/ + └── web.php ← MODIFY (라우트 추가) +``` + +### 4.2 DB 스키마 (이미 존재, 참조용) + +```sql +-- numbering_rules (API에서 생성 완료) +id, tenant_id, document_type(50), rule_name(100), +pattern(JSON), reset_period(20), sequence_padding(INT), +is_active(BOOL), created_by, updated_by, timestamps +UNIQUE(tenant_id, document_type) + +-- numbering_sequences (API에서 생성 완료, 조회 전용) +id, tenant_id, document_type(50), scope_key(100), +period_key(20), last_sequence(INT), timestamps +UNIQUE(tenant_id, document_type, scope_key, period_key) +``` + +### 4.3 JSON 패턴 세그먼트 타입 + +| 타입 | 필드 | 예시 | 설명 | +|------|------|------|------| +| `static` | `value` | `{"type":"static","value":"KD"}` | 고정 문자열 | +| `separator` | `value` | `{"type":"separator","value":"-"}` | 구분자 | +| `date` | `format` | `{"type":"date","format":"ymd"}` | PHP date format | +| `param` | `key`, `default` | `{"type":"param","key":"pair_code","default":"SS"}` | 외부 파라미터 | +| `mapping` | `key`, `map`, `default` | `{"type":"mapping","key":"product_category","map":{"screen":"SC","steel":"ST"},"default":"SC"}` | 값 매핑 | +| `sequence` | (없음) | `{"type":"sequence"}` | 자동 순번 | + +### 4.4 UI 설계 + +#### 목록 페이지 (`index.blade.php`) +``` +┌──────────────────────────────────────────────────────────┐ +│ 채번 규칙 관리 [+ 새 규칙] │ +├──────────────────────────────────────────────────────────┤ +│ [문서유형 ▼] [상태 ▼] [검색...] [검색 버튼] │ +├──────────────────────────────────────────────────────────┤ +│ # │ 규칙명 │ 문서유형 │ 패턴 미리보기 │ 상태 │ 작업 │ +│ 1 │ 5130 견적번호 │ quote │ KD-PR-260207-01 │ 활성 │ 수정/삭제│ +│ 2 │ 5130 수주 로트 │ order │ KD-SS-260207-01 │ 활성 │ 수정/삭제│ +└──────────────────────────────────────────────────────────┘ +``` + +#### 생성/수정 페이지 (`create.blade.php` / `edit.blade.php`) +``` +┌──────────────────────────────────────────────────────────┐ +│ 채번 규칙 생성 ← 목록으로 │ +├──────────────────────────────────────────────────────────┤ +│ ┌─ 기본 정보 ──────────────────────────────────────────┐ │ +│ │ 규칙명: [________] 문서유형: [quote ▼] │ │ +│ │ 리셋주기: [daily ▼] 시퀀스 자릿수: [2] │ │ +│ │ 활성: [✓] │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 패턴 세그먼트 ─────────────────────────────────────┐ │ +│ │ ① [static ▼] value: [KD] [✕] [↕] │ │ +│ │ ② [separator ▼] value: [-] [✕] [↕] │ │ +│ │ ③ [static ▼] value: [PR] [✕] [↕] │ │ +│ │ ④ [separator ▼] value: [-] [✕] [↕] │ │ +│ │ ⑤ [date ▼] format: [ymd] [✕] [↕] │ │ +│ │ ⑥ [separator ▼] value: [-] [✕] [↕] │ │ +│ │ ⑦ [sequence ▼] [✕] [↕] │ │ +│ │ [+ 세그먼트 추가] │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 미리보기 ──────────────────────────────────────────┐ │ +│ │ 생성 예시: KD-PR-260207-01 │ │ +│ │ KD-PR-260207-02 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ [취소] [저장] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4.5 핵심 구현 코드 (Blueprint) + +#### Model (`mng/app/Models/NumberingRule.php`) +```php + 'array', + 'is_active' => 'boolean', + 'sequence_padding' => 'integer', + ]; + + // 문서유형 상수 + const DOC_QUOTE = 'quote'; + const DOC_ORDER = 'order'; + const DOC_SALE = 'sale'; + const DOC_WORK_ORDER = 'work_order'; + const DOC_MATERIAL_RECEIPT = 'material_receipt'; + + public static function documentTypes(): array + { + return [ + self::DOC_QUOTE => '견적', + self::DOC_ORDER => '수주', + self::DOC_SALE => '매출', + self::DOC_WORK_ORDER => '작업지시', + self::DOC_MATERIAL_RECEIPT => '원자재수입검사', + ]; + } + + public static function resetPeriods(): array + { + return [ + 'daily' => '일별', + 'monthly' => '월별', + 'yearly' => '연별', + 'never' => '리셋안함', + ]; + } + + /** + * 패턴 미리보기 문자열 생성 (실제 시퀀스 없이) + */ + public function getPreviewAttribute(): string + { + $result = ''; + foreach ($this->pattern as $segment) { + $result .= match ($segment['type']) { + 'static' => $segment['value'], + 'separator' => $segment['value'], + 'date' => now()->format($segment['format']), + 'param' => $segment['default'] ?? '{' . $segment['key'] . '}', + 'mapping' => $segment['default'] ?? '{' . $segment['key'] . '}', + 'sequence' => str_pad('1', $this->sequence_padding, '0', STR_PAD_LEFT), + default => '', + }; + } + return $result; + } +} +``` + +#### Service (`mng/app/Services/NumberingRuleService.php`) +```php +where('tenant_id', $tenantId); + } + if (!empty($filters['document_type'])) { + $query->where('document_type', $filters['document_type']); + } + if (isset($filters['is_active']) && $filters['is_active'] !== '') { + $query->where('is_active', (bool) $filters['is_active']); + } + if (!empty($filters['search'])) { + $query->where('rule_name', 'like', "%{$filters['search']}%"); + } + + return $query->orderBy('document_type')->paginate($perPage); + } + + public function getRule(int $id): ?NumberingRule { ... } + public function createRule(array $data): NumberingRule { ... } + public function updateRule(int $id, array $data): bool { ... } + public function deleteRule(int $id): bool { ... } +} +``` + +#### 세그먼트 동적 폼 (Alpine.js) +```javascript +// create.blade.php 내 Alpine.js 컴포넌트 +Alpine.data('patternEditor', () => ({ + segments: [], + segmentTypes: [ + { value: 'static', label: '고정 문자열' }, + { value: 'separator', label: '구분자' }, + { value: 'date', label: '날짜' }, + { value: 'param', label: '외부 파라미터' }, + { value: 'mapping', label: '값 매핑' }, + { value: 'sequence', label: '자동 순번' }, + ], + dateFormats: [ + { value: 'ymd', label: 'YYMMDD (260207)' }, + { value: 'Ymd', label: 'YYYYMMDD (20260207)' }, + { value: 'Ym', label: 'YYYYMM (202602)' }, + { value: 'Y', label: 'YYYY (2026)' }, + ], + + addSegment() { + this.segments.push({ type: 'static', value: '' }); + }, + removeSegment(index) { + this.segments.splice(index, 1); + }, + moveSegment(from, to) { ... }, + + get preview() { + return this.segments.map(seg => { + switch(seg.type) { + case 'static': return seg.value || '?'; + case 'separator': return seg.value || '-'; + case 'date': return formatDate(seg.format || 'ymd'); + case 'param': return seg.default || `{${seg.key || '?'}}`; + case 'mapping': return seg.default || `{${seg.key || '?'}}`; + case 'sequence': return '01'; + default: return ''; + } + }).join(''); + } +})); +``` + +--- + +## 5. 구현 순서 & 예상 작업량 + +| Phase | 작업 | 파일 수 | 예상 | +|-------|------|--------|------| +| 1 | 백엔드 (Model, Service, Controller, FormRequest, Route) | 6개 생성 + 1개 수정 | 중 | +| 2 | 프론트엔드 (Blade Views 6개) | 6개 생성 | 대 (Alpine.js 동적 폼) | +| 3 | 통합 & 검증 (메뉴, 테스트) | 1개 수정 | 소 | + +**핵심 난이도**: Phase 2의 세그먼트 동적 폼 (Alpine.js로 JSON 배열 편집 + 실시간 미리보기) + +--- + +## 6. 검증 결과 + +### 6.1 테스트 시나리오 + +| 입력 | 예상 결과 | 상태 | +|------|----------|:----:| +| 목록 진입 | tenant_id=287 규칙 2건 표시 | ⏳ | +| 견적 규칙 수정 → 저장 | pattern JSON 업데이트, 미리보기 변경 | ⏳ | +| 새 규칙 생성 (material_receipt) | 규칙 3건으로 증가 | ⏳ | +| 세그먼트 추가/삭제/순서변경 | Alpine.js 동적 폼 동작 | ⏳ | +| 미리보기 버튼 | 실시간 번호 예시 표시 | ⏳ | +| 규칙 삭제 | soft delete 또는 hard delete | ⏳ | +| 중복 document_type 생성 시도 | 유니크 제약 에러 표시 | ⏳ | + +### 6.2 성공 기준 + +| 기준 | 달성 | 비고 | +|------|------|------| +| 규칙 CRUD 정상 동작 | ⏳ | 생성/조회/수정/삭제 | +| 세그먼트 동적 편집 | ⏳ | 추가/삭제/순서변경 | +| 실시간 미리보기 | ⏳ | 패턴 변경 시 즉시 반영 | +| 기존 API 채번 로직과 호환 | ⏳ | MNG에서 수정한 규칙이 API에서 정상 작동 | +| MNG 기존 패턴 준수 | ⏳ | HTMX + Alpine.js + Tailwind | + +--- + +## 7. 참고 문서 + +- **채번 시스템 설계**: `docs/plans/tenant-numbering-system-plan.md` +- **MNG CRUD 패턴**: `mng/app/Http/Controllers/DepartmentController.php` + `Api/Admin/DepartmentController.php` +- **Alpine.js 동적 폼 참고**: `mng/resources/views/quote-formulas/edit.blade.php` (탭 + 동적 아이템) +- **HTMX 테이블 참고**: `mng/resources/views/departments/partials/table.blade.php` + +--- + +*이 문서는 /sc:plan 스킬로 생성되었습니다.* \ No newline at end of file diff --git a/plans/quote-order-sync-improvement-plan.md b/plans/quote-order-sync-improvement-plan.md new file mode 100644 index 0000000..a85c247 --- /dev/null +++ b/plans/quote-order-sync-improvement-plan.md @@ -0,0 +1,172 @@ +# 견적 수정 → 기존 수주 업데이트 연동 개선 계획 + +> **작성일**: 2026-02-07 +> **목적**: 견적 수정 시 연결된 기존 수주를 자동 업데이트하고, "수주등록" 대신 "수주 보기" 버튼 표시 +> **상태**: 🔄 진행중 + +--- + +## 📍 현재 진행 상태 + +| 항목 | 내용 | +|------|------| +| **마지막 완료 작업** | 분석 완료 | +| **다음 작업** | Phase 1.1 - API 수정 | +| **진행률** | 0/4 (0%) | +| **마지막 업데이트** | 2026-02-07 | + +--- + +## 1. 개요 + +### 1.1 배경 + +사용자가 수주(33) 상세 → 견적(41) 수정 → 저장 후 "수주등록" 버튼이 표시되어 클릭 시 새 수주가 생성됨. + +**기대 동작**: 견적 수정 → 기존 수주(33)에 자동 반영 + "수주 보기" 버튼으로 기존 수주 이동 + +### 1.2 근본 원인 + +- `Quote.order_id`가 null인 상태에서 `Order.quote_id`만 설정된 경우 발생 +- `QuoteService::update()`는 `quote->order_id` 기준으로만 동기화 트리거 +- `QuoteService::show()`는 역방향 참조(`Order.quote_id`)를 고려하지 않음 +- 결과: 프론트에서 `orderId = null` → "수주등록" 버튼 표시 + +### 1.3 기존 구현 현황 (이미 구현된 부분) + +| 기능 | 상태 | 위치 | +|------|------|------| +| `OrderService::syncFromQuote()` | ✅ 완전 구현 | `api/app/Services/OrderService.php:561-746` | +| `QuoteService::update()` → syncFromQuote 호출 | ✅ 구현 (order_id 기준) | `api/app/Services/Quote/QuoteService.php:448` | +| QuoteFooterBar "수주 보기" / "수주등록" 분기 | ✅ 구현 (orderId 기준) | `react/src/components/quotes/QuoteFooterBar.tsx:209` | +| `transformApiToV2`: order_id → orderId 매핑 | ✅ 구현 | `react/src/components/quotes/types.ts:275,1008` | + +### 1.4 변경 승인 정책 + +| 분류 | 예시 | 승인 | +|------|------|------| +| ✅ 즉시 가능 | 기존 메서드에 조건 추가 | 불필요 | +| ⚠️ 컨펌 필요 | API 로직 변경, 데이터 보정 | **필수** | + +--- + +## 2. 대상 범위 + +### 2.1 Phase 1: API 수정 (백엔드) + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1.1 | QuoteService::show() - 역방향 order 참조 보정 | ⏳ | order_id null이면 orders() 관계로 탐색 | +| 1.2 | QuoteService::update() - 역방향 sync 트리거 | ⏳ | order_id null이어도 orders() 있으면 동기화 | + +### 2.2 Phase 2: 프론트엔드 확인 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 2.1 | 견적 수정 후 view 모드 전환 시 데이터 갱신 확인 | ⏳ | orderId가 정상 반영되는지 | +| 2.2 | "수주 보기" 버튼 동작 확인 | ⏳ | 기존 수주로 정상 이동하는지 | + +--- + +## 3. 작업 절차 + +### 3.1 상세 변경 사항 + +#### 1.1 QuoteService::show() 수정 + +**파일**: `api/app/Services/Quote/QuoteService.php` (line 168-187) + +**현재 동작**: Quote 모델을 그대로 반환 (order_id가 null이면 null 그대로) + +**변경**: quote.order_id가 null인데 Order.quote_id로 연결된 수주가 있으면 order_id를 보정 + +```php +// show() 메서드 내, return $quote; 직전에 추가 +if (!$quote->order_id) { + $linkedOrder = \App\Models\Orders\Order::where('quote_id', $quote->id) + ->where('tenant_id', $tenantId) + ->first(); + if ($linkedOrder) { + // DB에도 반영 (데이터 정합성 복구) + $quote->update(['order_id' => $linkedOrder->id]); + $quote->refresh(); + } +} +``` + +#### 1.2 QuoteService::update() 동기화 트리거 확장 + +**파일**: `api/app/Services/Quote/QuoteService.php` (line 447-460) + +**현재 동작**: `if ($quote->order_id)` 일 때만 syncFromQuote 호출 + +**변경**: order_id가 null이어도 orders() 관계로 연결된 수주가 있으면 동기화 실행 + +```php +// 기존 코드 (line 448) +if ($quote->order_id) { + +// 변경 후 +$orderId = $quote->order_id; +if (!$orderId) { + // 역방향 참조로 연결된 수주 찾기 + $linkedOrder = \App\Models\Orders\Order::where('quote_id', $quote->id) + ->where('tenant_id', $tenantId) + ->first(); + if ($linkedOrder) { + $quote->update(['order_id' => $linkedOrder->id]); + $orderId = $linkedOrder->id; + } +} +if ($orderId) { +``` + +### 3.2 영향 범위 + +| 영향 받는 부분 | 변경 여부 | 설명 | +|---------------|----------|------| +| QuoteService::show() | ✅ 수정 | 역방향 참조 보정 | +| QuoteService::update() | ✅ 수정 | sync 트리거 확장 | +| OrderService::syncFromQuote() | ❌ 변경 없음 | 이미 완전 구현 | +| QuoteFooterBar.tsx | ❌ 변경 없음 | orderId 기준 분기 이미 구현 | +| QuoteRegistrationV2.tsx | ❌ 변경 없음 | orderId 전달 이미 구현 | +| types.ts (transformApiToV2) | ❌ 변경 없음 | order_id → orderId 매핑 이미 구현 | + +--- + +## 4. 검증 방법 + +### 4.1 테스트 시나리오 + +| # | 시나리오 | 예상 결과 | +|---|---------|----------| +| 1 | 수주(33) 상세 → 견적(41) 수정 → 저장 | 기존 수주(33) 품목 자동 업데이트 | +| 2 | 견적(41) 상세 view 모드 진입 | "수주 보기" 버튼 표시 (수주등록 아님) | +| 3 | "수주 보기" 버튼 클릭 | 수주(33) 상세 페이지로 이동 | +| 4 | 견적 수정 후 금액 변경 | 수주 총금액도 동기화 | + +### 4.2 성공 기준 + +- 견적 수정 시 연결된 수주가 자동 업데이트됨 +- "수주 보기" 버튼이 정상 표시됨 +- 기존 수주로 정상 네비게이션됨 +- 기존 convertToOrder() 플로우에 영향 없음 + +--- + +## 5. 참고 파일 + +| 파일 | 역할 | +|------|------| +| `api/app/Services/Quote/QuoteService.php` | 견적 서비스 (show, update, convertToOrder) | +| `api/app/Services/OrderService.php` | 수주 서비스 (syncFromQuote) | +| `api/app/Models/Quote/Quote.php` | 견적 모델 (orders() 관계) | +| `api/app/Models/Orders/Order.php` | 수주 모델 (quote() 관계) | +| `react/src/components/quotes/QuoteFooterBar.tsx` | 견적 푸터 바 (버튼 분기) | +| `react/src/components/quotes/QuoteRegistrationV2.tsx` | 견적 등록/수정 V2 | +| `react/src/components/quotes/types.ts` | 타입 및 API→V2 변환 | +| `react/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx` | 견적 상세 페이지 | + +--- + +*이 문서는 /sc:plan 스킬로 생성되었습니다.* \ No newline at end of file diff --git a/plans/tenant-numbering-system-plan.md b/plans/tenant-numbering-system-plan.md new file mode 100644 index 0000000..69c7d54 --- /dev/null +++ b/plans/tenant-numbering-system-plan.md @@ -0,0 +1,838 @@ +# 테넌트별 채번 규칙 시스템 계획 + +> **작성일**: 2026-02-07 +> **목적**: 테넌트별 문서번호(견적번호, 수주로트번호 등) 채번 규칙을 DB에 저장하고, 규칙 기반으로 자동 채번하는 시스템 구축 +> **상태**: 🔄 진행중 +> **대상 테넌트**: tenant_id = 287 + +--- + +## 📍 현재 진행 상태 + +| 항목 | 내용 | +|------|------| +| **마지막 완료 작업** | 분석 & 계획 문서 작성 | +| **다음 작업** | Phase 1: DB 마이그레이션 (1.1 numbering_rules 테이블 생성) | +| **진행률** | 0/8 (0%) | +| **마지막 업데이트** | 2026-02-07 | + +--- + +## 1. 개요 + +### 1.1 배경 + +5130 레거시 시스템에서 SAM으로 전환 중이며, 기존 번호 체계를 SAM에 적용해야 함. +향후 채번관리 UI를 만들 예정이나, 현재는 DB에 규칙을 저장하고 서비스에서 읽어 사용하는 구조. + +**변경 전후 비교 (tenant_id=287):** + +| 문서 유형 | 현재 SAM 패턴 | 변경 후 패턴 (5130 방식) | +|----------|-------------|----------------------| +| 견적번호 | `KD-SC-260207-01` | `KD-PR-260207-01` | +| 수주번호 | `ORD202602070001` | `KD-SS-260207-01` (모델별 두문자) | + +**다른 테넌트**: 기존 로직 그대로 유지 (하위호환) + +### 1.2 5130 레거시 채번 패턴 (원본 분석) + +#### 견적번호 - `5130/estimate/get_initial_pjnum.php` +```php +// 핵심 로직 (전체 24줄) +$today = date('ymd'); // "260207" +$prefix = "KD-PR-$today"; // "KD-PR-260207" +// → DB에서 해당 prefix로 시작하는 기존 견적 개수 확인 +// → 항상 "KD-PR-{YYMMDD}" 반환 (순번은 별도 관리) +// → 복사 시 마지막 "-NN" 제거: substr($pjnum, 0, -3) +``` +결과: `KD-PR-260207`, 복사 시 `KD-PR-260207-01` → `KD-PR-260207` (순번 제거 후 재생성) + +#### 수주 로트번호 - `5130/output/lotnum_generator.php` +```php +// 핵심 로직 (전체 72줄) +$currentDate = date('ymd'); // "260207" +$filepath = $_SERVER['DOCUMENT_ROOT'] . '/output/lotnum.txt'; + +// lotnum.txt에서 이전 번호 읽기 +$lastLotNumber = file_get_contents($filepath); // "260207-01" +list($date, $number) = explode('-', $lastLotNumber); + +if ($date === $currentDate) { + $newNumber = str_pad((int)$number + 1, 2, '0', STR_PAD_LEFT); + $lot_number = $currentDate . '-' . $newNumber; // "260207-02" +} else { + $lot_number = $currentDate . '-01'; // 날짜 바뀌면 01 리셋 +} + +file_put_contents($filepath, $lot_number); + +// lot_sales 테이블에 INSERT 후 JSON 반환 +echo json_encode(['lotNum' => 'KD-' . $pairCode . '-' . $lot_number]); +// → "KD-SS-260207-01" +``` +- `$pairCode`: 프론트에서 POST로 전달 (모델 선택 시 해당 모델의 "두문자") + +#### 모델별 두문자 (pair_code) - `5130/models/models.json` +```json +[ + {"model_name": "KSS01", "slatitem": "스크린", "pair": "SS"}, + {"model_name": "KSS02", "slatitem": "스크린", "pair": "SA"}, + {"model_name": "KSE01", "slatitem": "스크린", "pair": "SE"}, + {"model_name": "KWE01", "slatitem": "스크린", "pair": "WE"}, + {"model_name": "KQTS01", "slatitem": "철재", "pair": "TS"}, + {"model_name": "KTE01", "slatitem": "철재", "pair": "TE"}, + {"model_name": "스크린비인정", "slatitem": "스크린", "pair": "비인정"}, + {"model_name": "철재비인정", "slatitem": "철재", "pair": "비인정"}, + {"model_name": "KDSS01", "slatitem": "스크린", "pair": "DS"} +] +``` +**결정사항**: 현재 9개 고정 목록으로 운영. 향후 채번관리 UI에서 추가/수정 가능하게 확장. + +### 1.3 현재 SAM 채번 로직 (수정 대상 코드) + +#### QuoteNumberService.php (전체 코드) +**파일**: `api/app/Services/Quote/QuoteNumberService.php` +```php +tenantId(); + $prefix = match ($productCategory) { + Quote::CATEGORY_SCREEN => 'SC', + Quote::CATEGORY_STEEL => 'ST', + default => 'SC', + }; + $dateStr = now()->format('ymd'); + $pattern = "KD-{$prefix}-{$dateStr}-%"; + + $lastQuote = Quote::withTrashed() + ->where('tenant_id', $tenantId) + ->where('quote_number', 'like', $pattern) + ->orderBy('quote_number', 'desc') + ->first(); + + $sequence = 1; + if ($lastQuote) { + $parts = explode('-', $lastQuote->quote_number); + if (count($parts) >= 4) { + $lastSeq = (int) end($parts); + $sequence = $lastSeq + 1; + } + } + $seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + return "KD-{$prefix}-{$dateStr}-{$seqStr}"; + } + + public function preview(?string $productCategory = null): array { /* ... */ } + public function validate(string $quoteNumber): bool + { + return (bool) preg_match('/^KD-[A-Z]{2}-\d{6}-\d{2,}$/', $quoteNumber); + } + public function parse(string $quoteNumber): ?array { /* ... */ } + public function isUnique(string $quoteNumber, ?int $excludeId = null): bool { /* ... */ } +} +``` + +#### OrderService::generateOrderNo() (수정 대상 메서드) +**파일**: `api/app/Services/OrderService.php` (Line 410-429) +```php +private function generateOrderNo(int $tenantId): string +{ + $prefix = 'ORD'; + $date = now()->format('Ymd'); + + $lastNo = Order::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('order_no', 'like', "{$prefix}{$date}%") + ->orderByDesc('order_no') + ->value('order_no'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s%s%04d', $prefix, $date, $seq); +} +``` + +#### OrderService::store() 호출부 (Line 141-148) +```php +public function store(array $data) +{ + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $data['order_no'] = $this->generateOrderNo($tenantId); + // ... + }); +} +``` + +#### OrderService::createFromQuote() 호출부 (Line 457-459) +```php +return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { + $orderNo = $this->generateOrderNo($tenantId); + $order = Order::createFromQuote($quote, $orderNo); + // ... +}); +``` + +#### StoreOrderRequest.php (현재 전체 rules) +**파일**: `api/app/Http/Requests/Order/StoreOrderRequest.php` +```php +public function rules(): array +{ + return [ + 'quote_id' => 'nullable|integer|exists:quotes,id', + 'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])], + 'status_code' => ['nullable', Rule::in([Order::STATUS_DRAFT, Order::STATUS_CONFIRMED])], + 'category_code' => 'nullable|string|max:50', + 'client_id' => 'nullable|integer|exists:clients,id', + 'client_name' => 'nullable|string|max:200', + // ... (금액, 배송, 옵션, 품목 등) + // ❌ pair_code 없음 → 추가 필요 + ]; +} +``` + +#### Service 베이스 클래스 +**파일**: `api/app/Services/Service.php` +```php +abstract class Service +{ + protected function tenantId(): int { /* app('tenant_id') */ } + protected function apiUserId(): int { /* app('api_user') */ } + public function setContext(int $tenantId, int $userId): self + { + app()->instance('tenant_id', $tenantId); + app()->instance('api_user', $userId); + return $this; + } +} +``` + +### 1.4 핵심 원칙 + +1. 규칙 없는 테넌트는 기존 하드코딩 로직 그대로 사용 (하위호환) +2. `numbering_rules` 테이블에 규칙이 있으면 규칙 기반 채번 +3. 시퀀스는 DB 기반으로 관리 (파일 기반 X, LIKE 검색 X) +4. 동시성 안전 (MySQL UPSERT로 Atomic Update) +5. 향후 채번관리 UI 확장 고려 (JSON 기반 유연한 패턴 정의) + +### 1.5 변경 승인 정책 + +| 분류 | 예시 | 승인 | +|------|------|------| +| ✅ 즉시 가능 | 서비스 내부 리팩토링 | 불필요 | +| ⚠️ 컨펌 필요 | 새 테이블 생성, 기존 서비스 로직 변경, FormRequest 변경 | **필수** | +| 🔴 금지 | 기존 채번된 번호 변경, orders/quotes 테이블 스키마 변경 | 별도 협의 | + +--- + +## 2. 대상 범위 + +### 2.1 Phase 1: DB 설계 & 마이그레이션 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 1.1 | `numbering_rules` 테이블 생성 마이그레이션 | ⏳ | 채번 규칙 저장 | +| 1.2 | `numbering_sequences` 테이블 생성 마이그레이션 | ⏳ | 시퀀스 추적 (atomic) | +| 1.3 | `NumberingRuleSeeder` - tenant_id=287 시드 데이터 | ⏳ | 견적/수주 규칙 2건 | + +### 2.2 Phase 2: 핵심 서비스 구현 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 2.1 | `NumberingRule` 모델 | ⏳ | BelongsToTenant | +| 2.2 | `NumberingSequence` 모델 | ⏳ | | +| 2.3 | `NumberingService` 통합 서비스 | ⏳ | 규칙 해석 + 번호 생성 | + +### 2.3 Phase 3: 견적번호 적용 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 3.1 | `QuoteNumberService` 수정 | ⏳ | NumberingService 우선, 없으면 기존 로직 | + +### 2.4 Phase 4: 수주 로트번호 적용 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 4.1 | `OrderService::generateOrderNo()` 수정 | ⏳ | NumberingService 연동 | +| 4.2 | `StoreOrderRequest`에 `pair_code` 옵셔널 추가 | ⏳ | | + +--- + +## 3. 상세 설계 + +### 3.1 테이블 설계 + +#### numbering_rules (채번 규칙) + +```sql +CREATE TABLE numbering_rules ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + document_type VARCHAR(50) NOT NULL COMMENT '문서유형: quote, order, sale, work_order, material_receipt', + rule_name VARCHAR(100) NULL COMMENT '규칙명 (관리용)', + pattern JSON NOT NULL COMMENT '패턴 정의 (세그먼트 배열)', + reset_period VARCHAR(20) NOT NULL DEFAULT 'daily' COMMENT '시퀀스 리셋 주기: daily, monthly, yearly, never', + sequence_padding INT NOT NULL DEFAULT 2 COMMENT '시퀀스 자릿수', + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + UNIQUE KEY uq_tenant_doctype (tenant_id, document_type), + INDEX idx_tenant (tenant_id) +) COMMENT '테넌트별 채번 규칙'; +``` + +#### numbering_sequences (시퀀스 카운터) + +```sql +CREATE TABLE numbering_sequences ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + document_type VARCHAR(50) NOT NULL COMMENT '문서유형', + scope_key VARCHAR(100) NOT NULL DEFAULT '' COMMENT '범위 키 (카테고리/모델별 구분)', + period_key VARCHAR(20) NOT NULL COMMENT '기간 키: 260207(daily), 202602(monthly), 2026(yearly)', + last_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '마지막 시퀀스 번호', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + UNIQUE KEY uq_sequence (tenant_id, document_type, scope_key, period_key) +) COMMENT '채번 시퀀스 카운터'; +``` + +### 3.2 패턴 JSON 구조 + +#### 세그먼트 타입 정의 + +| type | 설명 | 필수 속성 | 예시 | +|------|------|----------|------| +| `static` | 고정 문자열 | `value` | `{"type": "static", "value": "KD"}` | +| `separator` | 구분자 | `value` | `{"type": "separator", "value": "-"}` | +| `date` | 날짜 | `format` (PHP date format) | `{"type": "date", "format": "ymd"}` | +| `sequence` | 일련번호 | (padding은 rule 레벨 설정 사용) | `{"type": "sequence"}` | +| `param` | 외부 파라미터 값 | `key`, `default` | `{"type": "param", "key": "pair_code", "default": "SS"}` | +| `mapping` | 파라미터→코드 매핑 | `key`, `map`, `default` | 아래 참조 | + +#### mapping 세그먼트 상세 + +```json +{ + "type": "mapping", + "key": "product_category", + "map": { "SCREEN": "SC", "STEEL": "ST" }, + "default": "SC" +} +``` +→ `params['product_category']`가 `"SCREEN"`이면 `"SC"` 출력 + +### 3.3 tenant_id=287 시드 데이터 + +#### 견적번호 규칙: `KD-PR-{YYMMDD}-{NN}` + +```json +{ + "tenant_id": 287, + "document_type": "quote", + "rule_name": "5130 견적번호", + "pattern": [ + {"type": "static", "value": "KD"}, + {"type": "separator", "value": "-"}, + {"type": "static", "value": "PR"}, + {"type": "separator", "value": "-"}, + {"type": "date", "format": "ymd"}, + {"type": "separator", "value": "-"}, + {"type": "sequence"} + ], + "reset_period": "daily", + "sequence_padding": 2, + "is_active": true +} +``` +**scope_key**: `""` (빈 문자열, 견적은 카테고리 구분 없이 통합 순번) +**생성 예시**: `KD-PR-260207-01`, `KD-PR-260207-02`, ... + +#### 수주 로트번호 규칙: `KD-{pairCode}-{YYMMDD}-{NN}` + +```json +{ + "tenant_id": 287, + "document_type": "order", + "rule_name": "5130 수주 로트번호", + "pattern": [ + {"type": "static", "value": "KD"}, + {"type": "separator", "value": "-"}, + {"type": "param", "key": "pair_code", "default": "SS"}, + {"type": "separator", "value": "-"}, + {"type": "date", "format": "ymd"}, + {"type": "separator", "value": "-"}, + {"type": "sequence"} + ], + "reset_period": "daily", + "sequence_padding": 2, + "is_active": true +} +``` +**scope_key**: `param` 세그먼트의 결과값 (예: `"SS"`, `"TS"`, `"비인정"`) +**생성 예시**: pair_code="SS" → `KD-SS-260207-01` + +#### pair_code 고정 목록 (9개, 향후 채번관리 UI에서 추가 가능) + +| # | 모델명 | 종류 | pair_code | 로트번호 예시 | +|---|--------|------|:---------:|-------------| +| 1 | KSS01 | 스크린 | SS | `KD-SS-260207-01` | +| 2 | KSS02 | 스크린 | SA | `KD-SA-260207-01` | +| 3 | KSE01 | 스크린 | SE | `KD-SE-260207-01` | +| 4 | KWE01 | 스크린 | WE | `KD-WE-260207-01` | +| 5 | KQTS01 | 철재 | TS | `KD-TS-260207-01` | +| 6 | KTE01 | 철재 | TE | `KD-TE-260207-01` | +| 7 | 스크린비인정 | 스크린 | 비인정 | `KD-비인정-260207-01` | +| 8 | 철재비인정 | 철재 | 비인정 | `KD-비인정-260207-01` | +| 9 | KDSS01 | 스크린 | DS | `KD-DS-260207-01` | + +### 3.4 NumberingService 구현 명세 + +```php +tenantId(); + + // 1. 규칙 조회 + $rule = NumberingRule::where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('is_active', true) + ->first(); + + if (!$rule) { + return null; // 규칙 없음 → 호출자가 기존 로직 사용 + } + + $segments = $rule->pattern; // JSON → array (cast) + $result = ''; + $scopeKey = ''; + + // 2. 세그먼트 순회하며 문자열 조립 + foreach ($segments as $segment) { + switch ($segment['type']) { + case 'static': + $result .= $segment['value']; + break; + + case 'separator': + $result .= $segment['value']; + break; + + case 'date': + $result .= now()->format($segment['format']); + break; + + case 'param': + $value = $params[$segment['key']] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; // scope_key로 사용 + break; + + case 'mapping': + $inputValue = $params[$segment['key']] ?? ''; + $value = $segment['map'][$inputValue] ?? $segment['default'] ?? ''; + $result .= $value; + $scopeKey = $value; + break; + + case 'sequence': + // 3. period_key 결정 + $periodKey = match ($rule->reset_period) { + 'daily' => now()->format('ymd'), + 'monthly' => now()->format('Ym'), + 'yearly' => now()->format('Y'), + 'never' => 'all', + default => now()->format('ymd'), + }; + + // 4. atomic increment + $nextSeq = $this->nextSequence( + $tenantId, $documentType, $scopeKey, $periodKey + ); + + // 5. padding 적용 + $result .= str_pad( + (string) $nextSeq, + $rule->sequence_padding, + '0', + STR_PAD_LEFT + ); + break; + } + } + + return $result; + } + + /** + * 미리보기 (시퀀스 증가 없이 다음 번호 예측) + */ + public function preview(string $documentType, array $params = []): ?array + { + $tenantId = $this->tenantId(); + $rule = NumberingRule::where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('is_active', true) + ->first(); + + if (!$rule) return null; + + // preview는 시퀀스를 증가시키지 않고 현재값+1 예측 + // (실제 generate와 동일 로직이나 DB UPDATE 없이 SELECT만) + // 구현 시 generate와 유사하되 nextSequence 대신 peekSequence 사용 + + return [ + 'preview_number' => '(preview)', + 'document_type' => $documentType, + 'rule_name' => $rule->rule_name, + ]; + } + + /** + * Atomic sequence increment (MySQL UPSERT) + */ + private function nextSequence( + int $tenantId, string $documentType, string $scopeKey, string $periodKey + ): int { + // MySQL INSERT ... ON DUPLICATE KEY UPDATE (atomic) + DB::statement( + 'INSERT INTO numbering_sequences + (tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + last_sequence = last_sequence + 1, + updated_at = NOW()', + [$tenantId, $documentType, $scopeKey, $periodKey] + ); + + // 방금 할당된 번호 조회 + return (int) DB::table('numbering_sequences') + ->where('tenant_id', $tenantId) + ->where('document_type', $documentType) + ->where('scope_key', $scopeKey) + ->where('period_key', $periodKey) + ->value('last_sequence'); + } +} +``` + +### 3.5 기존 서비스 수정 상세 + +#### QuoteNumberService.php 수정 + +```php +// ===== 변경 전 ===== +public function generate(?string $productCategory = null): string +{ + $tenantId = $this->tenantId(); + $prefix = match ($productCategory) { /* ... */ }; + // ... LIKE 검색으로 마지막 번호 조회 +} + +// ===== 변경 후 ===== +public function generate(?string $productCategory = null): string +{ + $tenantId = $this->tenantId(); + + // 1. NumberingService로 규칙 기반 생성 시도 + $number = app(NumberingService::class) + ->setContext($tenantId, $this->apiUserId()) + ->generate('quote', [ + 'product_category' => $productCategory ?? Quote::CATEGORY_SCREEN, + ]); + + // 2. 규칙 없으면 기존 로직 (하위호환) + if ($number === null) { + return $this->generateLegacy($productCategory); + } + + return $number; +} + +// 기존 로직을 별도 메서드로 분리 +private function generateLegacy(?string $productCategory = null): string +{ + // 현재 generate() 코드 그대로 이동 + $tenantId = $this->tenantId(); + $prefix = match ($productCategory) { + Quote::CATEGORY_SCREEN => 'SC', + Quote::CATEGORY_STEEL => 'ST', + default => 'SC', + }; + $dateStr = now()->format('ymd'); + $pattern = "KD-{$prefix}-{$dateStr}-%"; + $lastQuote = Quote::withTrashed() + ->where('tenant_id', $tenantId) + ->where('quote_number', 'like', $pattern) + ->orderBy('quote_number', 'desc') + ->first(); + $sequence = 1; + if ($lastQuote) { + $parts = explode('-', $lastQuote->quote_number); + if (count($parts) >= 4) { + $sequence = (int) end($parts) + 1; + } + } + $seqStr = str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + return "KD-{$prefix}-{$dateStr}-{$seqStr}"; +} + +// validate()도 규칙 기반이면 패턴 달라지므로 조정 +public function validate(string $quoteNumber): bool +{ + // 기존: /^KD-[A-Z]{2}-\d{6}-\d{2,}$/ + // 규칙 기반이면 "KD-PR-260207-01" 도 유효 → 더 넓은 패턴 + return (bool) preg_match('/^KD-.+-\d{6}-\d{2,}$/', $quoteNumber); +} +``` + +#### OrderService.php 수정 + +```php +// ===== 변경 전 ===== +private function generateOrderNo(int $tenantId): string +{ + $prefix = 'ORD'; + $date = now()->format('Ymd'); + // ... LIKE 검색 + return sprintf('%s%s%04d', $prefix, $date, $seq); +} + +// ===== 변경 후 ===== +private function generateOrderNo(int $tenantId, array $params = []): string +{ + // 1. NumberingService로 규칙 기반 생성 시도 + $number = app(NumberingService::class) + ->setContext($tenantId, $this->apiUserId()) + ->generate('order', $params); + + // 2. 규칙 없으면 기존 로직 + if ($number === null) { + return $this->generateOrderNoLegacy($tenantId); + } + + return $number; +} + +private function generateOrderNoLegacy(int $tenantId): string +{ + // 현재 generateOrderNo() 코드 그대로 이동 + $prefix = 'ORD'; + $date = now()->format('Ymd'); + $lastNo = Order::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('order_no', 'like', "{$prefix}{$date}%") + ->orderByDesc('order_no') + ->value('order_no'); + $seq = $lastNo ? (int) substr($lastNo, -4) + 1 : 1; + return sprintf('%s%s%04d', $prefix, $date, $seq); +} +``` + +#### store() 호출부 수정 (Line 148) +```php +// 변경 전 +$data['order_no'] = $this->generateOrderNo($tenantId); + +// 변경 후 (pair_code가 있으면 전달) +$data['order_no'] = $this->generateOrderNo($tenantId, [ + 'pair_code' => $data['pair_code'] ?? null, +]); +unset($data['pair_code']); // orders 테이블에 pair_code 컬럼 없으므로 제거 +``` + +#### createFromQuote() 호출부 수정 (Line 459) +```php +// 변경 전 +$orderNo = $this->generateOrderNo($tenantId); + +// 변경 후 +$orderNo = $this->generateOrderNo($tenantId, [ + 'pair_code' => $data['pair_code'] ?? null, +]); +``` + +#### StoreOrderRequest.php 수정 +```php +public function rules(): array +{ + return [ + // ... 기존 규칙 그대로 ... + + // 채번용 pair_code 추가 (수주 로트번호 생성에 사용) + 'pair_code' => 'nullable|string|max:20', + ]; +} +``` + +--- + +## 4. 파일 생성/수정 목록 + +### 새로 생성 (6개) + +| # | 파일 경로 | 용도 | +|---|----------|------| +| 1 | `api/database/migrations/2026_02_07_000001_create_numbering_rules_table.php` | 채번 규칙 테이블 | +| 2 | `api/database/migrations/2026_02_07_000002_create_numbering_sequences_table.php` | 시퀀스 카운터 테이블 | +| 3 | `api/app/Models/NumberingRule.php` | 채번 규칙 모델 | +| 4 | `api/app/Models/NumberingSequence.php` | 시퀀스 카운터 모델 | +| 5 | `api/app/Services/NumberingService.php` | 통합 채번 서비스 | +| 6 | `api/database/seeders/NumberingRuleSeeder.php` | tenant_id=287 시드 (견적/수주 규칙 2건) | + +### 수정 (3개) + +| # | 파일 경로 | 변경 내용 | +|---|----------|----------| +| 1 | `api/app/Services/Quote/QuoteNumberService.php` | generate() → NumberingService 우선, 기존→generateLegacy() | +| 2 | `api/app/Services/OrderService.php` | generateOrderNo() → NumberingService 우선, 기존→generateOrderNoLegacy(), store()/createFromQuote() 호출부에 pair_code 전달 | +| 3 | `api/app/Http/Requests/Order/StoreOrderRequest.php` | `pair_code` 옵셔널 필드 추가 | + +--- + +## 5. 작업 절차 (Step-by-Step) + +### Step 1: DB 마이그레이션 (Phase 1) + +1. 마이그레이션 파일 2개 생성 (numbering_rules, numbering_sequences) +2. `php artisan migrate` 실행 (Docker 컨테이너 내) +3. NumberingRuleSeeder 생성 → `php artisan db:seed --class=NumberingRuleSeeder` +4. DB에서 확인: `SELECT * FROM numbering_rules WHERE tenant_id = 287;` → 2건 + +### Step 2: 모델 & 서비스 (Phase 2) + +1. NumberingRule 모델 생성 (BelongsToTenant, `$casts = ['pattern' => 'array']`) +2. NumberingSequence 모델 생성 +3. NumberingService 생성 (섹션 3.4의 코드 기반) +4. 단위 확인: `tinker`에서 NumberingService 호출 테스트 + +### Step 3: 견적번호 연동 (Phase 3) + +1. QuoteNumberService 수정 (섹션 3.5 코드 기반) +2. 확인: tenant 287로 견적 생성 → `KD-PR-YYMMDD-01` 형식 확인 +3. 확인: 다른 테넌트로 견적 생성 → `KD-SC-YYMMDD-01` (기존 로직) + +### Step 4: 수주 로트번호 연동 (Phase 4) + +1. StoreOrderRequest에 `pair_code` 추가 +2. OrderService 수정 (섹션 3.5 코드 기반) +3. 확인: tenant 287로 수주 생성 (pair_code=SS) → `KD-SS-YYMMDD-01` +4. 확인: pair_code 미전달 → `KD-SS-YYMMDD-01` (default) +5. 확인: 다른 테넌트 → `ORD{YYYYMMDD}{NNNN}` (기존 로직) + +--- + +## 6. 검증 계획 + +### 6.1 테스트 케이스 + +| # | 시나리오 | 입력 | 예상 결과 | 상태 | +|---|---------|------|----------|:----:| +| 1 | 견적번호 (tenant 287, 첫 건) | quote, tenant=287 | `KD-PR-{오늘}-01` | ⏳ | +| 2 | 견적번호 (tenant 287, 두 번째) | quote, tenant=287 | `KD-PR-{오늘}-02` | ⏳ | +| 3 | 수주 로트 (pair_code=SS) | order, tenant=287, pair_code=SS | `KD-SS-{오늘}-01` | ⏳ | +| 4 | 수주 로트 (pair_code=TS) | order, tenant=287, pair_code=TS | `KD-TS-{오늘}-01` | ⏳ | +| 5 | 수주 로트 (pair_code 미전달) | order, tenant=287 | `KD-SS-{오늘}-01` (default) | ⏳ | +| 6 | 규칙 없는 테넌트 견적 | quote, tenant=999 | `KD-SC-{오늘}-01` (기존) | ⏳ | +| 7 | 규칙 없는 테넌트 수주 | order, tenant=999 | `ORD{오늘날짜}0001` (기존) | ⏳ | +| 8 | 날짜 변경 후 리셋 | 다음 날 첫 요청 | 순번 01 리셋 | ⏳ | + +### 6.2 성공 기준 + +| 기준 | 달성 | 비고 | +|------|:----:|------| +| tenant 287 견적번호 `KD-PR-YYMMDD-NN` | ⏳ | | +| tenant 287 수주 로트번호 `KD-{pairCode}-YYMMDD-NN` | ⏳ | | +| 규칙 없는 테넌트는 기존 번호 체계 유지 | ⏳ | 하위호환 | +| 번호 중복 불가 (atomic increment) | ⏳ | | + +### 6.3 확인 방법 + +```bash +# Docker 컨테이너 접속 +docker exec -it sam-api bash + +# 마이그레이션 실행 +php artisan migrate + +# 시더 실행 +php artisan db:seed --class=NumberingRuleSeeder + +# tinker로 테스트 +php artisan tinker + +# tenant 287 견적번호 테스트 +>>> app()->instance('tenant_id', 287); +>>> app()->instance('api_user', 1); +>>> app(App\Services\NumberingService::class)->generate('quote'); +// 예상: "KD-PR-260207-01" + +# tenant 287 수주 로트번호 테스트 +>>> app(App\Services\NumberingService::class)->generate('order', ['pair_code' => 'SS']); +// 예상: "KD-SS-260207-01" + +# 규칙 없는 테넌트 (null 반환 확인) +>>> app()->instance('tenant_id', 999); +>>> app(App\Services\NumberingService::class)->generate('quote'); +// 예상: null +``` + +--- + +## 7. 변경 이력 + +| 날짜 | 항목 | 변경 내용 | 파일 | 승인 | +|------|------|----------|------|------| +| 2026-02-07 | - | 계획 문서 작성 | - | - | + +--- + +## 8. 컨펌 대기 목록 + +| # | 항목 | 변경 내용 | 영향 범위 | 상태 | +|---|------|----------|----------|------| +| 1 | numbering_rules 테이블 생성 | 새 테이블 | DB | ⚠️ | +| 2 | numbering_sequences 테이블 생성 | 새 테이블 | DB | ⚠️ | +| 3 | QuoteNumberService 로직 변경 | NumberingService 우선 시도 | 견적 생성 API | ⚠️ | +| 4 | OrderService 로직 변경 | NumberingService 우선 시도 | 수주 생성 API | ⚠️ | +| 5 | StoreOrderRequest에 pair_code 추가 | 옵셔널 파라미터 | 수주 등록 API | ⚠️ | + +--- + +*이 문서는 /sc:plan 스킬로 생성되었습니다.* \ No newline at end of file