From 7a969b9d57c38e7e94fdb08eafc16706d48fb85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Mar 2026 22:53:07 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20[structure]=20sam/=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20docs=20=EB=A3=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=9E=AC=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore를 sam/ 기반에서 루트 기반으로 변경 - sam/docs/ 하위 문서를 루트로 이동 (contracts, features, guides, plans 등) - sam/ 폴더 삭제 (docker, coocon 포함) --- .gitignore | 48 +- changes/20260303_gemini_model_upgrade.md | 119 +++ .../20260304_eaccount_infinite_loop_fix.md | 165 +++ ...0260306_purchase_request_payment_method.md | 0 contracts/CHANGELOG.md | 42 + ...고객_서비스이용계약서_v4_0_전자서명용.docx | Bin 0 -> 39346 bytes contracts/docx/비밀유지서약서.docx | Bin 0 -> 29026 bytes .../docx/영업파트너 위촉계약서(단체용).docx | Bin 0 -> 25013 bytes contracts/docx/영업파트너 위촉계약서.docx | Bin 0 -> 33340 bytes contracts/markdown/01-service-agreement.md | 458 ++++++++ contracts/markdown/02-nda.md | 199 ++++ contracts/markdown/03-partner-agreement.md | 276 +++++ .../markdown/04-partner-agreement-group.md | 267 +++++ contracts/revisions.json | 58 + contracts/scripts/extract_to_markdown.py | 334 ++++++ contracts/scripts/sync_check.py | 263 +++++ data/interview-master-questions.sql | 279 +++++ dev/dev_plans/qms-api-integration-plan.md | 316 ++++++ .../academy/fire-shutter-image-prompts.md | 369 +++++++ features/approvals/README.md | 298 ++++++ features/approvals/api-reference.md | 594 +++++++++++ .../approvals/db-changes-and-model-sync.md | 286 +++++ features/approvals/form-types.md | 999 ++++++++++++++++++ features/approvals/ui-screens.md | 381 +++++++ features/approvals/workflows.md | 565 ++++++++++ .../esign-notification-guide.md | 250 +++++ features/business-card-request.md | 173 +++ features/credit-evaluation/README.md | 284 +++++ features/documents/mng-document-system.md | 738 +++++++++++++ features/documents/mng-document-template.md | 826 +++++++++++++++ features/planning/README.md | 129 +++ features/planning/construction-photos.md | 275 +++++ features/planning/meeting-minutes.md | 456 ++++++++ features/planning/planning-views.md | 222 ++++ features/rd/README.md | 110 ++ features/rd/design-insight.md | 246 +++++ features/rd/planning-design.md | 366 +++++++ .../rd/sound-logo-studio.md | 0 guides/ai-config-settings.md | 325 ++++++ guides/ai-management.md | 291 +++++ guides/ai-model-update-workflow.md | 313 ++++++ guides/pptx-generation-guide.md | 387 +++++++ guides/server-how-it-works.md | 247 +++++ guides/table-design-guide.md | 486 +++++++++ plans/SAM_General_Rule_Storyboard_D1.0.md | 737 +++++++++++++ plans/ai-quotation-engine-plan.md | 928 ++++++++++++++++ plans/attendance-management-plan.md | 284 +++++ plans/block-builder-evolution-plan.md | 706 +++++++++++++ plans/design-insight-menu-plan.md | 611 +++++++++++ plans/fire-shutter-drawing-generator-plan.md | 753 +++++++++++++ plans/sound-logo-generator-plan.md | 637 +++++++++++ projects/org-chart/README.md | 317 ++++++ projects/planning-design/README.md | 157 +++ rules/slides/usage-plan/SAM_활용방안.pptx | Bin 0 -> 279940 bytes rules/slides/usage-plan/convert.cjs | 31 + rules/slides/usage-plan/slide-01.html | 42 + rules/slides/usage-plan/slide-02.html | 58 + rules/slides/usage-plan/slide-03.html | 108 ++ rules/slides/usage-plan/slide-04.html | 88 ++ rules/slides/usage-plan/slide-05.html | 74 ++ rules/slides/usage-plan/slide-06.html | 68 ++ rules/slides/usage-plan/slide-07.html | 82 ++ system/ai-automation-vision.md | 174 +++ system/database/codebridge-separation.md | 443 ++++++++ 64 files changed, 18723 insertions(+), 15 deletions(-) create mode 100644 changes/20260303_gemini_model_upgrade.md create mode 100644 changes/20260304_eaccount_infinite_loop_fix.md rename {sam/docs/dev/changes => changes}/20260306_purchase_request_payment_method.md (100%) create mode 100644 contracts/CHANGELOG.md create mode 100644 contracts/docx/01_고객_서비스이용계약서_v4_0_전자서명용.docx create mode 100755 contracts/docx/비밀유지서약서.docx create mode 100755 contracts/docx/영업파트너 위촉계약서(단체용).docx create mode 100755 contracts/docx/영업파트너 위촉계약서.docx create mode 100644 contracts/markdown/01-service-agreement.md create mode 100644 contracts/markdown/02-nda.md create mode 100644 contracts/markdown/03-partner-agreement.md create mode 100644 contracts/markdown/04-partner-agreement-group.md create mode 100644 contracts/revisions.json create mode 100644 contracts/scripts/extract_to_markdown.py create mode 100644 contracts/scripts/sync_check.py create mode 100644 data/interview-master-questions.sql create mode 100644 dev/dev_plans/qms-api-integration-plan.md create mode 100644 features/academy/fire-shutter-image-prompts.md create mode 100644 features/approvals/README.md create mode 100644 features/approvals/api-reference.md create mode 100644 features/approvals/db-changes-and-model-sync.md create mode 100644 features/approvals/form-types.md create mode 100644 features/approvals/ui-screens.md create mode 100644 features/approvals/workflows.md create mode 100644 features/barobill-kakaotalk/esign-notification-guide.md create mode 100644 features/business-card-request.md create mode 100644 features/credit-evaluation/README.md create mode 100644 features/documents/mng-document-system.md create mode 100644 features/documents/mng-document-template.md create mode 100644 features/planning/README.md create mode 100644 features/planning/construction-photos.md create mode 100644 features/planning/meeting-minutes.md create mode 100644 features/planning/planning-views.md create mode 100644 features/rd/README.md create mode 100644 features/rd/design-insight.md create mode 100644 features/rd/planning-design.md rename {sam/docs/features => features}/rd/sound-logo-studio.md (100%) create mode 100644 guides/ai-config-settings.md create mode 100644 guides/ai-management.md create mode 100644 guides/ai-model-update-workflow.md create mode 100644 guides/pptx-generation-guide.md create mode 100644 guides/server-how-it-works.md create mode 100644 guides/table-design-guide.md create mode 100644 plans/SAM_General_Rule_Storyboard_D1.0.md create mode 100644 plans/ai-quotation-engine-plan.md create mode 100644 plans/attendance-management-plan.md create mode 100644 plans/block-builder-evolution-plan.md create mode 100644 plans/design-insight-menu-plan.md create mode 100644 plans/fire-shutter-drawing-generator-plan.md create mode 100644 plans/sound-logo-generator-plan.md create mode 100644 projects/org-chart/README.md create mode 100644 projects/planning-design/README.md create mode 100644 rules/slides/usage-plan/SAM_활용방안.pptx create mode 100644 rules/slides/usage-plan/convert.cjs create mode 100644 rules/slides/usage-plan/slide-01.html create mode 100644 rules/slides/usage-plan/slide-02.html create mode 100644 rules/slides/usage-plan/slide-03.html create mode 100644 rules/slides/usage-plan/slide-04.html create mode 100644 rules/slides/usage-plan/slide-05.html create mode 100644 rules/slides/usage-plan/slide-06.html create mode 100644 rules/slides/usage-plan/slide-07.html create mode 100644 system/ai-automation-vision.md create mode 100644 system/database/codebridge-separation.md diff --git a/.gitignore b/.gitignore index 9109c08..bbbc6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # 추적할 파일만 허용 !.gitignore !CLAUDE.md +!INDEX.md +!README.md +!resources.md # .claude 폴더 - 스킬/에이전트는 추적 !.claude/ @@ -14,22 +17,37 @@ !.claude/agents/ !.claude/agents/** -# sam 문서 -!sam/ -sam/* -!sam/docs/ -!sam/docs/** -sam/docs/contracts/docx/backup/ - -# sam 배포/운영 문서 -!sam/deploys/ -!sam/deploys/** -!sam/front/ -!sam/front/** -!sam/projects/ -!sam/projects/** +# 문서 폴더 (루트 기준) +!assets/ +!assets/** +!brochure/ +!brochure/** +!changes/ +!changes/** +!contracts/ +!contracts/** +contracts/docx/backup/ +!data/ +!data/** +!dev/ +!dev/** +!features/ +!features/** +!frontend/ +!frontend/** +!guides/ +!guides/** +!plans/ +!plans/** +!projects/ +!projects/** +!requests/ +!requests/** +!rules/ +!rules/** +!system/ +!system/** # 기타 -sam/sales .DS_Store _to_notion/ diff --git a/changes/20260303_gemini_model_upgrade.md b/changes/20260303_gemini_model_upgrade.md new file mode 100644 index 0000000..e3806fc --- /dev/null +++ b/changes/20260303_gemini_model_upgrade.md @@ -0,0 +1,119 @@ +# Gemini 모델 업그레이드: 2.0-flash → 2.5-flash + +**날짜:** 2026-03-03 +**작업자:** Claude Code + +--- + +## 변경 개요 + +Google이 2026년 6월 1일부로 Gemini 2.0 Flash 모델 서비스를 종료한다는 통보를 받아, SAM 시스템 전체의 Gemini 모델을 `gemini-2.0-flash` → `gemini-2.5-flash`로 마이그레이션했다. + +--- + +## 변경 사유 + +- Google의 공식 메일 통보: Gemini 2.0 Flash / 2.0 Flash-Lite → 2026-06-01 강제 종료 +- 마이그레이션 경로: `gemini-2.0-flash` → `gemini-2.5-flash` +- API 키, Base URL 변경 없음 (모델명만 변경) + +--- + +## 수정된 파일 + +### API 프로젝트 (`/home/aweso/sam/api`) + +| 파일 | 변경 내용 | +|------|----------| +| `.env` | `GEMINI_MODEL=gemini-2.0-flash` → `gemini-2.5-flash` | +| `config/services.php` | fallback 기본값 `gemini-2.0-flash` → `gemini-2.5-flash` | +| `app/Services/AiReportService.php` | fallback 기본값 변경 | + +### MNG 프로젝트 (`/home/aweso/sam/mng`) + +| 파일 | 변경 내용 | +|------|----------| +| `.env` | `GEMINI_MODEL=gemini-2.0-flash` → `gemini-2.5-flash` | +| `config/services.php` | fallback 기본값 변경 | +| `app/Models/System/AiConfig.php` | `DEFAULT_MODELS['gemini']` 상수 + `getActiveGemini()` fallback 변경 | +| `app/Services/NotionService.php` | fallback 기본값 변경 | +| `resources/views/system/ai-config/index.blade.php` | UI placeholder, 기본값, JS defaultModels 변경 | +| `resources/views/google-cloud/ai-guide/index.blade.php` | 서비스 현황 테이블 모델명 7곳 변경 | +| `resources/views/academy/env-management.blade.php` | 환경변수 예시 테이블 변경 | + +### 문서 (`/home/aweso/sam/docs`) + +| 파일 | 변경 내용 | +|------|----------| +| `guides/ai-config-settings.md` | 기본 모델명 업데이트, 최종 업데이트 날짜 변경 | +| `guides/ai-management.md` | **신규** — AI 관리 종합 가이드 (아키텍처, 버전 이력, 온보딩) | +| `guides/ai-model-update-workflow.md` | **신규** — 모델 업데이트 표준 절차 (7단계 워크플로우) | +| `changes/20260303_gemini_model_upgrade.md` | **신규** — 이 변경 이력 문서 | + +### 수정하지 않은 파일 (의도적) + +| 파일 | 이유 | +|------|------| +| `api/database/migrations/2026_01_27_*.php` | 이미 실행된 마이그레이션 — 변경 시 DB 무결성 문제 | +| `api/database/migrations/2026_02_07_*.php` | 동일 | +| `api/database/migrations/2026_02_09_*.php` | 동일 | +| `mng/views/google-cloud/cloud-api-pricing/index.blade.php` | `2.0 → 2.5` 마이그레이션 안내 UI — 이전 모델명이 의도적 잔존 | + +--- + +## 서버 .env 수정 필요 (배포 후) + +| 환경 | 파일 | 변수 | 담당 | +|------|------|------|------| +| 개발서버 | `/home/webservice/api/.env` | `GEMINI_MODEL=gemini-2.5-flash` | SSH 접속 수정 | +| 개발서버 | `/home/webservice/mng/.env` | `GEMINI_MODEL=gemini-2.5-flash` | SSH 접속 수정 | +| 운영서버 | `/home/webservice/api/.env` | `GEMINI_MODEL=gemini-2.5-flash` | 개발팀장 직접 | +| 운영서버 | `/home/webservice/mng/.env` | `GEMINI_MODEL=gemini-2.5-flash` | 개발팀장 직접 | + +수정 후 반드시 실행: +```bash +php artisan config:clear +``` + +--- + +## DB 단가 설정 필요 + +MNG `/system/ai-token-usage` → 단가 설정에서: +- 기존 `gemini-2.0-flash` 단가 → 비활성화 +- 신규 `gemini-2.5-flash` 단가 추가: + - `input_price_per_million`: 0.15 + - `output_price_per_million`: 0.60 + - `exchange_rate`: 현재 환율 + +--- + +## 테스트 체크리스트 + +- [x] 로컬 .env 수정 완료 +- [x] 코드 fallback 전체 변경 완료 +- [ ] 로컬 연결 테스트 (MNG `/system/ai-config`) +- [ ] 개발서버 .env 수정 + config:clear +- [ ] 개발서버 연결 테스트 +- [ ] 운영서버 .env 수정 + config:clear +- [ ] DB 단가 설정 (gemini-2.5-flash) +- [ ] 토큰 사용량 로그 확인 (새 모델명) + +--- + +## 롤백 절차 + +문제 발생 시 `.env`만 되돌리면 즉시 복구: +```bash +# 모든 환경의 .env에서 +GEMINI_MODEL=gemini-2.0-flash +php artisan config:clear +``` + +--- + +## 관련 문서 + +- [AI 관리 종합 가이드](../guides/ai-management.md) +- [모델 업데이트 워크플로우](../guides/ai-model-update-workflow.md) +- [AI 설정 기술문서](../guides/ai-config-settings.md) diff --git a/changes/20260304_eaccount_infinite_loop_fix.md b/changes/20260304_eaccount_infinite_loop_fix.md new file mode 100644 index 0000000..e754f9f --- /dev/null +++ b/changes/20260304_eaccount_infinite_loop_fix.md @@ -0,0 +1,165 @@ +# 계좌 입출금내역 부분 월 조회 시 무한루프 크래시 수정 + +**날짜:** 2026-03-04 +**작업자:** Claude Code + +--- + +## 변경 개요 + +계좌 입출금내역 페이지에서 **날짜를 수동 입력**하여 조회 시 500 에러가 발생하는 문제를 수정했다. +편의 버튼(이번달, 지난달 등)은 항상 전체 월(1일~말일)을 사용하여 문제가 없었으나, +수동으로 날짜를 입력하면 **부분 월**(예: 12/01~12/18)이 되어 무한루프가 발생했다. + +--- + +## 근본 원인 + +### `splitDateRangeMonthly()` 함수의 cursor 이동 버그 + +긴 기간 조회 시 바로빌 SOAP API의 한계로 인해 기간을 **월별 청크**로 분할하는 함수에서, +endDate가 **월 중간**일 때 cursor가 **같은 달 1일로 되돌아가** 무한루프가 발생했다. + +```php +// ❌ 버그 코드 — endDate가 월 중간이면 무한루프 +$cursor = $chunkEnd->copy()->addDay()->startOfMonth(); + +// 예시: endDate = 20251218 +// chunkEnd = 20251218 +// → addDay() = 20251219 +// → startOfMonth() = 20251201 ← 같은 달 1일로 되돌아감! +// → while($cursor <= $end) 조건 여전히 true → 무한 반복 +``` + +```php +// ✅ 수정 코드 — chunkStart 기준으로 다음 월로 이동 +$cursor = $chunkStart->copy()->addMonth()->startOfMonth(); + +// 예시: startDate = 20251201 +// chunkStart = 20251201 +// → addMonth() = 20260101 +// → startOfMonth() = 20260101 ← 다음 달로 정상 이동 +// → while($cursor <= $end) 조건 false → 루프 종료 +``` + +### 재현 조건 + +| 조건 | 결과 | +|------|------| +| 전체 월 (12/01~12/31) | 정상 — `addDay()` = 01/01 → `startOfMonth()` = 01/01 | +| 부분 월 (12/01~12/18) | **무한루프** — `addDay()` = 12/19 → `startOfMonth()` = 12/01 | +| 다중 월 (12/01~02/18) | **무한루프** — 마지막 월이 부분 월이면 동일 증상 | + +### 증상 + +- PHP 프로세스가 메모리 한도(256M/512M)에 도달하여 **Fatal Error로 크래시** +- Laravel 로그에 에러 기록 없음 (try-catch 밖에서 프로세스가 종료) +- 프론트엔드에 `서버 응답 오류 (500):` (빈 응답 본문) + +--- + +## 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `app/Http/Controllers/Barobill/EaccountController.php` | `splitDateRangeMonthly()` cursor 이동 로직 수정 | + +--- + +## 검증 결과 + +tinker에서 수정 전후 비교 테스트: + +``` +=== 수정 전 (버그): 20251201~20251218 === +→ 같은 청크 무한 반복 (10회 제한으로 강제 중단) + +=== 수정 후: 20251201~20251218 === +→ [{start: 20251201, end: 20251218}] ← 1개 청크, 정상 + +=== 수정 후: 20251201~20260218 (다중 월) === +→ [{20251201~20251231}, {20260101~20260131}, {20260201~20260218}] ← 3개 청크, 정상 + +=== 수정 후: 20251215~20251231 === +→ [{start: 20251215, end: 20251231}] ← 1개 청크, 정상 +``` + +--- + +## 동일 패턴 코드베이스 점검 결과 + +`sam/mng` 전체를 검색하여 유사 패턴을 점검했다: + +| 파일 | 함수 | 패턴 | 위험도 | +|------|------|------|--------| +| `EaccountController.php` | `splitDateRangeMonthly()` | 월별 청크 분할 | ✅ 수정 완료 | +| `DashboardStatService.php` | `generateDateRange()` | `addDay()` 단순 증가 | 안전 | +| `InspectionCycle.php` | `getHolidayDates()` | `addDay()` 단순 증가 | 안전 | +| `CorporateCardController.php` | `getNextBusinessDay()` | `addDay()` 단순 증가 | 안전 | +| `PartitionManagementService.php` | `addPartitions()` | `for` 루프 (고정 횟수) | 안전 | + +> **결론**: `EaccountController` 외에 동일 버그 패턴 없음. +> 다른 코드들은 모두 `addDay()` 단순 증가 패턴을 사용하여 무한루프 위험 없음. + +--- + +## 교훈 및 방지 규칙 + +### R1. 날짜 cursor 이동 시 `chunkEnd` 기반 이동 금지 + +```php +// ❌ 위험: chunkEnd가 월 중간이면 startOfMonth()가 같은 달로 되돌림 +$cursor = $chunkEnd->copy()->addDay()->startOfMonth(); + +// ✅ 안전: chunkStart 기준으로 항상 다음 월로 이동 +$cursor = $chunkStart->copy()->addMonth()->startOfMonth(); +``` + +### R2. 날짜 루프에 안전장치(max iterations) 추가 권장 + +```php +$maxIterations = 120; // 10년 = 120개월 +$iterations = 0; + +while ($cursor->lte($end) && $iterations < $maxIterations) { + // ... 청크 처리 ... + $iterations++; +} + +if ($iterations >= $maxIterations) { + Log::error('날짜 분할 루프 안전장치 작동', compact('startDate', 'endDate')); +} +``` + +### R3. 부분 월 테스트 필수 + +날짜 범위를 분할하는 코드 작성/수정 시 반드시 다음 케이스를 테스트: + +- [ ] 전체 월 (01일~말일) +- [ ] 부분 월 — 시작 (01일~중간) +- [ ] 부분 월 — 끝 (중간~말일) +- [ ] 다중 월 (마지막 월이 부분 월) +- [ ] 같은 날 (시작일 = 종료일) + +--- + +## 부수 개선 사항 + +이 문제 조사 과정에서 추가로 발견/수정된 항목: + +| 항목 | 내용 | +|------|------| +| WSDL 캐싱 | `WSDL_CACHE_NONE` → `WSDL_CACHE_BOTH` (4개 바로빌 컨트롤러 전체) | +| 소켓 타임아웃 | `default_socket_timeout` 60→120초 연장 | +| Shutdown handler | PHP Fatal Error 감지 시 Laravel 로그에 기록 | +| SOAP 호출 로깅 | 호출 시작/완료 시간 + 소요시간(ms) 기록 | + +--- + +## 관련 문서 + +- `app/Http/Controllers/Barobill/EaccountController.php` — 바로빌 계좌 입출금내역 + +--- + +**최종 업데이트**: 2026-03-04 diff --git a/sam/docs/dev/changes/20260306_purchase_request_payment_method.md b/changes/20260306_purchase_request_payment_method.md similarity index 100% rename from sam/docs/dev/changes/20260306_purchase_request_payment_method.md rename to changes/20260306_purchase_request_payment_method.md diff --git a/contracts/CHANGELOG.md b/contracts/CHANGELOG.md new file mode 100644 index 0000000..cfd3346 --- /dev/null +++ b/contracts/CHANGELOG.md @@ -0,0 +1,42 @@ +# 계약서 개정이력 + +> **작성일**: 2026-02-22 +> **관리 대상**: 전자계약 DOCX 4종 + +--- + +## v4.1 (2026-02-22) + +**작성자**: 개발팀 +**대상**: 고객사 서비스 이용계약서 + +- 제4조에 사용량 기반 추가 과금 조항(4.5) 추가 + - 파일 저장 공간: 기본 100GB 초과 시 100GB당 100,000원/월 + - AI 토큰: 월 100만 토큰 기본, 초과 시 1,000토큰 단위 실비 과금 +- 제4조에 바로빌 부가 서비스 요금 조항(4.6) 추가 + - 계좌조회, 카드내역, 세금계산서 발행 요금 명시 + - 홈택스 매입/매출 조회는 회사 부담 명시 + +--- + +## v4.0 (2026-02-22) + +**작성자**: 개발팀 + +- 계약서 버전 관리 시스템 도입 +- DOCX → Markdown 미러링 체계 구축 +- 4개 전자계약 문서에 개정이력 테이블 삽입 +- 동기화 검증 스크립트 구축 + +### 대상 문서 + +| 파일 | 문서명 | +|------|--------| +| `01_고객_서비스이용계약서_v4_0_전자서명용.docx` | 고객사 서비스 이용계약서 | +| `비밀유지서약서.docx` | 비밀유지서약서 (NDA) | +| `영업파트너 위촉계약서.docx` | 영업파트너 위촉계약서 | +| `영업파트너 위촉계약서(단체용).docx` | 영업파트너 위촉계약서 (단체용) | + +--- + +**최종 업데이트**: 2026-02-22 (v4.1) diff --git a/contracts/docx/01_고객_서비스이용계약서_v4_0_전자서명용.docx b/contracts/docx/01_고객_서비스이용계약서_v4_0_전자서명용.docx new file mode 100644 index 0000000000000000000000000000000000000000..de1e3ed35ccfea3c3b4b0b6e91664c1f4240caa4 GIT binary patch literal 39346 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

J( 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 literal 0 HcmV?d00001 diff --git a/contracts/docx/영업파트너 위촉계약서(단체용).docx b/contracts/docx/영업파트너 위촉계약서(단체용).docx new file mode 100755 index 0000000000000000000000000000000000000000..1332c74876f5e975e58e9542a227fda12bcfcd05 GIT binary patch literal 25013 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 T2?hay0&W3db$b$nU%meUdV(v2 literal 0 HcmV?d00001 diff --git a/contracts/docx/영업파트너 위촉계약서.docx b/contracts/docx/영업파트너 위촉계약서.docx new file mode 100755 index 0000000000000000000000000000000000000000..9e849b513362dd7b7d2e788da6d693dc289c2de0 GIT binary patch literal 33340 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

@zWj)5wQoxb|&()EWYj>^EmkuAe~yIqwH97YC{ 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 g;V|z1fdBQfmz4km^eDeZ&nQ4wfKfDq*RR ⚠️ 중요: - 월 구독료는 원이며, 영업 협상 및 개발 범위에 따라 증액될 수 있습니다. + +- 계약 시 확정된 구독료: [ ]원/월 + +### 4.3 납부 방법 + +- **개발비**: + - 계좌이체 (세금계산서 발행) + - 입금 계좌: 기업은행 170-175519-04-011  (주)코드브릿지엑스 +- **구독료**: + - CMS 자동이체 (권장) + - 또는 세금계산서 발행 후 계좌이체 + +### 4.4 잔금 지급 기한 [법률 검토 반영] + +- **지급 기한**: 서비스 게시일로부터 **3일 이내** +- **사전 준비**: 회사는 영업 단계부터 납품 일정을 공유하여 고객이 미리 준비할 수 있도록 합니다. +- **미납 시 조치**: 제13조 참조 + +### 4.5 사용량 기반 추가 과금 + +기본 제공 한도 초과 시 다음과 같이 실비 과금됩니다. + +| 항목 | 기본 제공 | 추가 과금 기준 | +| --- | --- | --- | +| 파일 저장 공간 | 100GB | 100GB당 100,000원/월 (부가세 별도) | +| AI 토큰 | 월 100만 토큰 | 1,000토큰 단위 실비 과금 | + +- **파일 저장 공간: **기본 100GB를 초과하는 경우 100GB 단위로 월 100,000원(부가세 별도)이 추가 과금됩니다. +- **AI 토큰: **월 100만 토큰 기본 제공되며, 초과 사용 시 1,000토큰 단위로 실비 과금됩니다. + - 미사용 잔여 토큰은 이월되지 않습니다. (매월 1일 갱신) + - 기본 제공량 80%, 100% 소진 시 자동 알림이 발송됩니다. + +### 4.6 바로빌 부가 서비스 요금 + +고객이 선택적으로 이용하는 바로빌 연동 서비스의 요금은 다음과 같습니다. + +| 서비스 | 과금 방식 | 기본 제공 | 추가 과금 | +| --- | --- | --- | --- | +| 계좌조회 | 월정액 10,000원 | 1계좌 | 추가 1계좌당 10,000원 | +| 카드내역 | 월정액 10,000원 | 5장 | 추가 1장당 5,000원 | +| 세금계산서 발행 | 건별 | 100건 | 추가 50건당 5,000원 | + +- **바로빌 서비스 요금은 고객이 부담하며, 월 구독료와 별도로 청구됩니다.** + - 홈택스 매입/매출 조회 서비스(월 30,000원)는 회사가 부담합니다. + - 상기 금액은 부가세 별도입니다. + +## 제5조 (마일스톤 및 진행 일정) + +### 5.1 개발 단계 (5단계 통일) + +| 단계 | 주요 활동 | 진행률 | 기간 | 납부 | +| --- | --- | --- | --- | --- | +| M1 | 요구사항 분석 및 기획 | 20% | [ 2 ]주 | 1차 개발비 (착수금 50%) | +| M2 | 설계 및 개발 착수 | 50% | [ 2 ]주 | - | +| M3 | 개발 진행 (50% 완료) | 60% | [ 2 ]주 | - | +| M4 | 개발 완료 및 테스트 | 80% | [ 2 ]주 | - | +| M5 | 검수 및 서비스 게시 | 100% | 최대 2주 | 2차 개발비 (잔금 50%) | + +> ⚠️ 중요: - 5단계 마일스톤으로 통일 관리 - M5 검수 완료 후 서비스 게시 - 서비스 게시일로부터 3일 이내 잔금 납부 + +### 5.2 일정 조정 + + - 개발 일정은 고객의 협조에 따라 변동될 수 있습니다. + - 고객 귀책 사유로 인한 지연은 회사의 책임이 아닙니다. + - 불가항력으로 인한 지연 시 양측 협의하여 일정을 조정합니다. + +## 제6조 (서비스 게시 및 검수) + +### 6.1 서비스 게시 + +- 회사는 개발 완료 후 고객에게 **서비스 게시**를 통지합니다. +- **서비스 게시일**은 고객이 서비스에 접근 가능한 날짜를 의미합니다. + - 서비스 게시일부터 구독료가 발생합니다. + +### 6.2 검수 기간 + +- 고객은 개발 완료 후 **최대 2주간 검수 기간**을 가집니다. +- 검수 기간은 서비스 게시 **전**에 이루어집니다. + - 검수 기간 중 발견된 하자는 회사가 무상으로 수정합니다. + +### 6.3 검수 완료 + + - 고객이 서면으로 검수 완료를 통지하거나, + - 검수 기간 2주 종료 시점에 특별한 이의가 없으면 자동 승인으로 간주합니다. + - 검수 완료 후 서비스 게시일이 확정되고, 하자담보 책임 정책이 적용됩니다. + +## 제7조 (하자담보 책임) + +### 7.1 책임 기간 + +서비스 게시일로부터 1년 (소프트웨어산업진흥법 제16조, 민법 제667조) + +### 7.2 하자담보 범위 (무상 처리) + +| 항목 | 내용 | 예시 | +| --- | --- | --- | +| 버그 수정 | 소프트웨어 오류 | 계산 오류, 기능 미작동 | +| 미구현 기능 | 계약서에 명시된 기능 누락 | 약속된 기능 미구현 | +| 성능 개선 | 명시된 성능 기준 미달 | 속도 저하, 응답 지연 | +| UI/UX 수정 | 사용성 문제 | 버튼 미작동, 화면 깨짐 | +| 데이터 오류 | 데이터 손실 또는 오류 | 데이터 삭제, 중복 생성 | +| 보안 패치 | 보안 취약점 수정 | 해킹 방지, 암호화 | + +### 7.3 제외 사항 (별도 비용) + +| 항목 | 내용 | 예시 | +| --- | --- | --- | +| 신규 기능 개발 | 계약서에 없던 새 기능 | 새로운 모듈, 기능 확장 | +| 구조 변경 | 시스템 아키텍처 변경 | DB 구조, 프레임워크 교체 | +| 추가 모듈 | 새로운 모듈 개발 | 회계 모듈, 재고 모듈 | +| 기획 변경 | 초기 기획과 다른 요구사항 | 화면 구성, 프로세스 변경 | +| 교육/컨설팅 | 사용자 교육, 업무 컨설팅 | 직원 교육, 프로세스 개선 | + +### 7.4 하자 처리 절차 + +| 단계 | 내용 | 기간 | +| --- | --- | --- | +| 1. 하자 신고 | 고객이 이메일로 하자 신고 | - | +| 2. 하자 확인 | 회사가 하자 여부 판정 | 3영업일 | +| 3. 수정 작업 | 하자 인정 시 무상 수정 | 7영업일 | +| 4. 검수 완료 | 고객이 수정 사항 확인 | - | + +> ⚠️ 긴급 하자 (서비스 중단)는 24시간 이내 조치합니다. + +### 7.5 책임 면제 사유 + +다음의 경우 하자담보 책임이 면제됩니다: +- **고객 귀책 사유**: + - 고객의 임의 수정 또는 변경 + - 승인되지 않은 제3자 개입 + - 사용 환경 미준수 +- **불가항력**: + - 천재지변 (지진, 태풍 등) + - 전쟁, 테러, 전염병 + - 정부 규제 또는 법령 변경 +- **기간 만료**: + - 서비스 게시일로부터 1년 경과 + +## 제8조 (계약 해제 및 환불) + +### 8.1 환불 정책 개요 + +고객의 임의 해제 권리와 회사의 투입 비용 보전의 균형을 고려하여 수립되었습니다. + +### 8.2 단계별 환불 + +### Phase 1: 상담(인터뷰) 시작 전 + +- **환불율**: 100% (전액 환불) +- **조건**: 계약 후 상담(인터뷰) 배정 전 +- **위약금**: 없음 +- **임의 해제 가능** + +### Phase 2: 상담(인터뷰) 시작 후, 개발 착수 전 + +| 진행 상황 | 환불율 | 공제 내역 | +| --- | --- | --- | +| M1: 기획안 작성 중 (50% 미만) | 80% | 상담매니저 및 기획/개발자 투입 비용 20% 공제 | +| M2: 기획안 완료 (50% 이상) | 50% | 상담매니저 및 기획/개발자 투입 비용 50% 공제 | + +### Phase 3: 개발 진행 중 (5단계 마일스톤 기준) + +| 마일스톤 | 진행률 | 청구 금액(개발비 대비) | 비고 | +| --- | --- | --- | --- | +| M3: 개발 진행 중 (50%) | 70% | 70% | 30% 환불 | +| M4: 개발 완료 및 테스트 | 90% | 90% | 10% 환불 | +| M5: 서비스 개시 완료 | 100% | 100% | 환불 불가 | + +> ⚠️ 중요: 5단계 마일스톤으로 통일 관리 + +### Phase 4: 서비스 게시 후 + +- **환불율**: 0% (환불 불가) +- **개발비**: 전액 확정, 환불 불가 +- **구독료**: 매월 말일 후불제이므로 사용한 만큼만 청구 (환불 개념 없음) +- **대신 제공**: 하자담보 책임 (1년) + 유지보수 (구독 기간 전체) + +### 8.3 환불 불가 사유 + +- **고객 귀책 사유**: + - 협조 지연으로 인한 개발 지연 + - 요구사항 변경으로 인한 추가 개발 + - 승인 거부 또는 회피 +- **약관 위반**: + - 허위 정보 제공 + - 부정 사용 또는 재판매 + - 회사 명예 훼손 + +### 8.4 할인 계약 해지 시 추가 조건 + +본 계약이 정상가 대비 할인 조건으로 체결된 경우, 다음 조건이 추가 적용된다. + +- 발주자 귀책 해지 시 정상가(할인 전 금액) 기준으로 정산한다. + +## 제9조 (구독 및 해지) + +### 9.1 구독 시작 + +- **시작일**: 서비스 게시일 (검수 완료 후) +- **결제일**: 매월 말일 +- **청구 방식**: 후불제 (해당 월 사용량 기준) +- **일할 계산**: (사용 일수 / 해당 월 일수) × 구독료 + +> ⚠️ 중요: - 계약 시 확정된 구독료 금액은 [ ]원/월입니다. + +- 매월 말일에 해당 월 사용일수만큼만 후불 청구됩니다. + +### 9.2 구독 해지 + + - 고객은 언제든지 구독을 해지할 수 있습니다. (위약금 없음) + - 해지 신청 후 30일간 데이터 백업 기간 제공 + - 해지일로부터 30일 후 모든 데이터 완전 삭제 + +## 제10조 (유지보수 정책) + +### 10.1 유지보수 개요 + +- **적용 대상**: 구독료를 정상 납부하는 고객 +- **적용 기간**: 구독 기간 전체 (하자담보 책임 1년 이후에도 구독 중이면 계속 제공) +- **비용**: 월 구독료(500,000원)에 포함 + +### 10.2 하자담보 책임과의 차이 + +| 구분 | 하자담보 책임 (제7조) | 유지보수 (제9조의2) | +| --- | --- | --- | +| 기간 | 서비스 게시일로부터 1년 | 구독 기간 전체 | +| 근거 | 법적 의무 (소프트웨어산업진흥법) | 계약 조건 | +| 비용 | 무상 | 구독료에 포함 | +| 범위 | 하자(버그, 미구현 등) | 하자 + 일반 유지보수 | + +### 10.3 유지보수 범위 (구독료에 포함) + +> ✅ 무상 제공: - 모든 버그 수정 및 오류 처리 - 보안 패치 및 업데이트 - 성능 최적화 - 긴급 장애 대응 (24시간 이내) - 데이터 백업 및 복구 - 기술 지원 (고객센터, 이메일) - 플랫폼 업데이트 (프레임워크, 브라우저 호환성) + +> ❌ 별도 비용: - 신규 기능 개발 - 커스터마이징 및 추가 개발 - 기획 변경 (화면 구성, 프로세스 변경) - 외부 시스템 연동 - 추가 교육 및 컨설팅 + +### 10.4 서비스 레벨 (SLA) + +| 심각도 | 상황 | 응답 시간 | 해결 목표 | +| --- | --- | --- | --- | +| 긴급 (P0) | 서비스 완전 중단 | 1시간 | 24시간 | +| 높음 (P1) | 주요 기능 장애 | 4시간 | 3영업일 | +| 보통 (P2) | 일반 버그 | 1영업일 | 7영업일 | +| 낮음 (P3) | 문의/안내 | 1영업일 | 3영업일 | + +### 10.5 정기 유지보수 + +- **월간**: 보안 패치, 백업 점검 (매월 첫째 주 일요일 새벽) +- **분기**: 성능 최적화 (분기 말 일요일 새벽) +- **반기**: 시스템 점검 (6월/12월 일요일 새벽) + +> ⚠️ 모든 정기 점검은 최소 7일 전 사전 공지됩니다. + +### 10.6 유지보수 신청 + +- **고객센터**: 02-6347-0005 (평일 09:00~18:00 ) +- **이메일**: support@codebridge-x.com (24시간) +- **시스템 내**: SAM 시스템 내 고객지원 메뉴 + +### 10.7 유지보수 종료 + +다음의 경우 유지보수 서비스가 종료됩니다: 1. 구독 해지 시 2. 구독료 3개월 연속 미납 시 3. 중대한 약관 위반 시 + +## 제11조 (고객의 의무) + +고객은 다음 사항을 준수해야 합니다: +- **정확한 정보 제공**: 허위 정보 제공 금지 +- **협조 의무**: 개발에 필요한 자료 및 정보 제공 +- **부정 사용 금지**: 서비스의 재판매, 재배포 금지 +- **지적재산권 존중**: 무단 복제, 역설계 금지 + +## 제12조 (회사의 의무) + +회사는 다음 사항을 준수합니다: +- **서비스 제공**: 계약서에 명시된 서비스 제공 +- **하자담보 책임**: 1년간 하자 무상 수정 +- **개인정보 보호**: 개인정보보호법 준수 +- **기술 지원**: 고객센터 운영 및 기술 지원 + +## 제13조 (미입금 시 법적 조치) + +### 13.1 개발비 미입금 절차 + +| 단계 | 시점 | 조치 내용 | +| --- | --- | --- | +| 1차 독촉 | 기한 경과 후 3일 | 이메일 및 SMS 발송 | +| 내용증명 | 기한 경과 후 7일 | 우편 발송, 7일 내 입금 요청 | +| 추심등 | 기한 경과 후 14일 | 신용정보사 연체 등록, 법률대리인 위임 | +| 법적 조치 | 기한 경과 후 30일 | 지급명령 신청 또는 소송 제기 | + +### 13.2 구독료 미입금 절차 + +| 단계 | 시점 | 조치 내용 | +| --- | --- | --- | +| 1차 실패 | 익일 | 재출금 | +| 2차 실패 | 기한 경과 후 3일 | 재출금 | +| 3차 실패 | 미수금 처리 | 서비스 접근 제한, 1차 독촉 | +| 내용증명 | 기한 경과 후 7일 | 우편 발송, 7일 내 입금 요청 | +| 서비스 중단 | 기한 경과 후 14일 | 로그인 불가, 데이터 격리 | +| 강제 해지 | 기한 경과 후 30일 | 계약 해지, 법적 조치 검토 | + +## 제14조 (개인정보 보호) + + - 회사는 「개인정보 보호법」을 준수합니다. + - 고객의 개인정보는 서비스 제공 목적으로만 사용됩니다. + - 제3자에게 제공하지 않습니다. (법령 제외) + - 계약 종료 시 개인정보는 즉시 삭제됩니다. (법정 보관 의무 제외) + +## 제15조 (지적재산권) + +- **소유권**: 서비스에 대한 모든 지적재산권은 회사에 귀속됩니다. +- **사용 권한**: 고객은 서비스 사용 권한만을 부여받습니다. +- **금지 사항**: 복제, 배포, 역설계, 재판매 금지 + +## 제16조 (손해배상) + + - 회사 또는 고객이 본 계약을 위반하여 상대방에게 손해를 입힌 경우 배상 책임이 있습니다. + - 다만, 불가항력으로 인한 손해는 배상 책임에서 제외됩니다. + +## 제17조 (불가항력) + +다음의 사유로 서비스 제공이 불가능한 경우 회사는 책임을 지지 않습니다: + - 천재지변 (지진, 태풍, 홍수 등) + - 전쟁, 테러, 전염병 + - 정부 규제 또는 법령 변경 + - 제3자의 불법 행위 (해킹 등) + +## 제18조 (분쟁 해결) + + - 본 계약과 관련한 분쟁은 상호 협의하여 해결합니다. +- 협의가 이루어지지 않을 경우, **서울중앙지방법원**을 관할 법원으로 합니다. + +## 제19조 (계약의 효력) + + - 본 계약은 계약일로부터 효력이 발생합니다. + - 본 계약은 구독 해지 시까지 유효합니다. + +## 제20조 (기타) + + - 본 계약서는 2부 작성하여 회사와 고객이 각 1부씩 보관합니다. + - 본 계약의 해석 및 적용은 대한민국 법률을 준거법으로 합니다. + +## 계약 당사자 + +### [회사] + +상호: 주식회사 코드브릿지엑스 +대표자: 이의찬 +사업자등록번호: 664-86-03713 +주소: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 +이메일: contact@codebridge-x.com +전화: 02-6347-0005 +서명: +날짜: + +### [고객] + +상호: +대표자: +사업자등록번호: +주소: +이메일: +전화: +서명: +날짜: + +## 별첨 + +### 별첨 1: 기획서 + +[별도 첨부] + +### 별첨 2: 개발 일정표 + +[별도 첨부] + +### 별첨 3: 기능 명세서 + +[별도 첨부] + +주식회사 코드브릿지엑스 +이메일: contact@codebridge-x.com +전화: 02-6347-0005 +주소: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 + diff --git a/contracts/markdown/02-nda.md b/contracts/markdown/02-nda.md new file mode 100644 index 0000000..cb6ff9c --- /dev/null +++ b/contracts/markdown/02-nda.md @@ -0,0 +1,199 @@ +--- +title: "비밀유지서약서 (NDA)" +version: "v4.0" +date: "2026-02-22" +docx_file: "비밀유지서약서.docx" +--- + +# 비밀유지서약서 (NDA) + +- **작성일**: + +- **서약인 정보** +- **구분**: + +- **인적 사항:** +상호(성명): _______________ +대표자(본인): _______________ +사업자등록번호(주민등록번호): ____________________ +주소: ______________________________________________________________________ +연락처: _______________ +이메일: _______________ + +## 제1조 (목적) + + - 본 서약서는 주식회사 코드브릿지(이하 “회사”)와의 업무 관계에서 알게 된 기밀 정보를 + - 보호하기 위해 작성되었습니다. + +## 제2조 (기밀 정보의 정의) + + - 다음 각 호의 정보는 회사의 기밀 정보로 간주됩니다: + +### 2.1 고객 정보 + + - 고객사 명단 (법인명, 대표자명, 연락처) + - 고객사 담당자 정보 (성명, 부서, 연락처, 이메일) + - 계약 내역 (가입비, 할인율, 구독료, 특약 사항) + - 고객사의 사업 정보 (매출, 직원 수, 거래처 등) + - 고객사가 회사에 요구한 개발 내역 및 기획 문서 + +### 2.2 영업 정보 + + - 가격 정책 (정가, 할인 정책, 최소 가입비) + - 수수료 정책 (비율, 지급 기준, 상계 방식) + - 영업 전략 및 마케팅 계획 + - 잠재 고객 리스트 + - 계약 체결 노하우 및 제안서 템플릿 + +### 2.3 기술 정보 + + - SAM 시스템의 소스코드 + - 데이터베이스 구조 및 설계 문서 + - 개발 프로세스 및 방법론 + - 서버 인프라 구성 및 보안 정책 + - API 키, 접속 정보, 관리자 권한 + +### 2.4 경영 정보 + + - 회사의 재무 정보 (매출, 이익, 원가) + - 조직도 및 인사 정보 + - 사업 계획 및 전략 + - 투자 유치 및 M&A 관련 정보 + +### 2.5 기타 + +- 회사가 **“기밀(Confidential)”** 또는 **“대외비”**로 표시한 모든 문서 및 정보 + +## 제3조 (기밀 유지 의무) + +### 3.1 기본 의무 + + - 본인은 업무 수행 중 알게 된 모든 기밀 정보를: +- **외부에 누설하지 않습니다** +- **업무 목적 외에 사용하지 않습니다** +- **무단으로 복사, 복제, 전송하지 않습니다** +- **제3자에게 제공하거나 공개하지 않습니다** + +### 3.2 정보 관리 + + - 기밀 문서는 안전한 장소에 보관 + - 이메일, 메신저 등 전송 시 암호화 + - 업무 종료 시 모든 기밀 자료 반환 또는 파기 + - 개인 디바이스에 기밀 정보 저장 금지 + +### 3.3 제3자 접근 차단 + + - 가족, 친구 등 타인이 기밀 정보에 접근하지 못하도록 조치 + - 공공장소(카페, 도서관 등)에서 기밀 정보 취급 금지 + - 비밀번호 및 접속 정보 타인 공유 금지 + +## 제4조 (예외 사항) + + - 다음의 정보는 기밀 정보에서 제외됩니다: + - 본인이 알기 전에 이미 공개된 정보 + - 본인의 귀책사유 없이 공개된 정보 + - 제3자로부터 적법하게 취득한 정보 + - 본인이 독자적으로 개발한 정보 + - 법원, 정부기관의 법적 요구에 따라 공개해야 하는 정보 (단, 회사에 사전 통지 필수) + +## 제5조 (의무 기간) + +### 5.1 기간 + + - 본 서약서의 기밀 유지 의무는: +- **계약 체결일로부터 효력 발생** +- **계약 종료 후 2년간 유지** + +### 5.2 영구 보호 + +- 단, 다음 정보는 **영구적으로** 보호됩니다: + - 고객사 개인정보 + - 회사의 영업 비밀 (부정경쟁방지법상 영업 비밀) + - 기술 정보 (특허, 저작권 대상) + +## 제6조 (위반 시 책임) + +### 6.1 민사 책임 + + - 본인이 본 서약을 위반하여 회사 또는 고객에게 손해를 입힌 경우: +- **실손해**** 배상**: 실제 발생한 손해 전액 +- **징벌적 손해배상**: 실손해의 최대 3배 (악의적 유출 시) +- **법률 비용**: 소송 비용, 변호사 비용 등 + +### 6.2 형사 책임 + + - 다음의 경우 형사 고발 대상이 됩니다: +- **부정경쟁방지법** 위반 (영업 비밀 침해) +- **개인정보보호법** 위반 (고객 정보 유출) +- **정보통신망법** 위반 (기술 정보 침해) +- **형법** 위반 (업무상 배임) +- **※ 형사 처벌: 5년 이하 징역 또는 5천만원 이하 벌금** + +### 6.3 계약 해지 + + - 회사는 본 서약 위반 시 즉시 계약을 해지할 수 있으며, 이미 지급한 수수료 또는 + - 대금을 환수할 수 있습니다. + +## 제7조 (자료 반환) + +### 7.1 반환 대상 + + - 계약 종료 또는 요청 시 다음을 즉시 반환해야 합니다: + - 회사로부터 제공받은 모든 문서 (종이, 파일) + - 고객사 계약서 및 개인정보 + - 가격표, 제안서, 템플릿 등 영업 자료 + - USB, 하드디스크 등 저장 매체 + +### 7.2 파기 확인 + +- 반환 불가능한 파일(이메일, 클라우드 등)은 즉시 삭제하고, **삭제 확인서**를 회사에 + - 제출해야 합니다. + +## 제8조 (경업 금지) + +### 8.1 경업 금지 기간 + +- 계약 종료 후 **6개월간** 다음 행위를 금지합니다: + - 회사의 고객에게 경쟁 제품 판매 + - 회사의 기밀 정보를 이용한 유사 사업 + - 회사 직원 또는 영업파트너를 스카우트 + +### 8.2 예외 + + - 단순히 경쟁사 제품을 판매하는 것은 허용되나, 회사의 기밀 정보를 활용해서는 + - 안 됩니다. + +## 제9조 (분쟁 해결) + +### 9.1 관할 법원 + + - 본 서약과 관련된 분쟁은 회사 본사 소재지 관할 법원으로 합니다. + +### 9.2 준거법 + + - 본 서약은 대한민국 법률에 따라 해석됩니다. + +- **서약 확인** + - 본인은 위 내용을 충분히 이해하였으며, 이를 성실히 준수할 것을 서약합니다. +- **서약일**: ___________________ +- **서약인** +상호(성명): _______________ +대표자(본인): _______________ +주민등록번호(또는 사업자등록번호): _______________ +- **서명 또는 인**: _______________ + +- **수령인 (주식회사 ****코드브릿지엑스****)** + - 대표이사: 이의찬 +- **확인****일**: ___________________ +- **서명 또는 인**: _______________ + +- **참고: 관련 법률** +- **부정경쟁방지법 제2조 (영업비밀)** + - “영업비밀”이란 공공연히 알려져 있지 아니하고 독립된 경제적 가치를 가지는 것으로서, + - 비밀로 관리된 생산방법, 판매방법, 그 밖에 영업활동에 유용한 기술상 또는 경영상의 + - 정보를 말한다. +- **부정경쟁방지법 제18조 (벌칙)** +- 영업비밀을 외국에서 사용하거나 외국에서 사용되게 할 목적으로 취득·사용 또는 제3자에게 누설한 자는 **15년 이하의 징역** 또는 **15억원 이하의 벌금**에 처한다. + +- **※ 본 서약서는 2부를 작성하여 회사와 서약인이 각 1부씩 보관합니다.** +- **※ 서약 위반 시 민·형사상 책임을 질 수 있습니다.** \ No newline at end of file diff --git a/contracts/markdown/03-partner-agreement.md b/contracts/markdown/03-partner-agreement.md new file mode 100644 index 0000000..81e6af5 --- /dev/null +++ b/contracts/markdown/03-partner-agreement.md @@ -0,0 +1,276 @@ +--- +title: "영업파트너 위촉계약서" +version: "v4.0" +date: "2026-02-22" +docx_file: "영업파트너 위촉계약서.docx" +--- + +# < 영업파트너 위촉계약서 > + +# Sales Partner Engagement Agreement + + - 본 계약은 주식회사 코드브릿지엑스(이하 “회사”)와 (이하 “파트너)간에 SAM 서비스 영업 활동과 관련하여 다음과 같이 위촉계약을 체결합니다. + +## 제1조 (계약의 목적) + + - 본 계약은 회사와 파트너 간의 영업파트너 위촉 관계를 규정하고, 상호 권리와 의무를 + - 명확히 함을 목적으로 합니다. + +## 제2조 (용어의 정의) + +- **판매자**: 고객을 발굴하고 계약 체결을 주도하는 영업파트너 +- **관리자**: 판매자를 관리하고 지원하는 상급 영업파트너 +- **개발비**: 고객이 SAM 서비스 개발을 위해 지급하는 비용 +- **수수료**: 파트너가 영업 활동의 대가로 받는 보상 +- **서비스 게시**: 개발 완료 후 고객이 서비스에 접근 가능하도록 제공하는 것 + +## 제3조 (파트너의 역할 및 업무) + +### 3.1 판매자의 역할 + + - 잠재 고객 발굴 및 초기 접촉 + - SAM 서비스 소개 및 제안 + - 고객과의 계약 체결 지원 + - 계약 후 고객 관리 및 사후 지원 + +### 3.2 관리자의 역할 + + - 판매자 모집 및 관리 + - 판매자 교육 및 지원 + - 영업 전략 수립 및 실행 + - 회사와 판매자 간 소통 중재 + +### 3.3 공통 의무 + + - 회사의 브랜드 이미지 유지 + - 정확한 정보 제공 + - 윤리적 영업 활동 준수 + - 비밀 유지 의무 + +## 제4조 (수수료 정책) + +### 4.1 수수료 비율 + +| 역할 | 수수료 비율 | 산정 기준 | +| --- | --- | --- | +| 판매자 | 개발비의 20% | 1차,2차 입금액 기준 | +| 관리자 | 개발비의 5% | 1차,2차 입금액 기준 | + +### 4.2 수수료 산정 예시 + +- **총 개발비 80,000,000원 계약 시** + +| 단계 | 고객 입금 | 판매자 수수료 (20%) | 관리자 수수료 (5%) | +| --- | --- | --- | --- | +| 1차 착수금 (50%) | 40,000,000원 | 8,000,000원 | 2,000,000원 | +| 2차 잔금 (50%) | 40,000,000원 | 8,000,000원 | 2,000,000원 | +| 총계 | 80,000,000원 | 16,000,000원 | 4,000,000원 | + +- **⚠️ 중요**: 개발비만 수수료 산정 대상이며, 구독료는 수수료 대상이 아닙니다. + +### 4.3 지급 시기 + +- **지급일**: 고객 입금일 **익월 10일** +- **지급 방식**: 계좌 이체 +- **세금**: 3.3% 원천징수 (사업소득) + +### 4.4 수수료 지급 조건 + + - 고객이 개발비를 실제로 입금한 경우에만 지급 + - 계약 해지 또는 환불 시 수수료 미지급 또는 환수 + - 파트너가 계약 위반 시 수수료 지급 보류 + +## 제5조 (수수료 정책 변경) + +### 5.1 사전 고지 의무 + +- 회사는 수수료 정책을 변경할 경우 **최소 1개월 전** 서면 또는 이메일로 파트너에게 고지합니다. + - 수수료 정책을 완전히 폐지하는 경우에도 동일하게 1개월 전 고지합니다. + - 고지 기간 중 체결된 계약은 기존 수수료 정책을 적용합니다. + +### 5.2 변경 효력 + +- 변경된 수수료 정책은 고지일로부터 **1개월 후** 새로 체결되는 계약부터 적용됩니다. + - 고지 기간 만료 전에 체결된 계약은 기존 정책을 따릅니다. + - 진행 중인 계약은 최초 약정 조건을 유지합니다. + +### 5.3 변경 예시 + +#### 예시 1: 수수료율 변경 + + - 고지일: 2026년 2월 1일 + - 변경 내용: 판매자 수수료 20% → 18% + - 적용일: 2026년 3월 1일 이후 체결 계약부터 + +#### 예시 2: 수수료 정책 폐지 + + - 고지일: 2026년 2월 1일 + - 변경 내용: 수수료 정책 완전 폐지 + - 적용일: 2026년 3월 1일 이후 체결 계약부터 + +## 제6조 (계약 기간) + +- 본 계약은 계약일로부터 **1년간** 유효합니다. +- 양측이 계약 만료 **30일 전**까지 서면으로 해지 의사를 통지하지 않으면 자동으로 **1년 연장**됩니다. + - 자동 연장은 동일한 조건으로 반복됩니다. + +## 제7조 (계약 해지) + +### 7.1 일반 해지 (양방향) + +- **통지 기간**: 양측은 **30일 전** 서면 통지로 계약을 해지할 수 있습니다. +- **통지 방법**: 이메일 또는 등기우편 +- **효력 발생**: 통지일로부터 30일 후 +- **미지급 수수료**: 해지일 이전에 발생한 수수료는 정산하여 지급 +- **예시**: + - 통지일: 2026년 2월 1일 + - 해지일: 2026년 3월 1일 + - 2월 중 발생한 수수료는 3월 10일 정상 지급 + +### 7.2 즉시 해지 사유 + +- 회사는 다음의 경우 **즉시 계약을 해지**할 수 있습니다: +- **(1) 품위 유지 결격사유 발생 [신설]** + - 음주운전으로 적발된 경우 + - 형사 범죄로 기소 또는 구속된 경우 + - 사회적 물의를 일으킨 경우 + - 기타 파트너로서의 품위를 훼손한 경우 +- **(2) 계약 위반** + - 허위 정보 제공 또는 사기 행위 + - 회사 명예 훼손 또는 영업 방해 + - 비밀 유지 의무 위반 + - 중대한 업무 태만 +- **(3) 부정 행위** + - 고객으로부터 금품 수수 + - 계약서 위조 또는 변조 + - 회사 자산 횡령 또는 유용 + +### 7.3 즉시 해지 시 조치 + + - 미지급 수수료는 지급하지 않습니다. + - 이미 지급한 수수료는 환수하지 않습니다. (단, 사기 행위는 예외) + - 진행 중인 계약은 회사가 직접 관리합니다. + +## 제8조 (비밀 유지) + +### 8.1 비밀 정보 + + - 다음 정보는 비밀로 유지되어야 합니다: + - 회사의 영업 전략 및 계획 + - 고객 정보 (회사명, 담당자, 연락처 등) + - 수수료 정책 및 계약 조건 + - 기술 정보 및 노하우 + - 회사 내부 자료 + +### 8.2 비밀 유지 의무 + + - 파트너는 업무 중 알게 된 비밀 정보를 외부에 누설하지 않습니다. +- 비밀 유지 의무는 계약 종료 후에도 **3년간** 유효합니다. + - 위반 시 손해배상 책임이 있습니다. + +## 제9조 (지적재산권) + + - SAM 서비스에 대한 모든 지적재산권은 회사에 귀속됩니다. + - 파트너는 회사의 사전 서면 동의 없이 회사의 상표, 로고, 브랜드를 무단으로 사용할 수 없습니다. + - 영업 활동에 필요한 자료는 회사가 제공합니다. + +## 제10조 (세금 및 원천징수) + +### 10.1 사업소득 + +- 파트너 수수료는 **사업소득**으로 간주됩니다. + +### 10.2 원천징수 + +| 항목 | 비율 | 비고 | +| --- | --- | --- | +| 소득세 | 3.0% | | +| 지방소득세 | 0.3% | 소득세의 10% | +| 합계 | 3.3% | | + +### 10.3 지급명세서 + +- 회사는 매월 수수료를 지급한 후에 파트너에게 **지급명세서**를 발급합니다. + +## 제11조 (손해배상) + +### 11.1 파트너의 귀책 사유 + + - 파트너가 다음의 행위로 회사에 손해를 입힌 경우 배상 책임이 있습니다: + - 허위 정보 제공으로 계약 취소 + - 고객과의 분쟁으로 회사 명예 훼손 + - 비밀 유지 의무 위반 + - 부정 행위 + +### 11.2 회사의 귀책 사유 + + - 회사가 정당한 사유 없이 수수료를 지급하지 않을 경우, 연체 이자를 더하여 지급합니다. + +## 제12조 (분쟁 해결) + + - 본 계약과 관련한 분쟁은 상호 협의하여 해결합니다. +- 협의가 이루어지지 않을 경우, **서울중앙지방법원**을 관할 법원으로 합니다. + +## 제13조 (기타 사항) + +### 13.1 계약서 교부 + + - 본 계약서는 2부 작성하여 회사와 파트너가 각 1부씩 보관합니다. + +### 13.2 통지 + + - 모든 통지는 다음의 연락처로 발송됩니다: +- **회사**: +- 이메일: admin@codebridge-x.com +- 전화: 02-6347-0005 +- **파트너**: +- 이메일: +- 전화: + +### 13.3 준거법 + + - 본 계약은 대한민국 법률에 따라 해석되고 적용됩니다. + +- **계약 당사자** +- **[회사]** +- **상호**: 주식회사 코드브릿지엑스 +- **대표자**: 이의찬 (인) +- **사업자등록번호**: 664-86-03713 +- **주소**: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 +- **이메일**: admin@codebridge-x.com +- **전화**: 02-6347-0005 +- **날짜**: + +- **[파트너]** +- **상호/성명**: +- **대표자/본인**: (서명) +- **사업자등록번호**: +- **주소**: +- **이메일**: +- **전화**: +- **날짜**: + +- **별첨** + +#### 별첨 1: 수수료 정산표 + +| 계약번호 | 고객사 | 입금일 | 입금액 | 수수료율 | 수수료 | 지급일 | +| --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | + +#### 별첨 2: 영업 활동 보고서 + +| 날짜 | 활동내용 | 고객사 | 진행 상황 | +| --- | --- | --- | --- | +| | | | | + + - 첨부 서류 + - 사업자등록증 사본 (사업자인 경우) + - 주민등록등본 사본 (개인인 경우) + - 통장 사본 (수수료 입금용) + - 비밀유지서약서 + +- **주식회사 코드브릿지엑스** +- 이메일: admin@codebridge-x.com +- 전화: 02-6347-0005 +- 주소: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 diff --git a/contracts/markdown/04-partner-agreement-group.md b/contracts/markdown/04-partner-agreement-group.md new file mode 100644 index 0000000..b3251c4 --- /dev/null +++ b/contracts/markdown/04-partner-agreement-group.md @@ -0,0 +1,267 @@ +--- +title: "영업파트너 위촉계약서 (단체용)" +version: "v4.0" +date: "2026-02-22" +docx_file: "영업파트너 위촉계약서(단체용).docx" +--- + +# < 영업파트너 위촉계약서 > + +# Sales Partner Engagement Agreement + + - 본 계약은 주식회사 코드브릿지엑스(이하 “회사”)와 (이하 “파트너)간에 SAM 서비스 영업 활동과 관련하여 다음과 같이 위촉계약을 체결합니다. + +## 제1조 (계약의 목적) + + - 본 계약은 회사와 파트너 간의 영업파트너 위촉 관계를 규정하고, 상호 권리와 의무를 + - 명확히 함을 목적으로 합니다. + +## 제2조 (용어의 정의) + +- **판매자**: 고객을 발굴하고 계약 체결을 주도하는 영업파트너 +- **개발비**: 고객이 SAM 서비스 개발을 위해 지급하는 비용 +- **수수료**: 파트너가 영업 활동의 대가로 받는 보상 +- **서비스 게시**: 개발 완료 후 고객이 서비스에 접근 가능하도록 제공하는 것 + +## 제3조 (파트너의 역할 및 업무) + +### 3.1 판매자의 역할 + + - 잠재 고객 발굴 및 초기 접촉 + - SAM 서비스 소개 및 제안 + - 고객과의 계약 체결 지원 + - 계약 후 고객 관리 및 사후 지원 + +### 3.2 공통 의무 + + - 회사의 브랜드 이미지 유지 + - 정확한 정보 제공 + - 윤리적 영업 활동 준수 + - 비밀 유지 의무 + +## 제4조 (수수료 정책) + +### 4.1 수수료 비율 + +| 역할 | 수수료 비율 | 산정 기준 | +| --- | --- | --- | +| 판매자 | 개발비의 30% | 1차,2차 입금액 기준 | + +### 4.2 수수료 산정 예시 + +- **총 개발비 80,000,000원 계약 시** + +| 단계 | 고객 입금 | 판매자 수수료 (30%) | +| --- | --- | --- | +| 1차 착수금 (50%) | 40,000,000원 | 12,000,000원 | +| 2차 잔금 (50%) | 40,000,000원 | 12,000,000원 | +| 총계 | 80,000,000원 | 24,000,000원 | + +- **⚠️ 중요**: 개발비만 수수료 산정 대상이며, 구독료는 수수료 대상이 아닙니다. + +### 4.3 지급 시기 + +- **지급일**: 고객 입금일 **익월 10일** +- **지급 방식**: 계좌 이체 +- **세금**: 사업소득일 경우 3.3% 원천징수 + +### 4.4 수수료 지급 조건 + + - 고객이 개발비를 실제로 입금한 경우에만 지급 + - 계약 해지 또는 환불 시 수수료 미지급 또는 환수 + - 파트너가 계약 위반 시 수수료 지급 보류 + +## 제5조 (수수료 정책 변경) + +### 5.1 사전 고지 의무 + +- 회사는 수수료 정책을 변경할 경우 **최소 1개월 전** 서면 또는 이메일로 파트너에게 고지합니다. + - 수수료 정책을 완전히 폐지하는 경우에도 동일하게 1개월 전 고지합니다. + - 고지 기간 중 체결된 계약은 기존 수수료 정책을 적용합니다. + +### 5.2 변경 효력 + +- 변경된 수수료 정책은 고지일로부터 **1개월 후** 새로 체결되는 계약부터 적용됩니다. + - 고지 기간 만료 전에 체결된 계약은 기존 정책을 따릅니다. + - 진행 중인 계약은 최초 약정 조건을 유지합니다. + +### 5.3 변경 예시 + +#### 예시 1: 수수료율 변경 + + - 고지일: 2026년 2월 1일 + - 변경 내용: 판매자 수수료 20% → 18% + - 적용일: 2026년 3월 1일 이후 체결 계약부터 + +#### 예시 2: 수수료 정책 폐지 + + - 고지일: 2026년 2월 1일 + - 변경 내용: 수수료 정책 완전 폐지 + - 적용일: 2026년 3월 1일 이후 체결 계약부터 + +## 제6조 (계약 기간) + +- 본 계약은 계약일로부터 **1년간** 유효합니다. +- 양측이 계약 만료 **30일 전**까지 서면으로 해지 의사를 통지하지 않으면 자동으로 **1년 연장**됩니다. + - 자동 연장은 동일한 조건으로 반복됩니다. + +## 제7조 (계약 해지) + +### 7.1 일반 해지 (양방향) + +- **통지 기간**: 양측은 **30일 전** 서면 통지로 계약을 해지할 수 있습니다. +- **통지 방법**: 이메일 또는 등기우편 +- **효력 발생**: 통지일로부터 30일 후 +- **미지급 수수료**: 해지일 이전에 발생한 수수료는 정산하여 지급 +- **예시**: + - 통지일: 2026년 2월 1일 + - 해지일: 2026년 3월 1일 + - 2월 중 발생한 수수료는 3월 10일 정상 지급 + +### 7.2 즉시 해지 사유 + +- 회사는 다음의 경우 **즉시 계약을 해지**할 수 있습니다: +- **(1) 품위 유지 결격사유 발생 [신설]** + - 음주운전으로 적발된 경우 + - 형사 범죄로 기소 또는 구속된 경우 + - 사회적 물의를 일으킨 경우 + - 기타 파트너로서의 품위를 훼손한 경우 +- **(2) 계약 위반** + - 허위 정보 제공 또는 사기 행위 + - 회사 명예 훼손 또는 영업 방해 + - 비밀 유지 의무 위반 + - 중대한 업무 태만 +- **(3) 부정 행위** + - 고객으로부터 금품 수수 + - 계약서 위조 또는 변조 + - 회사 자산 횡령 또는 유용 + +### 7.3 즉시 해지 시 조치 + + - 미지급 수수료는 지급하지 않습니다. + - 이미 지급한 수수료는 환수하지 않습니다. (단, 사기 행위는 예외) + - 진행 중인 계약은 회사가 직접 관리합니다. + +## 제8조 (비밀 유지) + +### 8.1 비밀 정보 + + - 다음 정보는 비밀로 유지되어야 합니다: + - 회사의 영업 전략 및 계획 + - 고객 정보 (회사명, 담당자, 연락처 등) + - 수수료 정책 및 계약 조건 + - 기술 정보 및 노하우 + - 회사 내부 자료 + +### 8.2 비밀 유지 의무 + + - 파트너는 업무 중 알게 된 비밀 정보를 외부에 누설하지 않습니다. +- 비밀 유지 의무는 계약 종료 후에도 **3년간** 유효합니다. + - 위반 시 손해배상 책임이 있습니다. + +## 제9조 (지적재산권) + + - SAM 서비스에 대한 모든 지적재산권은 회사에 귀속됩니다. + - 파트너는 회사의 사전 서면 동의 없이 회사의 상표, 로고, 브랜드를 무단으로 사용할 수 없습니다. + - 영업 활동에 필요한 자료는 회사가 제공합니다. + +## 제10조 (세금 및 원천징수) + +### 10.1 사업소득 + +- 파트너 수수료는 **사업소득**으로 간주됩니다. + +### 10.2 원천징수 + +| 항목 | 비율 | 비고 | +| --- | --- | --- | +| 소득세 | 3.0% | | +| 지방소득세 | 0.3% | 소득세의 10% | +| 합계 | 3.3% | | + +### 10.3 지급명세서 + +- 회사는 매월 수수료를 지급한 후에 파트너에게 **지급명세서**를 발급합니다. + +## 제11조 (손해배상) + +### 11.1 파트너의 귀책 사유 + + - 파트너가 다음의 행위로 회사에 손해를 입힌 경우 배상 책임이 있습니다: + - 허위 정보 제공으로 계약 취소 + - 고객과의 분쟁으로 회사 명예 훼손 + - 비밀 유지 의무 위반 + - 부정 행위 + +### 11.2 회사의 귀책 사유 + + - 회사가 정당한 사유 없이 수수료를 지급하지 않을 경우, 연체 이자를 더하여 지급합니다. + +## 제12조 (분쟁 해결) + + - 본 계약과 관련한 분쟁은 상호 협의하여 해결합니다. +- 협의가 이루어지지 않을 경우, **서울중앙지방법원**을 관할 법원으로 합니다. + +## 제13조 (기타 사항) + +### 13.1 계약서 교부 + + - 본 계약서는 2부 작성하여 회사와 파트너가 각 1부씩 보관합니다. + +### 13.2 통지 + + - 모든 통지는 다음의 연락처로 발송됩니다: +- **회사**: +- 이메일: admin@codebridge-x.com +- 전화: 02-6347-0005 +- **파트너**: +- 이메일: +- 전화: + +### 13.3 준거법 + + - 본 계약은 대한민국 법률에 따라 해석되고 적용됩니다. + +- **계약 당사자** +- **[회사]** +- **상호**: 주식회사 코드브릿지엑스 +- **대표자**: 이의찬 (인) +- **사업자등록번호**: 664-86-03713 +- **주소**: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 +- **이메일**: admin@codebridge-x.com +- **전화**: 02-6347-0005 +- **날짜**: + +- **[파트너]** +- **상호/성명**: +- **대표자/본인**: (서명) +- **사업자등록번호**: +- **주소**: +- **이메일**: +- **전화**: +- **날짜**: + +- **별첨** + +#### 별첨 1: 수수료 정산표 + +| 계약번호 | 고객사 | 입금일 | 입금액 | 수수료율 | 수수료 | 지급일 | +| --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | + +#### 별첨 2: 영업 활동 보고서 + +| 날짜 | 활동내용 | 고객사 | 진행 상황 | +| --- | --- | --- | --- | +| | | | | + + - 첨부 서류 + - 사업자등록증 사본 (사업자인 경우) + - 주민등록등본 사본 (개인인 경우) + - 통장 사본 (수수료 입금용) + - 비밀유지서약서 + +- **주식회사 코드브릿지엑스** +- 이메일: admin@codebridge-x.com +- 전화: 02-6347-0005 +- 주소: 서울특별시 강서구 양천로 583, 우림블루나인 B동 1602호 diff --git a/contracts/revisions.json b/contracts/revisions.json new file mode 100644 index 0000000..1bcd843 --- /dev/null +++ b/contracts/revisions.json @@ -0,0 +1,58 @@ +{ + "documents": { + "01-service-agreement": { + "title": "고객사 서비스 이용계약서", + "docx_file": "01_고객_서비스이용계약서_v4_0_전자서명용.docx", + "revisions": [ + { + "version": "v4.0", + "date": "2026-02-22", + "author": "개발팀", + "description": "버전 관리 시스템 도입, 개정이력 추적 시작" + }, + { + "version": "v4.1", + "date": "2026-02-22", + "author": "개발팀", + "description": "제4조에 사용량 기반 추가 과금(4.5) 및 바로빌 부가 서비스 요금(4.6) 조항 추가" + } + ] + }, + "02-nda": { + "title": "비밀유지서약서 (NDA)", + "docx_file": "비밀유지서약서.docx", + "revisions": [ + { + "version": "v4.0", + "date": "2026-02-22", + "author": "개발팀", + "description": "버전 관리 시스템 도입, 개정이력 추적 시작" + } + ] + }, + "03-partner-agreement": { + "title": "영업파트너 위촉계약서", + "docx_file": "영업파트너 위촉계약서.docx", + "revisions": [ + { + "version": "v4.0", + "date": "2026-02-22", + "author": "개발팀", + "description": "버전 관리 시스템 도입, 개정이력 추적 시작" + } + ] + }, + "04-partner-agreement-group": { + "title": "영업파트너 위촉계약서 (단체용)", + "docx_file": "영업파트너 위촉계약서(단체용).docx", + "revisions": [ + { + "version": "v4.0", + "date": "2026-02-22", + "author": "개발팀", + "description": "버전 관리 시스템 도입, 개정이력 추적 시작" + } + ] + } + } +} diff --git a/contracts/scripts/extract_to_markdown.py b/contracts/scripts/extract_to_markdown.py new file mode 100644 index 0000000..ea44889 --- /dev/null +++ b/contracts/scripts/extract_to_markdown.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +""" +DOCX → Markdown 추출 스크립트 + +4개 전자계약 DOCX 파일을 Markdown으로 변환한다. +- 서비스이용계약서: Heading 스타일 기반 매핑 +- 나머지 3개: Bold 런 + 패턴 매칭으로 구조 유추 +""" + +import re +import sys +from datetime import date +from pathlib import Path + +from docx import Document + +# 경로 설정 +BASE_DIR = Path(__file__).resolve().parent.parent +DOCX_DIR = BASE_DIR / "docx" +MD_DIR = BASE_DIR / "markdown" + +# DOCX → Markdown 매핑 +FILE_MAP = { + "01_고객_서비스이용계약서_v4_0_전자서명용.docx": { + "output": "01-service-agreement.md", + "title": "고객사 서비스 이용계약서", + "type": "styled", + }, + "비밀유지서약서.docx": { + "output": "02-nda.md", + "title": "비밀유지서약서 (NDA)", + "type": "pattern", + }, + "영업파트너 위촉계약서.docx": { + "output": "03-partner-agreement.md", + "title": "영업파트너 위촉계약서", + "type": "pattern", + }, + "영업파트너 위촉계약서(단체용).docx": { + "output": "04-partner-agreement-group.md", + "title": "영업파트너 위촉계약서 (단체용)", + "type": "pattern", + }, +} + + +def table_to_markdown(table): + """DOCX 테이블을 Markdown 테이블로 변환""" + rows = [] + for row in table.rows: + cells = [cell.text.strip().replace("\n", " ") for cell in row.cells] + rows.append(cells) + + if not rows: + return "" + + lines = [] + # 헤더 + lines.append("| " + " | ".join(rows[0]) + " |") + lines.append("| " + " | ".join(["---"] * len(rows[0])) + " |") + # 본문 + for row in rows[1:]: + # 셀 개수 맞추기 + while len(row) < len(rows[0]): + row.append("") + lines.append("| " + " | ".join(row[: len(rows[0])]) + " |") + + return "\n".join(lines) + + +def get_paragraph_heading_level_styled(para): + """스타일 기반 문서의 헤딩 레벨 판별 (서비스이용계약서)""" + style = para.style.name if para.style else "" + + if style == "Heading 1": + return 1 + elif style == "Heading 2": + return 2 + elif style == "Heading 3": + return 3 + + return 0 + + +def get_paragraph_heading_level_pattern(para): + """패턴 매칭 기반 문서의 헤딩 레벨 판별 (비밀유지서약서, 영업파트너 위촉계약서)""" + text = para.text.strip() + has_bold = any(r.bold for r in para.runs if r.bold) + + if not text or not has_bold: + return 0 + + # "제X조" 패턴 → ## (h2) + if re.match(r"^ 0: + lines.append("") + lines.append(f"{'#' * level} {text}") + lines.append("") + elif style == "Compact": + # Bold 런이 있으면 강조 리스트 + has_bold = any(r.bold for r in para.runs if r.bold) + if has_bold: + # Bold 부분과 일반 부분 분리 + parts = [] + for run in para.runs: + if run.bold: + parts.append(f"**{run.text}**") + else: + parts.append(run.text) + combined = "".join(parts) + lines.append(f"- {combined}") + else: + # 들여쓰기된 하위 항목 + lines.append(f" - {text}") + elif style in ("Body Text", "First Paragraph"): + # 본문 텍스트 + if text.startswith("⚠️") or text.startswith("✅") or text.startswith("❌"): + lines.append("") + lines.append(f"> {text}") + lines.append("") + else: + lines.append(text) + else: + lines.append(text) + + elif tag == "tbl": + if table_idx <= len(doc.tables): + current_table_idx = sum( + 1 + for c in list(body)[: list(body).index(child)] + if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl" + ) + if current_table_idx < len(doc.tables): + lines.append("") + lines.append(table_to_markdown(doc.tables[current_table_idx])) + lines.append("") + + return "\n".join(lines) + + +def extract_pattern_doc(doc, file_info): + """패턴 매칭 기반 문서 추출 (비밀유지서약서, 영업파트너 위촉계약서)""" + lines = [] + + body = doc.element.body + para_idx = 0 + + for child in body: + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag + + if tag == "p": + para = doc.paragraphs[para_idx] + para_idx += 1 + text = para.text.strip() + + if not text: + lines.append("") + continue + + level = get_paragraph_heading_level_pattern(para) + has_bold = any(r.bold for r in para.runs if r.bold) + + if level > 0: + lines.append("") + # 제목에서 < > 제거 + clean_text = re.sub(r"^<\s*|\s*>$", "", text).strip() + lines.append(f"{'#' * level} {clean_text}") + lines.append("") + elif has_bold: + # Bold 텍스트는 강조 처리 + parts = [] + for run in para.runs: + if run.bold: + parts.append(f"**{run.text}**") + else: + parts.append(run.text) + combined = "".join(parts) + + # (1), (2) 같은 번호 패턴 + if re.match(r"^\*\*\(\d+\)", combined): + lines.append(f"- {combined}") + # "예시 N:", "Phase N:" 같은 패턴 + elif re.match(r"^\*\*(예시|Phase|별첨)\s", combined): + lines.append("") + lines.append(f"#### {text}") + lines.append("") + else: + lines.append(f"- {combined}") + else: + # 일반 텍스트 + # 빈칸 양식 (___) 유지 + if "___" in text: + lines.append(text) + elif re.match(r"^(이메일|전화|주소|상호|대표|사업자|주민|연락처|날짜):", text): + lines.append(f"- {text}") + else: + lines.append(f" - {text}") + + elif tag == "tbl": + current_table_idx = sum( + 1 + for c in list(body)[: list(body).index(child)] + if (c.tag.split("}")[-1] if "}" in c.tag else c.tag) == "tbl" + ) + if current_table_idx < len(doc.tables): + lines.append("") + lines.append(table_to_markdown(doc.tables[current_table_idx])) + lines.append("") + + return "\n".join(lines) + + +def add_frontmatter(content, file_info, docx_name): + """YAML 프론트매터 추가""" + frontmatter = f"""--- +title: "{file_info['title']}" +version: "v4.0" +date: "{date.today().isoformat()}" +docx_file: "{docx_name}" +--- +""" + return frontmatter + "\n" + content + + +def extract_file(docx_name, file_info): + """단일 DOCX 파일 추출""" + docx_path = DOCX_DIR / docx_name + if not docx_path.exists(): + print(f" [SKIP] {docx_name} - 파일 없음") + return False + + doc = Document(str(docx_path)) + + if file_info["type"] == "styled": + content = extract_styled_doc(doc, file_info) + else: + content = extract_pattern_doc(doc, file_info) + + # 프론트매터 추가 + content = add_frontmatter(content, file_info, docx_name) + + # 연속 빈 줄 정리 (3줄 이상 → 2줄로) + content = re.sub(r"\n{3,}", "\n\n", content) + + # 파일 저장 + output_path = MD_DIR / file_info["output"] + output_path.write_text(content, encoding="utf-8") + print(f" [OK] {docx_name} → {file_info['output']}") + return True + + +def main(): + print("DOCX → Markdown 추출 시작") + print(f" DOCX 디렉토리: {DOCX_DIR}") + print(f" 출력 디렉토리: {MD_DIR}") + print() + + MD_DIR.mkdir(parents=True, exist_ok=True) + + success = 0 + for docx_name, file_info in FILE_MAP.items(): + if extract_file(docx_name, file_info): + success += 1 + + print(f"\n완료: {success}/{len(FILE_MAP)} 파일 변환됨") + return 0 if success == len(FILE_MAP) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contracts/scripts/sync_check.py b/contracts/scripts/sync_check.py new file mode 100644 index 0000000..09d55d9 --- /dev/null +++ b/contracts/scripts/sync_check.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +DOCX ↔ Markdown 동기화 검증 스크립트 + +DOCX에서 텍스트를 추출하고 Markdown 파일의 텍스트와 비교하여 +불일치 항목을 리포트한다. +""" + +import difflib +import re +import sys +from pathlib import Path + +from docx import Document + +BASE_DIR = Path(__file__).resolve().parent.parent +DOCX_DIR = BASE_DIR / "docx" +MD_DIR = BASE_DIR / "markdown" + +# DOCX → Markdown 파일 매핑 +FILE_MAP = { + "01_고객_서비스이용계약서_v4_0_전자서명용.docx": "01-service-agreement.md", + "비밀유지서약서.docx": "02-nda.md", + "영업파트너 위촉계약서.docx": "03-partner-agreement.md", + "영업파트너 위촉계약서(단체용).docx": "04-partner-agreement-group.md", +} + + +def extract_text_from_docx(docx_path): + """DOCX에서 순수 텍스트만 추출 (개정이력 테이블 제외, 인터리빙 방식)""" + doc = Document(str(docx_path)) + lines = [] + + from docx.oxml.ns import qn as _qn + + body = doc.element.body + para_idx = 0 + table_idx = 0 + skip_revision = False + + for child in body: + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag + + if tag == "p": + if para_idx < len(doc.paragraphs): + text = doc.paragraphs[para_idx].text.strip() + para_idx += 1 + + if "개정이력" in text: + skip_revision = True + continue + if text: + skip_revision = False + lines.append(text) + + elif tag == "tbl": + if table_idx < len(doc.tables): + table = doc.tables[table_idx] + table_idx += 1 + + # 개정이력 테이블 건너뛰기 + if len(table.rows) > 0: + first_row_text = [cell.text.strip() for cell in table.rows[0].cells] + if "버전" in first_row_text and "날짜" in first_row_text: + skip_revision = False + continue + + if skip_revision: + skip_revision = False + continue + + for row in table.rows: + cells = [cell.text.strip() for cell in row.cells] + # 빈 셀만 있는 행 건너뛰기 + if not any(cells): + continue + row_text = " | ".join(cells) + if row_text.strip(): + lines.append(row_text) + + return lines + + +def extract_text_from_markdown(md_path): + """Markdown에서 순수 텍스트만 추출 (프론트매터, 마크업 제거)""" + content = md_path.read_text(encoding="utf-8") + lines = [] + + in_frontmatter = False + in_table = False + + for line in content.split("\n"): + stripped = line.strip() + + # YAML 프론트매터 건너뛰기 + if stripped == "---": + in_frontmatter = not in_frontmatter + continue + if in_frontmatter: + continue + + # 빈 줄 건너뛰기 + if not stripped: + in_table = False + continue + + # Markdown 마크업 제거 + text = stripped + + # 헤딩 마크업 제거 + text = re.sub(r"^#{1,6}\s+", "", text) + + # 리스트 마크업 제거 + text = re.sub(r"^\s*[-*+]\s+", "", text) + + # Bold/Italic 마크업 제거 + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + + # 블록인용 제거 + text = re.sub(r"^>\s*", "", text) + + # 테이블 구분선 건너뛰기 + if re.match(r"^\|[\s\-|]+\|$", text): + continue + + # 테이블 행 + if text.startswith("|") and text.endswith("|"): + # 파이프 제거하고 셀 텍스트 추출 + cells = [c.strip() for c in text.strip("|").split("|")] + text = " | ".join(cells) + + text = text.strip() + if text: + lines.append(text) + + return lines + + +def normalize_text(text): + """비교를 위한 텍스트 정규화""" + # 공백 정규화 + text = re.sub(r"\s+", " ", text).strip() + # 특수문자 정규화 + text = text.replace("\u00a0", " ") # non-breaking space + text = text.replace("\u3000", " ") # ideographic space + # 언더스코어 빈칸 정규화 + text = re.sub(r"_{3,}", "___", text) + # Bold 마크업(**) 제거 (DOCX 텍스트에 리터럴 ** 포함되는 경우) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + # 선행 리스트 마커 제거 (DOCX 텍스트가 "- "로 시작하는 경우) + text = re.sub(r"^-\s+", "", text) + return text + + +def compare_documents(docx_name, md_name): + """두 문서의 텍스트를 비교""" + docx_path = DOCX_DIR / docx_name + md_path = MD_DIR / md_name + + if not docx_path.exists(): + return {"status": "error", "message": f"DOCX 파일 없음: {docx_name}"} + if not md_path.exists(): + return {"status": "error", "message": f"Markdown 파일 없음: {md_name}"} + + docx_lines = [normalize_text(l) for l in extract_text_from_docx(docx_path) if l.strip()] + md_lines = [normalize_text(l) for l in extract_text_from_markdown(md_path) if l.strip()] + + # difflib로 비교 + matcher = difflib.SequenceMatcher(None, docx_lines, md_lines) + ratio = matcher.ratio() + + # 차이점 추출 + diffs = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + elif tag == "replace": + for idx in range(max(i2 - i1, j2 - j1)): + docx_text = docx_lines[i1 + idx] if i1 + idx < i2 else "(없음)" + md_text = md_lines[j1 + idx] if j1 + idx < j2 else "(없음)" + diffs.append({ + "type": "변경", + "docx": docx_text[:80], + "markdown": md_text[:80], + }) + elif tag == "delete": + for idx in range(i1, i2): + diffs.append({ + "type": "DOCX에만 존재", + "docx": docx_lines[idx][:80], + "markdown": "-", + }) + elif tag == "insert": + for idx in range(j1, j2): + diffs.append({ + "type": "Markdown에만 존재", + "docx": "-", + "markdown": md_lines[idx][:80], + }) + + return { + "status": "ok", + "similarity": round(ratio * 100, 1), + "docx_lines": len(docx_lines), + "md_lines": len(md_lines), + "diff_count": len(diffs), + "diffs": diffs[:20], # 상위 20개만 + } + + +def main(): + print("=" * 70) + print("DOCX ↔ Markdown 동기화 검증") + print("=" * 70) + + all_ok = True + + for docx_name, md_name in FILE_MAP.items(): + print(f"\n{'─' * 50}") + print(f"문서: {docx_name}") + print(f" ↔ {md_name}") + print(f"{'─' * 50}") + + result = compare_documents(docx_name, md_name) + + if result["status"] == "error": + print(f" [ERROR] {result['message']}") + all_ok = False + continue + + similarity = result["similarity"] + status_icon = "OK" if similarity >= 80 else "WARN" if similarity >= 60 else "FAIL" + + print(f" 유사도: {similarity}% [{status_icon}]") + print(f" DOCX 라인: {result['docx_lines']}") + print(f" Markdown 라인: {result['md_lines']}") + print(f" 차이점: {result['diff_count']}개") + + if result["diffs"]: + print(f"\n 주요 차이점 (상위 {min(len(result['diffs']), 10)}개):") + for i, diff in enumerate(result["diffs"][:10]): + print(f" [{diff['type']}]") + if diff["docx"] != "-": + print(f" DOCX: {diff['docx']}") + if diff["markdown"] != "-": + print(f" MD: {diff['markdown']}") + + if similarity < 80: + all_ok = False + + print(f"\n{'=' * 70}") + if all_ok: + print("결과: 모든 문서 동기화 상태 양호") + else: + print("결과: 일부 문서에서 불일치 발견 - 확인 필요") + print(f"{'=' * 70}") + + return 0 if all_ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/interview-master-questions.sql b/data/interview-master-questions.sql new file mode 100644 index 0000000..58b4899 --- /dev/null +++ b/data/interview-master-questions.sql @@ -0,0 +1,279 @@ +-- ============================================================ +-- 인터뷰 질문 마스터 데이터 SQL +-- 8개 도메인 × 16개 템플릿 × 80개 질문 +-- +-- 실행 방법: +-- 로컬: docker exec -i sam-mysql-1 mysql -u root -p samdb < docs/data/interview-master-questions.sql +-- 개발서버: mysql -u -p samdb < interview-master-questions.sql +-- phpMyAdmin: SQL 탭에서 전체 복사 후 실행 +-- +-- 주의: 한 번만 실행할 것. 중복 실행 시 데이터가 중복됨. +-- ============================================================ + +SET NAMES utf8mb4; +SET @tenant_id = 1; +SET @user_id = 1; +SET @now = NOW(); + +-- ============================================================ +-- 대분류: 제조업-방화셔터 (parent_id=null, 루트 카테고리) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, NULL, '제조업-방화셔터', '방화셔터 제조업 인터뷰', NULL, 1, 1, @user_id, @user_id, @now, @now); +SET @root_manufacturing = LAST_INSERT_ID(); + +-- ============================================================ +-- Domain 1: 제품 분류 체계 (product_classification) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '제품 분류 체계', '제품 카테고리, 모델 코드, 분류 기준 파악', 'product_classification', 3, 1, @user_id, @user_id, @now, @now); +SET @cat_1 = LAST_INSERT_ID(); + +-- 템플릿 1.1: 제품 카테고리 구조 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_1, '제품 카테고리 구조', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_1_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_1_1, '귀사의 주요 제품군을 모두 나열해주세요', 'text', NULL, '쉼표 구분으로 제품군 나열', NULL, NULL, 'product_classification', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '각 제품군의 하위 모델명과 코드 체계를 알려주세요', 'table_input', '{"columns":["모델코드","모델명","비고"]}', '코드-이름 매핑 테이블', NULL, NULL, 'product_classification', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '제품을 분류하는 기준은 무엇인가요? (소재, 용도, 크기 등)', 'multi_select', '{"choices":["소재별","용도별","크기별","설치방식별","인증여부별"]}', NULL, NULL, NULL, 'product_classification', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '인증(인정) 제품과 비인증 제품의 구분이 있나요?', 'select', '{"choices":["있음","없음"]}', NULL, NULL, NULL, 'product_classification', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '인증 제품의 경우 구성이 고정되나요?', 'checkbox', NULL, NULL, NULL, '{"question_index":3,"value":"있음"}', 'product_classification', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '카테고리별 제품 수는 대략 몇 개인가요?', 'number', NULL, NULL, '개', NULL, 'product_classification', 0, 6, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '제품 코드 명명 규칙을 설명해주세요 (예: KSS01의 의미)', 'text', NULL, '코드 체계의 각 자릿수 의미', NULL, NULL, 'product_classification', 0, 7, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_1, '기존 시스템(ERP/엑셀)에서 사용하는 제품 분류 방식을 캡처하여 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'product_classification', 0, 8, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 1.2: 설치 유형별 분류 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_1, '설치 유형별 분류', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_1_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_1_2, '설치 유형(벽면형, 측면형, 혼합형 등)에 따라 견적이 달라지나요?', 'select', '{"choices":["예, 크게 달라짐","약간 달라짐","달라지지 않음"]}', NULL, NULL, NULL, 'product_classification', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_2, '각 설치 유형별로 어떤 부품이 달라지나요?', 'table_input', '{"columns":["설치유형","추가부품","제외부품","비고"]}', NULL, NULL, NULL, 'product_classification', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_1_2, '설치 유형에 따른 추가 비용 항목이 있나요?', 'text', NULL, NULL, NULL, NULL, 'product_classification', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 2: BOM 구조 (bom_structure) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, 'BOM 구조', '완제품-부품 관계, 부품 카테고리, BOM 레벨', 'bom_structure', 4, 1, @user_id, @user_id, @now, @now); +SET @cat_2 = LAST_INSERT_ID(); + +-- 템플릿 2.1: 완제품-부품 관계 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_2, '완제품-부품 관계', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_2_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_2_1, '대표 제품 1개의 완제품→부품 구성을 트리로 그려주세요', 'bom_tree', NULL, '최상위 제품부터 하위 부품까지 트리 구조', NULL, NULL, 'bom_structure', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_1, '모든 제품에 공통으로 들어가는 부품은 무엇인가요?', 'multi_select', '{"choices":["가이드레일","케이스","모터","제어기","브라켓","볼트/너트"]}', '직접 입력 가능', NULL, NULL, 'bom_structure', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_1, '제품별로 선택적(옵션)인 부품은 무엇인가요?', 'table_input', '{"columns":["제품명","옵션부품","적용조건"]}', NULL, NULL, NULL, 'bom_structure', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_1, 'BOM이 현재 엑셀로 관리되고 있나요? 파일을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'bom_structure', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_1, '하위 부품의 단계(레벨)는 최대 몇 단계인가요?', 'number', NULL, NULL, '단계', NULL, 'bom_structure', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_1, '부품 수량이 고정인 것과 계산이 필요한 것을 구분해주세요', 'table_input', '{"columns":["부품명","고정/계산","고정수량 또는 계산식"]}', NULL, NULL, NULL, 'bom_structure', 0, 6, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 2.2: 부품 카테고리 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_2, '부품 카테고리', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_2_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_2_2, '부품을 카테고리로 분류하면 어떻게 나눠지나요? (본체, 절곡품, 전동부, 부자재 등)', 'text', NULL, '부품 분류 체계', NULL, NULL, 'bom_structure', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_2, '각 카테고리에 속하는 부품 목록을 정리해주세요', 'table_input', '{"columns":["카테고리","부품명","규격"]}', NULL, NULL, NULL, 'bom_structure', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_2, '외주 구매 부품과 자체 제작 부품의 구분이 있나요?', 'select', '{"choices":["있음","없음"]}', NULL, NULL, NULL, 'bom_structure', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_2_2, '부자재(볼트, 너트, 패킹 등)는 별도 관리하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'bom_structure', 0, 4, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 3: 치수/변수 계산 (dimension_formula) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '치수/변수 계산', '오픈 사이즈→제작 사이즈 변환, 파생 변수 계산', 'dimension_formula', 5, 1, @user_id, @user_id, @now, @now); +SET @cat_3 = LAST_INSERT_ID(); + +-- 템플릿 3.1: 오픈 사이즈 → 제작 사이즈 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_3, '오픈 사이즈 → 제작 사이즈', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_3_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_3_1, '고객이 입력하는 기본 치수 항목은 무엇인가요? (폭, 높이, 깊이 등)', 'multi_select', '{"choices":["폭(W)","높이(H)","깊이(D)","두께(T)","지름(Ø)"]}', NULL, NULL, NULL, 'dimension_formula', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '오픈 사이즈에서 제작 사이즈로 변환할 때 더하는 마진값은?', 'formula_input', NULL, '예: W1 = W0 + 120, H1 = H0 + 50', 'mm', NULL, 'dimension_formula', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '제품 카테고리별로 마진값이 다른가요?', 'table_input', '{"columns":["제품카테고리","W 마진(mm)","H 마진(mm)","비고"]}', NULL, NULL, NULL, 'dimension_formula', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '면적(㎡) 계산 공식을 알려주세요', 'formula_input', NULL, '예: area = W1 * H1 / 1000000', '㎡', NULL, 'dimension_formula', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '중량(kg) 계산 공식을 알려주세요', 'formula_input', NULL, '예: weight = area * 단위중량(kg/㎡)', 'kg', NULL, 'dimension_formula', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '기타 파생 변수가 있나요? (예: 분할 개수, 절곡 길이 등)', 'table_input', '{"columns":["변수명","계산식","단위","비고"]}', NULL, NULL, NULL, 'dimension_formula', 0, 6, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_1, '치수 계산에 사용하는 엑셀 수식을 캡처해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'dimension_formula', 0, 7, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 3.2: 변수 의존 관계 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_3, '변수 의존 관계', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_3_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_3_2, '변수 간 의존 관계를 설명해주세요 (A는 B와 C로 계산)', 'text', NULL, '계산 순서와 변수 의존성', NULL, NULL, 'dimension_formula', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_2, '계산 순서가 중요한 변수가 있나요?', 'text', NULL, NULL, NULL, NULL, 'dimension_formula', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_3_2, '단위는 mm, m, kg 중 어떤 것을 기본으로 사용하나요?', 'select', '{"choices":["mm","m","cm","혼용"]}', NULL, NULL, NULL, 'dimension_formula', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 4: 부품 구성 상세 (component_config) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '부품 구성 상세', '주요 부품별 규격, 선택 기준, 특수 구성', 'component_config', 6, 1, @user_id, @user_id, @now, @now); +SET @cat_4 = LAST_INSERT_ID(); + +-- 템플릿 4.1: 주요 부품별 상세 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_4, '주요 부품별 상세', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_4_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_4_1, '가이드레일의 표준 길이 규격은? (예: 1219, 2438, 3305mm)', 'table_input', '{"columns":["규격코드","길이(mm)","비고"]}', NULL, NULL, NULL, 'component_config', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '가이드레일 길이 조합 규칙은? (어떤 길이를 몇 개 사용?)', 'text', NULL, '높이에 따른 가이드레일 조합 로직', NULL, NULL, 'component_config', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '케이스(하우징) 크기별 규격과 부속품 차이를 설명해주세요', 'table_input', '{"columns":["케이스규격","적용조건","부속품"]}', NULL, NULL, NULL, 'component_config', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '모터 용량 종류와 선택 기준은? (무게별? 면적별?)', 'table_input', '{"columns":["모터용량","적용범위(최소)","적용범위(최대)","단위"]}', '무게/면적 범위별 모터 매핑', NULL, NULL, 'component_config', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '모터 전압 옵션은? (380V, 220V 등)', 'multi_select', '{"choices":["380V","220V","110V","DC 24V"]}', NULL, NULL, NULL, 'component_config', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '제어기 종류와 선택 기준은? (노출형/매립형 등)', 'table_input', '{"columns":["제어기유형","적용조건","비고"]}', NULL, NULL, NULL, 'component_config', 0, 6, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '절곡품(판재 가공) 목록과 각각의 치수 결정 방식은?', 'table_input', '{"columns":["절곡품명","치수결정방식","재질","두께(mm)"]}', NULL, NULL, NULL, 'component_config', 0, 7, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_1, '부자재(볼트, 너트, 패킹 등) 목록과 수량 결정 방식은?', 'table_input', '{"columns":["부자재명","규격","수량결정방식","기본수량"]}', NULL, NULL, NULL, 'component_config', 0, 8, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 4.2: 특수 구성 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_4, '특수 구성', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_4_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_4_2, '연기차단재 등 특수 부품이 있나요? 적용 조건은?', 'text', NULL, NULL, NULL, NULL, 'component_config', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_2, '보강재(샤프트, 파이프, 앵글 등) 사용 조건은?', 'table_input', '{"columns":["보강재명","규격","적용조건","수량"]}', NULL, NULL, NULL, 'component_config', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_4_2, '고객 요청에 따라 추가/제외되는 옵션 부품은?', 'table_input', '{"columns":["옵션부품","추가/제외","추가비용","비고"]}', NULL, NULL, NULL, 'component_config', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 5: 단가 체계 (pricing_structure) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '단가 체계', '단가 관리 방식, 계산 방식, 마진/LOSS율', 'pricing_structure', 7, 1, @user_id, @user_id, @now, @now); +SET @cat_5 = LAST_INSERT_ID(); + +-- 템플릿 5.1: 단가 관리 방식 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_5, '단가 관리 방식', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_5_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_5_1, '부품별 단가를 어디서 관리하나요? (엑셀, ERP, 구두 등)', 'select', '{"choices":["엑셀","ERP 시스템","구두/경험","기타"]}', NULL, NULL, NULL, 'pricing_structure', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, '단가표 파일을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'pricing_structure', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, '단가 변경 주기는? (월/분기/연 등)', 'select', '{"choices":["수시","월 단위","분기 단위","반기 단위","연 단위"]}', NULL, NULL, NULL, 'pricing_structure', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, '단가에 포함되는 항목은? (재료비만? 가공비 포함?)', 'multi_select', '{"choices":["재료비","가공비","운송비","설치비","마진"]}', NULL, NULL, NULL, 'pricing_structure', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, '고객별/거래처별 차등 단가가 있나요?', 'select', '{"choices":["있음 (등급별)","있음 (거래처별)","없음 (일괄 동일)"]}', NULL, NULL, NULL, 'pricing_structure', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, 'LOSS율(손실률)을 적용하나요? 적용 방식은?', 'formula_input', NULL, '예: 실제수량 = 계산수량 × (1 + LOSS율)', '%', NULL, 'pricing_structure', 0, 6, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_1, '마진율 설정 방식은? (일괄? 품목별?)', 'select', '{"choices":["일괄 마진율","품목별 마진율","카테고리별 마진율","고객별 마진율"]}', NULL, NULL, NULL, 'pricing_structure', 0, 7, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 5.2: 단가 계산 방식 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_5, '단가 계산 방식', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_5_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_5_2, '면적 기반 단가 품목은? (원/㎡)', 'table_input', '{"columns":["품목명","단가(원/㎡)","비고"]}', NULL, '원/㎡', NULL, 'pricing_structure', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_2, '중량 기반 단가 품목은? (원/kg)', 'table_input', '{"columns":["품목명","단가(원/kg)","비고"]}', NULL, '원/kg', NULL, 'pricing_structure', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_2, '수량 기반 단가 품목은? (원/EA)', 'table_input', '{"columns":["품목명","단가(원/EA)","비고"]}', NULL, '원/EA', NULL, 'pricing_structure', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_2, '길이 기반 단가 품목은? (원/m)', 'table_input', '{"columns":["품목명","단가(원/m)","비고"]}', NULL, '원/m', NULL, 'pricing_structure', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_5_2, '기타 특수 단가 계산 방식이 있나요?', 'text', NULL, NULL, NULL, NULL, 'pricing_structure', 0, 5, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 6: 수량 수식 (quantity_formula) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '수량 수식', '부품별 수량 결정 규칙, 계산식, 검증', 'quantity_formula', 8, 1, @user_id, @user_id, @now, @now); +SET @cat_6 = LAST_INSERT_ID(); + +-- 템플릿 6.1: 수량 결정 규칙 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_6, '수량 결정 규칙', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_6_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_6_1, '고정 수량 부품 목록 (항상 1개, 2개 등)', 'table_input', '{"columns":["부품명","고정수량","비고"]}', NULL, NULL, NULL, 'quantity_formula', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_1, '치수 기반 수량 계산 부품과 수식', 'formula_input', NULL, '예: 슬랫수량 = CEIL(H1 / 슬랫피치)', NULL, NULL, 'quantity_formula', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_1, '면적 기반 수량 계산 부품과 수식', 'formula_input', NULL, '예: 스크린수량 = area / 기준면적', NULL, NULL, 'quantity_formula', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_1, '중량 기반 수량 계산 부품과 수식', 'formula_input', NULL, NULL, NULL, NULL, 'quantity_formula', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_1, '올림/내림/반올림 규칙이 있는 계산은?', 'table_input', '{"columns":["계산항목","올림/내림/반올림","소수점자릿수"]}', NULL, NULL, NULL, 'quantity_formula', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_1, '여유 수량(LOSS) 적용 품목과 비율은?', 'table_input', '{"columns":["품목명","LOSS율(%)","비고"]}', NULL, NULL, NULL, 'quantity_formula', 0, 6, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 6.2: 수식 검증 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_6, '수식 검증', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_6_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_6_2, '실제 견적서에서 수량 계산 예시를 보여주세요 (W=3000, H=2500일 때)', 'table_input', '{"columns":["부품명","수식","계산결과","단위"]}', NULL, NULL, NULL, 'quantity_formula', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_2, '수식에 사용하는 함수가 있나요? (SUM, CEIL, ROUND 등)', 'multi_select', '{"choices":["CEIL (올림)","FLOOR (내림)","ROUND (반올림)","MAX","MIN","IF 조건문","SUM"]}', NULL, NULL, NULL, 'quantity_formula', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_6_2, '조건에 따라 수식이 달라지는 경우가 있나요?', 'text', NULL, '예: 폭이 3000 초과이면 분할 계산', NULL, NULL, 'quantity_formula', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 7: 조건부 로직 (conditional_logic) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '조건부 로직', '범위/매핑 기반 부품 자동 선택 규칙', 'conditional_logic', 9, 1, @user_id, @user_id, @now, @now); +SET @cat_7 = LAST_INSERT_ID(); + +-- 템플릿 7.1: 범위 기반 선택 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_7, '범위 기반 선택', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_7_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_7_1, '무게 범위별 모터 용량 선택표를 작성해주세요', 'price_table', '{"columns":["범위 시작(kg)","범위 끝(kg)","모터용량","비고"]}', NULL, NULL, NULL, 'conditional_logic', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_7_1, '크기 범위별 부품 자동 선택 규칙이 있나요?', 'table_input', '{"columns":["조건(변수)","범위","선택부품","비고"]}', NULL, NULL, NULL, 'conditional_logic', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_7_1, '브라켓 크기 결정 기준은?', 'table_input', '{"columns":["조건","범위","브라켓 규격"]}', NULL, NULL, NULL, 'conditional_logic', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 7.2: 매핑 기반 선택 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_7, '매핑 기반 선택', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_7_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_7_2, '제품 모델 → 기본 부품 세트 매핑표', 'table_input', '{"columns":["제품모델","기본부품1","기본부품2","기본부품3"]}', NULL, NULL, NULL, 'conditional_logic', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_7_2, '설치 유형 → 추가 부품 매핑표', 'table_input', '{"columns":["설치유형","추가부품","수량","비고"]}', NULL, NULL, NULL, 'conditional_logic', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_7_2, '제어기 유형 → 부속품 매핑표', 'table_input', '{"columns":["제어기유형","부속품1","부속품2","부속품3"]}', NULL, NULL, NULL, 'conditional_logic', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_7_2, '기타 조건부 자동 선택 규칙', 'text', NULL, '위 항목에 해당하지 않는 조건-결과 매핑', NULL, NULL, 'conditional_logic', 0, 4, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- Domain 8: 견적서 양식 (quote_format) +-- ============================================================ +INSERT INTO interview_categories (tenant_id, interview_project_id, parent_id, name, description, domain, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, NULL, @root_manufacturing, '견적서 양식', '출력 양식, 항목 그룹, 소계/합계 구조', 'quote_format', 10, 1, @user_id, @user_id, @now, @now); +SET @cat_8 = LAST_INSERT_ID(); + +-- 템플릿 8.1: 출력 양식 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_8, '출력 양식', 1, 1, @user_id, @user_id, @now, @now); +SET @tpl_8_1 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_8_1, '현재 사용 중인 견적서 양식을 업로드해주세요', 'file_upload', NULL, NULL, NULL, NULL, 'quote_format', 1, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '견적서에 표시되는 항목 그룹은? (재료비, 노무비, 설치비 등)', 'multi_select', '{"choices":["재료비","노무비","경비","설치비","운반비","이윤","부가세"]}', NULL, NULL, NULL, 'quote_format', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '소계/합계 계산 구조를 설명해주세요', 'text', NULL, '항목 그룹별 소계와 최종 합계의 관계', NULL, NULL, 'quote_format', 0, 3, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '할인 적용 방식은? (일괄? 항목별?)', 'select', '{"choices":["일괄 할인","항목별 할인","할인 없음","협의 할인"]}', NULL, NULL, NULL, 'quote_format', 0, 4, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '부가세 표시 방식은? (별도? 포함?)', 'select', '{"choices":["별도 표시","포함 표시","선택 가능"]}', NULL, NULL, NULL, 'quote_format', 0, 5, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '견적서에 표시하지 않는 내부 관리 항목은?', 'text', NULL, NULL, NULL, NULL, 'quote_format', 0, 6, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_1, '견적 번호 체계를 알려주세요', 'text', NULL, '예: Q-2026-001 형식', NULL, NULL, 'quote_format', 0, 7, 1, @user_id, @user_id, @now, @now); + +-- 템플릿 8.2: 특수 요구사항 +INSERT INTO interview_templates (tenant_id, interview_category_id, name, sort_order, is_active, created_by, updated_by, created_at, updated_at) +VALUES (@tenant_id, @cat_8, '특수 요구사항', 2, 1, @user_id, @user_id, @now, @now); +SET @tpl_8_2 = LAST_INSERT_ID(); + +INSERT INTO interview_questions (tenant_id, interview_template_id, question_text, question_type, options, ai_hint, expected_format, depends_on, domain, is_required, sort_order, is_active, created_by, updated_by, created_at, updated_at) VALUES +(@tenant_id, @tpl_8_2, '산출내역서(세부 내역)를 별도로 제공하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 1, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_2, '위치별(층/부호) 개별 산출이 필요한가요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 2, 1, @user_id, @user_id, @now, @now), +(@tenant_id, @tpl_8_2, '일괄 산출(여러 위치 합산)을 사용하나요?', 'checkbox', NULL, NULL, NULL, NULL, 'quote_format', 0, 3, 1, @user_id, @user_id, @now, @now); + +-- ============================================================ +-- 완료 확인 +-- ============================================================ +SELECT + (SELECT COUNT(*) FROM interview_categories WHERE interview_project_id IS NULL AND domain IS NOT NULL) AS master_categories, + (SELECT COUNT(*) FROM interview_templates t JOIN interview_categories c ON t.interview_category_id = c.id WHERE c.interview_project_id IS NULL AND c.domain IS NOT NULL) AS master_templates, + (SELECT COUNT(*) FROM interview_questions q JOIN interview_templates t ON q.interview_template_id = t.id JOIN interview_categories c ON t.interview_category_id = c.id WHERE c.interview_project_id IS NULL AND c.domain IS NOT NULL) AS master_questions; diff --git a/dev/dev_plans/qms-api-integration-plan.md b/dev/dev_plans/qms-api-integration-plan.md new file mode 100644 index 0000000..c760f0d --- /dev/null +++ b/dev/dev_plans/qms-api-integration-plan.md @@ -0,0 +1,316 @@ +# 품질인정심사(QMS) API 연동 계획 + +> **작성일**: 2026-03-09 +> **상태**: 계획 수립 +> **URL**: `/quality/qms` +> **스토리보드**: 슬라이드 19~20 +> **관련 문서**: `docs/features/quality-management/quality-certification-audit.md` + +--- + +## 1. 현황 분석 + +### 1.1 프론트엔드 현황 + +| 항목 | 상태 | 비고 | +|------|------|------| +| `page.tsx` | ✅ 구현됨 | 14KB, 전체 페이지 레이아웃 | +| `types.ts` | ✅ 구현됨 | 95줄, 타입 정의 완료 | +| `mockData.ts` | ✅ 구현됨 | 543줄, 완전한 목업 데이터 | +| `components/` | ✅ 구현됨 | 12개 컴포넌트 + documents/ 7개 | +| `actions.ts` | ❌ 없음 | API 연동 0% | + +프론트엔드는 UI가 완성되어 있으나 **100% 목업 데이터**로 동작 중. + +### 1.2 백엔드 현황 + +| 영역 | 기존 API | 신규 필요 | +|------|----------|-----------| +| **1일차 (기준/매뉴얼 심사)** | ❌ 없음 | 모델, 마이그레이션, 서비스, 컨트롤러 전체 | +| **2일차 (로트 추적 심사)** | ⚠️ 부분 존재 | 기존 API 조합 + 서류 연결 API 신규 | + +**기존 활용 가능 API:** +- `GET /quality/documents` — 품질관리서 목록 (2일차 1단계) +- `GET /quality/documents/{id}` — 품질관리서 상세 + 수주/개소 (2일차 2단계) +- `GET /quality/performance-reports` — 실적신고 (분기 필터 활용) +- `GET /inspections` — 수입검사/중간검사 성적서 +- 출하/출고/납품 관련 기존 API + +--- + +## 2. 작업 범위 + +### Phase 1: 2일차 (로트 추적 심사) API 연동 + +> **우선순위 높음** — 기존 API 활용 가능하여 빠르게 연동 가능 + +#### 2.1 Frontend — `actions.ts` 생성 + +``` +react/src/app/[locale]/(protected)/quality/qms/actions.ts +``` + +| 액션 | 호출 API | 설명 | +|------|----------|------| +| `getQualityReports()` | `GET /quality/documents` | 품질관리서 목록 (분기 필터) | +| `getReportRoutes(reportId)` | `GET /quality/documents/{id}` | 수주코드 + 개소 목록 | +| `getRouteDocuments(routeId)` | 복합 조회 (아래 참조) | 개소별 관련 서류 8종 | +| `confirmUnitInspection(unitId)` | `PATCH /qms/lot-audit/confirm` | 개소 확인 완료 처리 | + +#### 2.2 관련 서류 조회 로직 + +2일차 3단계 "관련 서류"는 개소(Location)에 연결된 8종 서류를 조합 조회: + +| 서류 타입 | 데이터 소스 | 조회 방식 | +|-----------|-------------|-----------| +| 수입검사 성적서 | `inspections` (type=IQC) | 수주의 BOM 원자재 LOT 추적 | +| 수주서 | `orders` | 수주코드로 직접 조회 | +| 작업일지 | `work_orders` + 작업일지 | 수주 → 작업지시 → 작업일지 | +| 중간검사 성적서 | `inspections` (type=PQC) | 작업지시별 중간검사 | +| 납품확인서 | `shipments` | 출하 → 납품확인서 | +| 출고증 | `shipments` | 출하 → 출고증 | +| 제품검사 성적서 | `quality_document_locations` | 개소별 검사 문서 (EAV) | +| 품질관리서 | `quality_documents` | 품질관리서 원본 | + +#### 2.3 Backend — 신규 API (최소) + +``` +GET /api/v1/qms/lot-audit/reports — 분기별 품질관리서 목록 (전용 뷰) +GET /api/v1/qms/lot-audit/reports/{id} — 수주코드 + 개소 + 완료 상태 +GET /api/v1/qms/lot-audit/routes/{id}/documents — 개소별 8종 서류 조합 조회 +PATCH /api/v1/qms/lot-audit/units/{id}/confirm — 확인 완료 처리 +``` + +> 기존 `quality/documents` API를 래핑하여 QMS 전용 응답 형태로 가공하는 방식 권장. +> 8종 서류 조합 로직이 복잡하므로 **전용 서비스 메서드** 필요. + +#### 2.4 DB 변경 + +| 변경 | 테이블 | 설명 | +|------|--------|------| +| 컬럼 추가 | `quality_document_locations` | `options` JSON에 `lot_audit_confirmed`, `lot_audit_confirmed_at` 추가 | + +> 별도 테이블 없이 기존 개소(Location) 테이블의 `options` 활용 (컬럼 추가 정책 준수) + +--- + +### Phase 2: 1일차 (기준/매뉴얼 심사) 백엔드 구축 + +> **작업량 많음** — 완전 신규 백엔드 구축 필요 + +#### 2.1 DB 설계 (신규 테이블) + +``` +audit_checklists (심사 점검표 마스터) +├── id, tenant_id +├── year, quarter +├── type: 'standard_manual' (1일차) +├── status: draft/in_progress/completed +├── options: JSON +├── created_by, timestamps, soft_delete + +audit_checklist_categories (점검표 카테고리) +├── id, tenant_id +├── checklist_id (FK → audit_checklists) +├── title: '원재료 품질관리 기준' +├── sort_order +├── options: JSON + +audit_checklist_items (점검표 세부 항목) +├── id, tenant_id +├── category_id (FK → audit_checklist_categories) +├── name: '수입검사 기준 확인' +├── description +├── is_completed: boolean +├── completed_at, completed_by +├── sort_order +├── options: JSON + +audit_standard_documents (기준 문서) +├── id, tenant_id +├── checklist_item_id (FK → audit_checklist_items) +├── title, version, date +├── document_id (FK → documents, EAV) +├── options: JSON +``` + +#### 2.2 Backend 구현 + +| 파일 | 역할 | +|------|------| +| `api/app/Models/Qualitys/AuditChecklist.php` | 심사 점검표 모델 | +| `api/app/Models/Qualitys/AuditChecklistCategory.php` | 카테고리 모델 | +| `api/app/Models/Qualitys/AuditChecklistItem.php` | 세부 항목 모델 | +| `api/app/Models/Qualitys/AuditStandardDocument.php` | 기준 문서 모델 | +| `api/app/Services/AuditChecklistService.php` | 서비스 | +| `api/app/Http/Controllers/Api/V1/AuditChecklistController.php` | 컨트롤러 | +| `api/database/migrations/XXXX_create_audit_checklists_table.php` | 마이그레이션 (4테이블) | + +#### 2.3 API 엔드포인트 + +``` +GET /api/v1/qms/checklists — 점검표 목록 (연도/분기 필터) +POST /api/v1/qms/checklists — 점검표 생성 +GET /api/v1/qms/checklists/{id} — 점검표 상세 (카테고리+항목+문서) +PUT /api/v1/qms/checklists/{id} — 점검표 수정 +PATCH /api/v1/qms/checklists/{id}/complete — 점검표 완료 처리 + +PATCH /api/v1/qms/checklist-items/{id}/toggle — 항목 완료/미완료 토글 +GET /api/v1/qms/checklist-items/{id}/documents — 항목별 기준 문서 조회 +POST /api/v1/qms/checklist-items/{id}/documents — 기준 문서 연결 +DELETE /api/v1/qms/checklist-items/{id}/documents/{docId} — 기준 문서 연결 해제 +``` + +#### 2.4 Frontend — actions.ts 확장 + +| 액션 | 설명 | +|------|------| +| `getChecklists(year, quarter)` | 점검표 목록 | +| `getChecklistDetail(id)` | 점검표 상세 (카테고리+항목+문서) | +| `toggleChecklistItem(itemId)` | 항목 완료/미완료 토글 | +| `getCheckItemDocuments(itemId)` | 기준 문서 조회 | +| `confirmCheckItem(itemId)` | 기준/매뉴얼 확인 완료 | + +--- + +### Phase 3: 프론트엔드 목업 → API 전환 + +#### 3.1 page.tsx 수정 + +- `mockData.ts` import 제거 +- `actions.ts` import로 교체 +- `useEffect`에서 API 호출 +- 로딩/에러 상태 추가 + +#### 3.2 컴포넌트 수정 + +| 컴포넌트 | 변경 내용 | +|----------|-----------| +| `ReportList.tsx` | API 데이터 바인딩 | +| `RouteList.tsx` | API 데이터 바인딩 | +| `DocumentList.tsx` | 8종 서류 실제 조회 | +| `InspectionModal.tsx` | 실제 검사 문서 렌더링 | +| `Day1ChecklistPanel.tsx` | API 데이터 바인딩 | +| `Day1DocumentSection.tsx` | 기준 문서 API 조회 | +| `Day1DocumentViewer.tsx` | 실제 파일 미리보기 | +| `AuditProgressBar.tsx` | 실시간 진행률 계산 | +| `Filters.tsx` | 연도/분기 필터 API 연동 | + +#### 3.3 mockData.ts 처리 + +- Phase 3 완료 후 `mockData.ts` 삭제 +- 또는 `USE_MOCK` 플래그 패턴 적용 (개발 편의) + +--- + +## 3. 데이터 매핑 + +### 3.1 InspectionReport ↔ QualityDocument + +| 프론트 (InspectionReport) | 백엔드 (QualityDocument) | +|---------------------------|-------------------------| +| `id` | `quality_documents.id` | +| `code` | `quality_documents.code` (채번) | +| `siteName` | `quality_documents.site_name` | +| `item` | `quality_documents.options.product_type` 또는 인정특성 | +| `routeCount` | `quality_document_orders` COUNT | +| `totalRoutes` | `quality_document_locations` COUNT | +| `quarter` | `performance_reports.year` + `quarter` | +| `year` | `performance_reports.year` | +| `quarterNum` | `performance_reports.quarter` | + +### 3.2 RouteItem ↔ QualityDocumentOrder + +| 프론트 (RouteItem) | 백엔드 (QualityDocumentOrder) | +|--------------------|-------------------------------| +| `id` | `quality_document_orders.id` | +| `code` | `orders.order_code` | +| `date` | `orders.order_date` | +| `site` | `orders.site_name` | +| `locationCount` | `quality_document_locations` COUNT | +| `subItems` | `quality_document_locations` 변환 | + +### 3.3 ChecklistCategory ↔ AuditChecklistCategory + +| 프론트 (ChecklistCategory) | 백엔드 (AuditChecklistCategory) | +|---------------------------|--------------------------------| +| `id` | `audit_checklist_categories.id` | +| `title` | `audit_checklist_categories.title` | +| `subItems` | `audit_checklist_items` 관계 | + +--- + +## 4. 일정 산정 + +| Phase | 작업 내용 | 예상 소요 | +|-------|----------|-----------| +| **Phase 1** | 2일차 API 연동 (기존 API 활용) | | +| ├ 1-1 | Backend: 전용 서비스 + 컨트롤러 + 라우트 | 1일 | +| ├ 1-2 | Backend: 8종 서류 조합 조회 로직 | 1일 | +| ├ 1-3 | Frontend: actions.ts 생성 + 목업 교체 | 1일 | +| └ 1-4 | 테스트 및 디버깅 | 0.5일 | +| **Phase 2** | 1일차 백엔드 구축 (완전 신규) | | +| ├ 2-1 | DB 설계 + 마이그레이션 (4테이블) | 0.5일 | +| ├ 2-2 | 모델 4개 + 관계 설정 | 0.5일 | +| ├ 2-3 | 서비스 + 컨트롤러 + 라우트 | 1일 | +| └ 2-4 | 초기 데이터 시딩 (점검표 마스터) | 0.5일 | +| **Phase 3** | 프론트엔드 전환 | | +| ├ 3-1 | 2일차 컴포넌트 API 바인딩 | 1일 | +| ├ 3-2 | 1일차 컴포넌트 API 바인딩 | 1일 | +| └ 3-3 | 통합 테스트 + mockData 정리 | 0.5일 | + +**총 예상: ~8일** + +--- + +## 5. 의존성 및 리스크 + +### 5.1 의존성 + +| 항목 | 의존 대상 | 상태 | +|------|-----------|------| +| 품질관리서 데이터 | `quality_documents` 실 데이터 | ✅ 운영 중 | +| 실적신고 데이터 | `performance_reports` 실 데이터 | ✅ 운영 중 | +| 수입검사 성적서 | `inspections` (IQC) | ✅ 운영 중 | +| 중간검사 성적서 | `inspections` (PQC) | ⚠️ 구현 중 | +| 작업일지 | `work_orders` 연결 | ✅ 운영 중 | +| 출하/납품 | `shipments` | ✅ 운영 중 | +| 기준 문서 파일 | EAV Document 시스템 | ✅ 운영 중 | + +### 5.2 리스크 + +| 리스크 | 영향 | 완화 방안 | +|--------|------|-----------| +| 8종 서류 추적 로직 복잡 | Phase 1 지연 | 서류별 독립 조회 후 프론트에서 조합 | +| 1일차 점검표 초기 데이터 부재 | Phase 2 테스트 어려움 | 시더로 기본 점검표 생성 | +| 중간검사 미완성 | 2일차 일부 서류 누락 | 빈 상태로 표시, 추후 연동 | + +--- + +## 6. 권장 진행 순서 + +``` +Phase 1 (2일차 API 연동) — 3.5일 + ↓ +Phase 2 (1일차 백엔드 구축) — 2.5일 + ↓ +Phase 3 (프론트엔드 전환) — 2.5일 +``` + +**Phase 1을 먼저 하는 이유:** +- 기존 API 활용으로 빠르게 실 데이터 확인 가능 +- 로트 추적은 실적신고와 직접 연결되어 비즈니스 우선순위 높음 +- Phase 2(1일차)는 독립적인 신규 개발이므로 나중에 진행 가능 + +--- + +## 관련 문서 + +- [품질인정심사 기능 문서](../../features/quality-management/quality-certification-audit.md) +- [제품검사 관리](../../features/quality-management/inspection-management.md) +- [생산실적신고](../../features/quality-management/performance-reports.md) +- [통합 개선 마스터 플랜](./integrated-master-plan.md) + +--- + +**최종 업데이트**: 2026-03-09 diff --git a/features/academy/fire-shutter-image-prompts.md b/features/academy/fire-shutter-image-prompts.md new file mode 100644 index 0000000..1614eda --- /dev/null +++ b/features/academy/fire-shutter-image-prompts.md @@ -0,0 +1,369 @@ +# 방화셔터 백과사전 이미지 생성 프롬프트 + +> **작성일**: 2026-02-22 +> **상태**: 확정 +> **용도**: Google Gemini (Nano Banana Pro) 이미지 생성용 + +--- + +## 1. 개요 + +### 1.1 목적 + +MNG 아카데미 > 방화셔터 백과사전 페이지에 삽입할 기술 일러스트레이션을 AI 이미지 생성 도구(Google Gemini)로 제작하기 위한 프롬프트 모음이다. + +### 1.2 사용 방법 + +1. Google Gemini (Nano Banana Pro 모델)에서 프롬프트를 입력한다 +2. 생성된 이미지를 `mng/public/images/academy/fire-shutter/` 경로에 저장한다 +3. Blade 뷰에서 `` 태그로 참조한다 + +### 1.3 주의사항 + +- **화면 내 모든 라벨은 영어**로 작성되어 있다 (한글 텍스트는 AI 이미지 생성 시 깨짐 현상 발생) +- 전체 구성도, 설치 장면 등 넓은 이미지는 **16:9** 비율 권장 +- 단면도, 부품 상세 등은 **1:1** 또는 **4:3** 비율 권장 +- 생성 실패 시 프롬프트 앞에 `Detailed technical engineering illustration, clean white background, ` 를 추가한다 + +--- + +## 2. 프롬프트 목록 + +### 2.1 방화셔터 전체 구성도 (Full Component Diagram) + +``` +Technical illustration of a fire shutter (automatic fire-rated rolling shutter) installed in a building opening, cutaway side view showing all components with English labels. + +Show these parts clearly labeled: +- Top: "CEILING SLAB" with "HEAD BOX / CASE" mounted below +- Inside head box: "SHAFT" with coiled steel slats, "BALANCE SPRING", "GEAR BOX", "MOTOR", "ELECTROMAGNETIC BRAKE", "BRACKET" on both sides +- Both sides: vertical "GUIDE RAIL" mounted on fireproof walls with "ANCHOR BOLTS" +- Center: multiple horizontal "STEEL SLATS" hanging down in interlocking pattern +- Bottom: "BOTTOM BAR" touching the floor with rubber seal +- Nearby wall: "MANUAL CONTROL BOX" with UP/STOP/DOWN buttons +- Ceiling: "SMOKE DETECTOR" and "HEAT DETECTOR" +- Wall-mounted: "FIRE SHUTTER CONTROLLER" + +Style: Clean technical cutaway diagram, white background, professional engineering illustration, labeled with arrows pointing to each component. Color-coded: structural parts in gray/silver, electrical parts in blue, safety parts in red. Isometric or 3/4 perspective view. +``` + +--- + +### 2.2 슬랫 인터록킹 구조 (Slat Interlocking) + +``` +Technical cross-section illustration showing how fire shutter steel slats interlock with each other. + +Show 3-4 slats connected in interlocking pattern: +- Each slat is a C-shaped or S-shaped profile made from 1.6mm EGI steel +- The curved edges of adjacent slats hook into each other, allowing flexibility while maintaining a continuous curtain surface +- One slat highlighted with dimension labels: "THICKNESS 1.6mm", "PITCH 75-100mm" +- Show the slight curved profile that allows the slat to wrap around the shaft when rolled up +- Arrow labeled "ROLLING DIRECTION" + +Label each part: "SLAT", "INTERLOCKING JOINT", "EGI STEEL 1.6mm" + +Style: Clean engineering cross-section diagram, white background, metallic silver color for steel. Include dimension lines. Zoomed-in detail view with magnified interlocking joint area in a callout circle. +``` + +--- + +### 2.3 가이드레일 단면도 (Guide Rail Cross-Section) + +``` +Technical cross-section illustration of a fire shutter guide rail mounted on a fireproof wall, viewed from top-down. + +Show the C-channel shaped guide rail: +- C-channel profile, steel thickness 2.3mm+ +- Inside the channel: slat edge sitting in the groove +- Smoke seal material strips on both sides of the channel, pressing against the slat +- Anchor bolts securing the guide rail to the concrete wall +- Wall shown as hatched concrete pattern + +Labels with arrows: +- "GUIDE RAIL BODY (C-CHANNEL)" +- "SLAT EDGE" +- "SMOKE SEAL PACKING" +- "ANCHOR BOLT" +- "FIREPROOF WALL" +- "STEEL 2.3mm+" + +Style: Clean technical cross-section, white background, steel parts in metallic gray, seal material in orange/red, wall in light brown hatched pattern. Include dimension annotations. +``` + +--- + +### 2.4 샤프트 어셈블리 (Shaft Assembly) + +``` +Technical illustration showing the inside of a fire shutter head box, exploded or cutaway view. + +Show these components assembled on or around the shaft: +- Central pipe labeled "SHAFT" with slats attached, partially wound +- Left side: "BRACKET" steel plate bolted to wall, with "BEARING" supporting shaft end +- Right side: "GEAR BOX" and "MOTOR" mounted on bracket +- "ELECTROMAGNETIC BRAKE" attached to motor assembly +- "BALANCE SPRING" torsion spring visible inside the shaft +- "AUTO CLOSER" device mounted near the brake +- "LIMIT SWITCH" small switches with actuator arms +- "HEAD BOX CASE" shown as transparent or partially removed to reveal internals +- Wiring connections going down labeled "TO CONTROLLER" + +Style: Exploded technical diagram or cutaway 3D illustration, white background, professional engineering style. Color-coded: mechanical parts in silver/gray, motor in dark blue, brake in red, spring in green. All labels in English with leader lines. +``` + +--- + +### 2.5 감속기+모터+브레이크 (Gear Box + Motor + Brake Assembly) + +``` +Technical illustration of a fire shutter drive unit assembly, showing three main components connected together. + +Show them assembled in sequence with labels: +1. "MOTOR (220V)" - cylindrical body with power cables +2. "ELECTROMAGNETIC BRAKE" - disc-type brake between motor and gearbox, showing brake disc, coil, and spring +3. "WORM GEAR BOX" - rectangular housing with cutaway revealing the worm gear and worm wheel inside + +Assembly order shown with arrows: MOTOR → BRAKE → GEAR BOX → "OUTPUT TO SHAFT" +Include rotation direction arrows + +Small inset callout showing worm gear mechanism detail labeled: "WORM", "WORM WHEEL", "SELF-LOCKING" + +Style: Technical exploded/assembly diagram, white background, metallic rendering, engineering illustration style. +``` + +--- + +### 2.6 연동제어기 시스템 (Controller System) + +``` +Technical schematic diagram showing the fire shutter interlock control system wiring and signal flow. + +Layout (block diagram style): +- Top center: "FIRE ALARM PANEL" - rectangular box +- Left: "SMOKE DETECTOR (PHOTOELECTRIC)" - circular device on ceiling +- Right: "HEAT DETECTOR (FIXED TEMP.)" - circular device on ceiling +- Center: "FIRE SHUTTER CONTROLLER" - panel with LED indicators labeled "POWER", "PARTIAL CLOSE", "FULL CLOSE" +- Below controller: "AUTO CLOSER" connected to shutter mechanism +- Bottom left: "MANUAL CONTROL BOX" with "UP / STOP / DOWN" buttons +- Bottom: "FIRE SHUTTER" shown schematically + +Signal flow arrows with labels: +- Smoke detector → Controller: "STAGE 1: PARTIAL CLOSE (1m gap)" +- Heat detector → Controller: "STAGE 2: FULL CLOSE (floor sealed)" +- Controller → Auto closer: "CLOSE COMMAND" +- Controller → Speaker icon: "ALARM OUTPUT" +- Controller ↔ Fire alarm panel: "STATUS SIGNAL" + +Style: Clean schematic/block diagram, white background, professional electrical diagram style. Color coding: red for fire signals, blue for power, green for status. All labels in English. +``` + +--- + +### 2.7 2단계 폐쇄 시퀀스 (2-Stage Closure Sequence) + +``` +Technical illustration showing the two-stage closing sequence of an automatic fire shutter, presented as 3 side-by-side panels: + +Panel 1 - Title: "NORMAL (OPEN)": +- Fire shutter fully open, rolled up inside head box +- People walking through the opening freely +- Smoke and heat detectors on ceiling shown in standby (green LED) +- Caption: "Shutter open, passage clear" + +Panel 2 - Title: "STAGE 1: PARTIAL CLOSE": +- Smoke detector activated (red LED, smoke wisps shown) +- Shutter descended leaving about 1 meter gap from floor +- A person crouching to pass under the gap +- Alarm buzzer icon showing sound waves +- Caption: "Smoke detected → Partial close, 1m gap for evacuation" + +Panel 3 - Title: "STAGE 2: FULL CLOSE": +- Heat detector activated (red LED, flames shown) +- Shutter fully closed to floor, bottom bar sealed against floor +- Fire and smoke on one side, clean air on other side +- Caption: "Heat detected → Full close, fire/smoke blocked" + +Arrow at bottom labeled "TIME SEQUENCE →" + +Style: Clean technical illustration with slight architectural rendering, sequential format left to right, white background. People as simple silhouettes. Fire/smoke rendered subtly. +``` + +--- + +### 2.8 롤포밍 공정 (Roll Forming Process) + +``` +Technical illustration showing the roll forming manufacturing process for fire shutter steel slats, production line viewed from the side. + +Show the line from left to right with labels: +1. "UNCOILER" - Steel coil (EGI 1.6mm) being unrolled +2. "LEVELER" - Flattening rollers correcting coil curvature +3. "ROLL FORMING STATION" - 6-8 pairs of forming rollers progressively shaping the flat strip into C/S-shaped slat profile +4. "CUTTING STATION" - Flying shear cutting the formed strip to length +5. "FINISHED SLATS" - Slats stacked neatly on output table + +Detail callout at top showing progressive cross-section shape changes: "FLAT → STAGE 1 → STAGE 2 → STAGE 3 → FINAL PROFILE" + +Arrow at bottom: "MATERIAL FLOW →" +Label on coil: "EGI STEEL COIL 1.6mm" + +Style: Technical factory/manufacturing illustration, clean white background, machinery in industrial gray/green, steel in silver. Side view. Directional arrows showing material flow. +``` + +--- + +### 2.9 현장 설치 (Field Installation) + +``` +Technical illustration showing fire shutter installation at a construction site, depicting key installation steps in a single scene. + +Scene showing a large building opening (about 5m wide, 4m tall) with: +- Two workers on scaffolding installing the head box assembly at the top +- Brackets already bolted to both side walls near the ceiling +- Guide rails mounted vertically on walls with anchor bolts +- Shaft with wound slat curtain being lifted up to place on brackets +- Manual control box being mounted on adjacent wall +- Wiring conduits visible running from controller to head box +- Construction tools: level tool, drill, anchor bolts, wrenches + +Labels with arrows pointing to activities: +- "BRACKET MOUNTING" +- "GUIDE RAIL ANCHORING" +- "SHAFT PLACEMENT" +- "ELECTRICAL WIRING" +- "LEVEL CHECK" +- "ANCHOR BOLT FIXING" + +Style: Technical construction illustration, slightly warm tone, realistic building interior with exposed concrete. Workers wearing safety helmets and vests. Clean architectural illustration style. All text in English. +``` + +--- + +### 2.10 유지보수 점검 (Maintenance Inspection) + +``` +Technical illustration showing fire shutter maintenance inspection scene. + +Show a maintenance technician inspecting a fire shutter: +- Technician with safety vest and hard hat, holding a tablet +- Fire shutter partially lowered (halfway) for testing +- Close-up callout bubbles showing key inspection points: + 1. "SLAT CONDITION" - checking for deformation, rust + 2. "SMOKE SEAL CHECK" - checking guide rail seal condition + 3. "BOTTOM BAR PACKING" - checking floor seal + 4. "MOTOR / BRAKE CHECK" - head box open, listening for sounds + 5. "MANUAL BOX TEST" - pressing UP/STOP/DOWN buttons + 6. "CONTROLLER STATUS" - checking LED indicators + +Checklist overlay in corner: +☑ MOTOR OPEN/CLOSE TEST +☑ DETECTOR INTERLOCK TEST +☑ ALARM SOUND CHECK +☑ MANUAL OPERATION CHECK +☑ BOTTOM BAR SEAL CHECK + +Style: Clean technical illustration, bright well-lit building interior, professional maintenance scene. Color callout bubbles with icons. All text in English. +``` + +--- + +### 2.11 강판형 vs 스크린형 (Steel Plate vs Screen Type) + +``` +Technical side-by-side comparison illustration of two types of fire shutters in similar building openings: + +Left side - Title "STEEL PLATE TYPE": +- Steel slat fire shutter in partially closed position +- Opaque metallic surface of interlocking steel slats visible +- Heavier, industrial appearance with thick guide rails +- Bottom bar with rubber seal +- Callout: "EGI STEEL 1.6mm / HEAVY / OPAQUE / HIGH SEALING" + +Right side - Title "SCREEN / FABRIC TYPE": +- Fabric fire shutter in partially closed position +- Semi-transparent woven silica fiber screen, you can faintly see light through it +- Lighter, sleeker with thin guide rails (11mm) +- Fabric gathered at top +- Callout: "SILICA FIBER / LIGHTWEIGHT / SEMI-TRANSPARENT / RAIL 11mm" + +Center dividing line with "VS" label +Bottom comparison bar: "WEIGHT: Heavy vs Light | VISIBILITY: Opaque vs See-through | RAIL WIDTH: Wide vs 11mm" + +Style: Clean technical comparison, white background, same scale, professional product comparison layout. All text in English. +``` + +--- + +### 2.12 주요 고장 유형 (Major Fault Types) + +``` +Technical illustration showing 6 common fire shutter failure types in a 2x3 grid layout, each in its own panel with a red problem highlight: + +Panel 1 - "SLAT DERAILMENT": +- A slat edge coming out of the guide rail groove, curtain jammed +- Red circle on problem area + +Panel 2 - "MOTOR BURNOUT": +- Motor with smoke marks, burnt wiring +- Overheat warning symbol + +Panel 3 - "BRAKE PAD WEAR": +- Electromagnetic brake with worn disc pad +- Side comparison: "NEW" thick pad vs "WORN" thin pad + +Panel 4 - "CONTROLLER MALFUNCTION": +- Controller panel with error LED, disconnected wires +- Broken signal path indicator + +Panel 5 - "CLOSER SPEED FAULT": +- Shutter dropping fast, speedometer showing "0.15 m/s LIMIT EXCEEDED" +- Governor mechanism detail + +Panel 6 - "SMOKE SEAL FAILURE": +- Smoke wisps leaking through guide rail gaps +- Comparison: "NEW SEAL" vs "DEGRADED SEAL" + +Style: Technical diagnostic illustration, white background, bordered panels. Problem areas in red/orange highlight. Clean maintenance manual style. All titles and labels in English. +``` + +--- + +## 3. 이미지 파일 관리 + +### 3.1 저장 경로 + +``` +mng/public/images/academy/fire-shutter/ +├── 01-full-component-diagram.png +├── 02-slat-interlocking.png +├── 03-guide-rail-cross-section.png +├── 04-shaft-assembly.png +├── 05-gearbox-motor-brake.png +├── 06-controller-system.png +├── 07-two-stage-closure.png +├── 08-roll-forming-process.png +├── 09-field-installation.png +├── 10-maintenance-inspection.png +├── 11-steel-vs-screen-type.png +└── 12-major-fault-types.png +``` + +### 3.2 Blade 참조 예시 + +```html +방화셔터 전체 구성도 +``` + +--- + +## 관련 문서 + +- `mng/resources/views/academy/fire-shutter.blade.php` - 방화셔터 백과사전 Blade 뷰 +- `mng/app/Http/Controllers/AcademyController.php` - 아카데미 컨트롤러 + +--- + +**최종 업데이트**: 2026-02-22 diff --git a/features/approvals/README.md b/features/approvals/README.md new file mode 100644 index 0000000..a43521c --- /dev/null +++ b/features/approvals/README.md @@ -0,0 +1,298 @@ +# 결재관리 시스템 + +> **작성일**: 2026-02-28 +> **상태**: Phase 2 구현 완료 +> **프로젝트**: SAM MNG (관리자 웹) +> **우선순위**: 🔴 필수 + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM MNG 전자결재 시스템. 기안부터 최종 승인, 반려, 회수, 보류, 전결, 참조까지 기업 결재 프로세스를 디지털화한다. + +### 1.2 문서 구조 + +| 문서 | 설명 | +|------|------| +| **README.md** (이 문서) | 시스템 전체 개요, 아키텍처, 상태 관리 | +| [form-types.md](form-types.md) | 양식별 필드/JSON 구조/인터랙션 기술 명세 | +| [workflows.md](workflows.md) | 상세 워크플로우 (승인/반려/회수/보류/전결/복사재기안) | +| [api-reference.md](api-reference.md) | API 엔드포인트 명세 | +| [ui-screens.md](ui-screens.md) | 화면별 UI 구성 및 동작 | +| [db-changes-and-model-sync.md](db-changes-and-model-sync.md) | DB 변경사항 및 API/MNG 모델 동기화 현황 | + +### 1.3 구현 현황 + +| Phase | 범위 | 상태 | +|-------|------|------| +| **Phase 1** | 순차결재, 기안/상신/승인/반려/회수 | ✅ 완료 | +| **Phase 2** | 보류/해제, 전결, 참조 열람 추적, 복사 재기안 | ✅ 완료 | +| **Phase 3** | 병렬결재, 위임(대결), 알림 | 미착수 | +| **Phase 4** | ERP 연동, 결재 통계, 관리자 설정 | 미착수 | + +--- + +## 2. 아키텍처 + +### 2.1 기술 스택 + +| 계층 | 기술 | 설명 | +|------|------|------| +| 뷰 | Blade + HTMX + Alpine.js | 동적 UI, 부분 렌더링 | +| API | Laravel Controller + Service | JSON API (내부용) | +| 모델 | Eloquent ORM | Multi-tenant 스코프 | +| DB | MySQL 8.0 | API 프로젝트에서 마이그레이션 관리 | + +### 2.2 프로젝트 분리 + +``` +API (/home/aweso/sam/api) +├── database/migrations/ ← 모든 결재 테이블 마이그레이션 + +MNG (/home/aweso/sam/mng) +├── app/Models/Approvals/ ← 모델 (Approval, ApprovalStep, ApprovalForm, ApprovalLine, ApprovalDelegation) +├── app/Services/ ← ApprovalService (비즈니스 로직) +├── app/Http/Controllers/ ← ApprovalController (웹), ApprovalApiController (API) +├── resources/views/approvals/ ← Blade 뷰 +└── routes/ ← 웹 라우트 + API 라우트 +``` + +### 2.3 핵심 클래스 + +``` +ApprovalService +├── 목록 조회: getMyDrafts(), getPendingForMe(), getCompletedByMe(), getReferencesForMe() +├── CRUD: createApproval(), updateApproval(), deleteApproval(), getApproval() +├── 워크플로우: submit(), approve(), reject(), cancel(), hold(), releaseHold(), preDecide(), copyForRedraft() +├── 참조: markAsRead() +└── 유틸: getBadgeCounts(), getApprovalLines(), getApprovalForms(), saveApprovalSteps() +``` + +--- + +## 3. 데이터베이스 + +### 3.1 테이블 관계 + +``` +approval_forms (결재 양식) + │ 1:N + ▼ +approvals (결재 문서) + │ 1:N │ N:1 (self) + ▼ ▼ +approval_steps (결재 단계) approvals (parent_doc_id → 원본 문서) + +approval_lines (결재선 템플릿) ← approvals.line_id 참조 + +approval_delegations (위임 설정) ← Phase 3 준비 +``` + +### 3.2 approvals (결재 문서) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT | 테넌트 격리 | +| `document_number` | VARCHAR | `APR-YYMMDD-001` 형식 | +| `form_id` | BIGINT FK | 양식 | +| `line_id` | BIGINT FK NULL | 결재선 템플릿 | +| `title` | VARCHAR(200) | 제목 | +| `content` | JSON | 양식 필드 데이터 | +| `body` | TEXT NULL | 본문 | +| `status` | VARCHAR(20) | 문서 상태 (6가지) | +| `is_urgent` | BOOLEAN | 긴급 여부 | +| `drafter_id` | BIGINT FK | 기안자 | +| `department_id` | BIGINT FK NULL | 기안 부서 | +| `current_step` | INT | 현재 결재 단계 번호 | +| `drafted_at` | TIMESTAMP NULL | 상신 일시 | +| `completed_at` | TIMESTAMP NULL | 완료 일시 | +| `recall_reason` | TEXT NULL | 회수 사유 | +| `parent_doc_id` | BIGINT FK NULL | 재기안 원본 문서 | +| `attachments` | JSON NULL | 첨부파일 | + +### 3.3 approval_steps (결재 단계) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `approval_id` | BIGINT FK | 결재 문서 | +| `step_order` | INT | 순서 (1, 2, 3...) | +| `step_type` | VARCHAR | `approval`, `agreement`, `reference` | +| `parallel_group` | INT NULL | 병렬 그룹 (Phase 3) | +| `approver_id` | BIGINT FK | 결재자 | +| `acted_by` | BIGINT FK NULL | 실제 처리자 (대결 시) | +| `approver_name` | VARCHAR | 결재자명 스냅샷 | +| `approver_department` | VARCHAR | 부서 스냅샷 | +| `approver_position` | VARCHAR | 직급 스냅샷 | +| `status` | VARCHAR(20) | 단계 상태 (5가지) | +| `approval_type` | VARCHAR(20) | `normal`, `pre_decided`, `delegated` | +| `comment` | TEXT NULL | 결재 의견 | +| `acted_at` | TIMESTAMP NULL | 처리 일시 | +| `is_read` | BOOLEAN | 참조 열람 여부 | +| `read_at` | TIMESTAMP NULL | 열람 일시 | + +### 3.4 approval_delegations (위임 설정, Phase 3) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | | +| `delegator_id` | BIGINT FK | 위임자 | +| `delegate_id` | BIGINT FK | 대리인 | +| `start_date` | DATE | 위임 시작일 | +| `end_date` | DATE | 위임 종료일 | +| `form_ids` | JSON NULL | 대상 양식 (NULL=전체) | +| `notify_delegator` | BOOLEAN | 대결 시 보고 여부 | +| `is_active` | BOOLEAN | 활성 여부 | +| `reason` | VARCHAR(200) | 위임 사유 | + +--- + +## 4. 상태 관리 + +### 4.1 문서 상태 (6가지) + +| 상태 | 코드 | 라벨 | 색상 | 설명 | +|------|------|------|------|------| +| 임시저장 | `draft` | 임시저장 | gray | 작성 중, 미상신 | +| 진행 | `pending` | 진행 | blue | 결재선 순환 중 | +| 완료 | `approved` | 완료 | green | 최종 승인 | +| 반려 | `rejected` | 반려 | red | 결재자가 반려 | +| 회수 | `cancelled` | 회수 | yellow | 기안자가 회수 | +| 보류 | `on_hold` | 보류 | amber | 결재자가 보류 | + +### 4.2 단계 상태 (5가지) + +| 상태 | 코드 | 라벨 | 아이콘 | 설명 | +|------|------|------|--------|------| +| 대기 | `pending` | 대기 | 숫자 | 차례 아직 아님 | +| 승인 | `approved` | 승인 | ✓ (녹색) | 승인 완료 | +| 반려 | `rejected` | 반려 | ✗ (적색) | 반려 | +| 건너뜀 | `skipped` | 건너뜀 | — (회색) | 전결/회수로 소멸 | +| 보류 | `on_hold` | 보류 | ⏸ (노란) | 보류 중 | + +### 4.3 결재 유형 (approval_type) + +| 유형 | 코드 | 아이콘 | 설명 | +|------|------|--------|------| +| 일반결재 | `normal` | ✓ | 기본 승인 | +| 전결 | `pre_decided` | ⚡ (남색) | 이후 단계 모두 건너뛰고 즉시 완료 | +| 대결 | `delegated` | — | 대리인이 처리 (Phase 3) | + +### 4.4 참여자 역할 (step_type) + +| 역할 | 코드 | 의사결정 | 설명 | +|------|------|---------|------| +| 결재 | `approval` | ✅ 있음 | 승인/반려/보류/전결 가능 | +| 합의 | `agreement` | ✅ 있음 | 타부서 동의 (승인/반려 가능) | +| 참조 | `reference` | ❌ 없음 | 열람만 가능, 열람 추적 | + +### 4.5 상태 전이 다이어그램 + +``` + ┌─────────────────────────────┐ + │ │ + ┌────────┐ submit() │ ┌─────────┐ │ + │ draft │────────────→│ │ pending │ │ + └────────┘ │ └────┬────┘ │ + ▲ │ │ │ + │ │ ┌────┼─────────┬───────┐ │ + │ (수정 후 재상신) │ │ │ │ │ │ + │ │ │ approve() reject() hold()│ + │ │ │ │ │ │ │ + │ │ │ ▼ ▼ ▼ │ + │ │ │ 다음 step rejected on_hold│ + │ │ │ 또는 │ │ │ + │ │ │ approved │ releaseHold() + │ │ │ │ │ │ │ + │ │ │ │ │ │ │ + │ │ └────┼────────┼───────┘ │ + │ │ │ │ │ + │ │ preDecide() │ │ + │ │ → approved │ │ + │ │ │ │ cancel() │ + │ │ │ │ │ │ + │ │ ▼ │ ▼ │ + │ │ ┌─────────┐ │ ┌──────────┐ + │ │ │approved │ │ │cancelled │ + │ │ └─────────┘ │ └──────────┘ + │ │ │ │ │ + │ │ │ │ │ + │ │ copyForRedraft() │ + │ │ │ │ │ + └───────────────────┼───────┴────────┘ │ + (새 draft 생성) │ │ + │ copyForRedraft() │ + │◀──────────────────────┘ + └─────────────────────────────┘ +``` + +--- + +## 5. 권한 매트릭스 + +### 5.1 누가 무엇을 할 수 있는가 + +| 액션 | 대상자 | 조건 | +|------|--------|------| +| **기안 작성** | 모든 사용자 | — | +| **수정** | 기안자 | `draft` 또는 `rejected` | +| **삭제** | 기안자 | `draft`만 | +| **상신** | 기안자 | `draft` 또는 `rejected`, 결재선 1명 이상 | +| **승인** | 현재 결재자 | `pending`, 자신이 현재 차례 | +| **반려** | 현재 결재자 | `pending`, 사유 필수 | +| **보류** | 현재 결재자 | `pending`, 사유 필수 | +| **보류 해제** | 보류한 결재자 | `on_hold`, 자신이 보류한 건 | +| **전결** | 현재 결재자 | `pending`, 이후 모든 단계 건너뜀 | +| **회수** | 기안자 | `pending` 또는 `on_hold`, 첫 결재자 미처리 | +| **복사 재기안** | 기안자 | `approved`, `rejected`, `cancelled` | +| **참조 열람** | 참조자 | `reference` step 보유 | + +### 5.2 회수 가능 조건 상세 + +``` +회수(cancel) 가능 여부 판단: + +1. 문서 상태가 pending 또는 on_hold인가? → 아니면 불가 +2. 요청자가 기안자(drafter_id)인가? → 아니면 불가 +3. 첫 번째 결재자(approval/agreement)의 상태가 pending 또는 on_hold인가? + → 이미 approved/rejected이면 불가 (첫 결재자가 이미 처리) +``` + +--- + +## 6. 메뉴 구조 + +``` +결재관리 +├── 기안함 /approval-mgmt/drafts ← 내가 기안한 문서 +├── 결재 대기함 /approval-mgmt/pending ← 내가 결재해야 할 문서 +├── 처리 완료함 /approval-mgmt/completed ← 내가 결재한 문서 +└── 참조함 /approval-mgmt/references ← 참조 문서 (열람 추적) +``` + +### 추가 페이지 + +| URL | 설명 | +|-----|------| +| `/approval-mgmt/create` | 기안 작성 | +| `/approval-mgmt/{id}` | 상세 조회 | +| `/approval-mgmt/{id}/edit` | 기안 수정 | + +--- + +## 7. 관련 문서 + +- [결재 양식 기술 명세](form-types.md) — 양식별 필드, JSON 구조, 인터랙션 +- [결재관리 워크플로우 상세](workflows.md) — 각 동작의 상세 흐름 +- [API 명세](api-reference.md) — 엔드포인트 목록 및 요청/응답 예시 +- [UI 화면 구성](ui-screens.md) — 화면별 UI 요소 및 동작 +- [기획서 원본](../../plans/approval-management-system-plan.md) — Phase 1~4 전체 기획 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/approvals/api-reference.md b/features/approvals/api-reference.md new file mode 100644 index 0000000..b63e31b --- /dev/null +++ b/features/approvals/api-reference.md @@ -0,0 +1,594 @@ +# 결재관리 API 명세 + +> **작성일**: 2026-02-28 +> **상태**: Phase 2 구현 완료 +> **Base URL**: `/api/admin/approvals` +> **미들웨어**: `web`, `auth`, `hq.member` +> **관련**: [README.md](README.md) | [워크플로우](workflows.md) | [UI 화면](ui-screens.md) + +--- + +## 1. 개요 + +모든 API는 JSON 응답을 반환한다. 인증은 세션 기반이며, CSRF 토큰이 필요하다. + +### 1.1 공통 응답 형식 + +**성공:** + +```json +{ + "success": true, + "message": "처리 메시지", + "data": { ... } +} +``` + +**실패 (400):** + +```json +{ + "success": false, + "message": "에러 메시지" +} +``` + +### 1.2 공통 헤더 + +``` +Content-Type: application/json +Accept: application/json +X-CSRF-TOKEN: {csrf_token} +``` + +--- + +## 2. 목록 조회 API + +### 2.1 기안함 + +내가 기안한 문서 목록을 조회한다. + +``` +GET /api/admin/approvals/drafts +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 제목/문서번호 검색 | +| `status` | string | 상태 필터 (`draft`, `pending`, `approved`, `rejected`, `cancelled`, `on_hold`) | +| `is_urgent` | boolean | 긴급 문서만 | +| `date_from` | date | 시작일 (YYYY-MM-DD) | +| `date_to` | date | 종료일 (YYYY-MM-DD) | +| `per_page` | int | 페이지당 건수 (기본 15) | +| `page` | int | 페이지 번호 | + +**응답:** Laravel 페이지네이션 형식 + +```json +{ + "data": [ + { + "id": 1, + "document_number": "APR-260228-001", + "title": "휴가 신청", + "status": "pending", + "is_urgent": false, + "form": { "id": 1, "name": "휴가신청서" }, + "steps": [...], + "created_at": "2026-02-28T10:00:00", + "drafted_at": "2026-02-28T10:05:00" + } + ], + "current_page": 1, + "last_page": 3, + "per_page": 15, + "total": 42 +} +``` + +--- + +### 2.2 결재 대기함 + +내가 현재 결재해야 할 문서 목록을 조회한다. + +``` +GET /api/admin/approvals/pending +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 제목/문서번호 검색 | +| `is_urgent` | boolean | 긴급 문서만 | +| `date_from` | date | 시작일 | +| `date_to` | date | 종료일 | +| `per_page` | int | 페이지당 건수 | + +> 현재 사용자가 결재 차례인 문서만 표시된다. 이미 승인/반려한 문서는 표시되지 않는다. + +--- + +### 2.3 처리 완료함 + +내가 승인 또는 반려한 문서 목록을 조회한다. + +``` +GET /api/admin/approvals/completed +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 제목/문서번호 검색 | +| `status` | string | 상태 필터 | +| `date_from` | date | 시작일 | +| `date_to` | date | 종료일 | +| `per_page` | int | 페이지당 건수 | + +--- + +### 2.4 참조함 + +내가 참조자로 지정된 문서 목록을 조회한다. + +``` +GET /api/admin/approvals/references +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 제목/문서번호 검색 | +| `is_read` | string | 열람 상태 필터 (`true`=열람완료, `false`=미열람) | +| `date_from` | date | 시작일 | +| `date_to` | date | 종료일 | +| `per_page` | int | 페이지당 건수 | + +--- + +## 3. CRUD API + +### 3.1 상세 조회 + +``` +GET /api/admin/approvals/{id} +``` + +**응답:** + +```json +{ + "success": true, + "data": { + "id": 1, + "tenant_id": 1, + "document_number": "APR-260228-001", + "form_id": 1, + "line_id": null, + "title": "휴가 신청", + "content": {}, + "body": "2월 27일~28일 연차 사용 신청합니다.", + "status": "pending", + "is_urgent": false, + "drafter_id": 10, + "department_id": 3, + "current_step": 2, + "drafted_at": "2026-02-28T10:05:00", + "completed_at": null, + "recall_reason": null, + "parent_doc_id": null, + "form": { "id": 1, "name": "휴가신청서" }, + "drafter": { "id": 10, "name": "홍길동" }, + "line": null, + "steps": [ + { + "id": 1, + "step_order": 1, + "step_type": "approval", + "approver_id": 20, + "approver_name": "김과장", + "approver_department": "경영지원팀", + "approver_position": "과장", + "status": "approved", + "approval_type": "normal", + "comment": "승인합니다.", + "acted_at": "2026-02-28T11:00:00", + "is_read": false, + "read_at": null + }, + { + "id": 2, + "step_order": 2, + "step_type": "approval", + "approver_id": 30, + "approver_name": "박부장", + "approver_department": "경영지원팀", + "approver_position": "부장", + "status": "pending", + "approval_type": "normal", + "comment": null, + "acted_at": null, + "is_read": false, + "read_at": null + } + ] + } +} +``` + +--- + +### 3.2 생성 (임시저장) + +``` +POST /api/admin/approvals +``` + +**Request Body:** + +```json +{ + "form_id": 1, + "title": "휴가 신청", + "body": "2월 27일~28일 연차 사용", + "is_urgent": false, + "steps": [ + { "user_id": 20, "step_type": "approval" }, + { "user_id": 30, "step_type": "approval" }, + { "user_id": 40, "step_type": "reference" } + ] +} +``` + +**Validation:** + +| 필드 | 규칙 | +|------|------| +| `form_id` | required, exists:approval_forms,id | +| `title` | required, string, max:200 | +| `body` | nullable, string | +| `is_urgent` | boolean | +| `steps` | nullable, array | +| `steps.*.user_id` | required_with:steps, exists:users,id | +| `steps.*.step_type` | required_with:steps, in:approval,agreement,reference | + +**응답 (201):** + +```json +{ + "success": true, + "message": "결재 문서가 저장되었습니다.", + "data": { ... } +} +``` + +--- + +### 3.3 수정 + +``` +PUT /api/admin/approvals/{id} +``` + +> `draft` 또는 `rejected` 상태에서만 수정 가능 + +**Request Body:** (생성과 동일, 모든 필드 선택) + +**Validation:** + +| 필드 | 규칙 | +|------|------| +| `title` | sometimes, string, max:200 | +| `body` | nullable, string | +| `is_urgent` | boolean | +| `steps` | nullable, array | + +--- + +### 3.4 삭제 + +``` +DELETE /api/admin/approvals/{id} +``` + +> `draft` 상태에서만 삭제 가능 + +**응답:** + +```json +{ + "success": true, + "message": "결재 문서가 삭제되었습니다." +} +``` + +--- + +## 4. 워크플로우 API + +### 4.1 상신 + +``` +POST /api/admin/approvals/{id}/submit +``` + +> 기안자가 `draft`/`rejected` 문서를 결재 요청한다. + +**Request Body:** 없음 + +**응답:** `{ "success": true, "message": "결재가 상신되었습니다.", "data": {...} }` + +--- + +### 4.2 승인 + +``` +POST /api/admin/approvals/{id}/approve +``` + +> 현재 결재자가 승인한다. + +**Request Body:** + +```json +{ + "comment": "승인합니다." // 선택 +} +``` + +**응답:** `{ "success": true, "message": "승인되었습니다.", "data": {...} }` + +--- + +### 4.3 반려 + +``` +POST /api/admin/approvals/{id}/reject +``` + +> 현재 결재자가 반려한다. 사유 필수. + +**Request Body:** + +```json +{ + "comment": "예산 초과로 반려합니다." // 필수 +} +``` + +**Validation:** `comment` — required, string, max:1000 + +**응답:** `{ "success": true, "message": "반려되었습니다.", "data": {...} }` + +--- + +### 4.4 회수 + +``` +POST /api/admin/approvals/{id}/cancel +``` + +> 기안자가 `pending`/`on_hold` 문서를 회수한다. 첫 결재자 미처리 시에만 가능. + +**Request Body:** + +```json +{ + "recall_reason": "내용 수정 필요" // 선택 +} +``` + +**응답:** `{ "success": true, "message": "결재가 회수되었습니다.", "data": {...} }` + +--- + +### 4.5 보류 + +``` +POST /api/admin/approvals/{id}/hold +``` + +> 현재 결재자가 결재를 보류한다. 사유 필수. + +**Request Body:** + +```json +{ + "comment": "추가 자료 검토 필요" // 필수 +} +``` + +**Validation:** `comment` — required, string, max:1000 + +**응답:** `{ "success": true, "message": "보류되었습니다.", "data": {...} }` + +--- + +### 4.6 보류 해제 + +``` +POST /api/admin/approvals/{id}/release-hold +``` + +> 보류한 결재자가 보류를 해제한다. + +**Request Body:** 없음 + +**응답:** `{ "success": true, "message": "보류가 해제되었습니다.", "data": {...} }` + +--- + +### 4.7 전결 + +``` +POST /api/admin/approvals/{id}/pre-decide +``` + +> 현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인한다. + +**Request Body:** + +```json +{ + "comment": "전결 처리합니다." // 선택 +} +``` + +**응답:** `{ "success": true, "message": "전결 처리되었습니다.", "data": {...} }` + +--- + +### 4.8 복사 재기안 + +``` +POST /api/admin/approvals/{id}/copy +``` + +> 기안자가 `approved`/`rejected`/`cancelled` 문서를 복사하여 새 draft를 생성한다. + +**Request Body:** 없음 + +**응답:** + +```json +{ + "success": true, + "message": "문서가 복사되었습니다.", + "data": { + "id": 15, + "document_number": "APR-260228-003", + "parent_doc_id": 1, + "status": "draft", + ... + } +} +``` + +> 응답의 `data.id`를 사용하여 `/approval-mgmt/{id}/edit`로 이동한다. + +--- + +### 4.9 참조 열람 추적 + +``` +POST /api/admin/approvals/{id}/mark-read +``` + +> 참조자가 문서를 열람했음을 기록한다. + +**Request Body:** 없음 + +**응답:** `{ "success": true, "message": "열람 처리되었습니다." }` + +--- + +## 5. 유틸리티 API + +### 5.1 결재선 템플릿 목록 + +``` +GET /api/admin/approvals/lines +``` + +**응답:** + +```json +{ + "success": true, + "data": [ + { "id": 1, "name": "일반 결재선", "steps": [...] } + ] +} +``` + +--- + +### 5.2 양식 목록 + +``` +GET /api/admin/approvals/forms +``` + +**응답:** + +```json +{ + "success": true, + "data": [ + { "id": 1, "name": "휴가신청서", "is_active": true } + ] +} +``` + +--- + +### 5.3 미처리 건수 (뱃지) + +``` +GET /api/admin/approvals/badge-counts +``` + +**응답:** + +```json +{ + "success": true, + "data": { + "pending": 3, + "draft": 1, + "reference_unread": 5 + } +} +``` + +| 필드 | 설명 | +|------|------| +| `pending` | 내가 결재해야 할 문서 수 | +| `draft` | 내 임시저장 문서 수 | +| `reference_unread` | 미열람 참조 문서 수 | + +--- + +## 6. 라우트 전체 목록 + +| Method | Path | 컨트롤러 메서드 | 이름 | 설명 | +|--------|------|---------------|------|------| +| GET | `/drafts` | `drafts` | `drafts` | 기안함 | +| GET | `/pending` | `pending` | `pending` | 결재 대기함 | +| GET | `/completed` | `completed` | `completed` | 처리 완료함 | +| GET | `/references` | `references` | `references` | 참조함 | +| GET | `/lines` | `lines` | `lines` | 결재선 템플릿 | +| GET | `/forms` | `forms` | `forms` | 양식 목록 | +| GET | `/badge-counts` | `badgeCounts` | `badge-counts` | 뱃지 건수 | +| POST | `/` | `store` | `store` | 생성 | +| GET | `/{id}` | `show` | `show` | 상세 | +| PUT | `/{id}` | `update` | `update` | 수정 | +| DELETE | `/{id}` | `destroy` | `destroy` | 삭제 | +| POST | `/{id}/submit` | `submit` | `submit` | 상신 | +| POST | `/{id}/approve` | `approve` | `approve` | 승인 | +| POST | `/{id}/reject` | `reject` | `reject` | 반려 | +| POST | `/{id}/cancel` | `cancel` | `cancel` | 회수 | +| POST | `/{id}/hold` | `hold` | `hold` | 보류 | +| POST | `/{id}/release-hold` | `releaseHold` | `release-hold` | 보류 해제 | +| POST | `/{id}/pre-decide` | `preDecide` | `pre-decide` | 전결 | +| POST | `/{id}/copy` | `copyForRedraft` | `copy` | 복사 재기안 | +| POST | `/{id}/mark-read` | `markAsRead` | `mark-read` | 열람 추적 | + +--- + +## 관련 문서 + +- [README.md](README.md) — 시스템 전체 개요 +- [워크플로우 상세](workflows.md) — 각 동작의 상세 흐름 +- [UI 화면 구성](ui-screens.md) — 화면별 동작 + +--- + +**최종 업데이트**: 2026-02-28 diff --git a/features/approvals/db-changes-and-model-sync.md b/features/approvals/db-changes-and-model-sync.md new file mode 100644 index 0000000..e59cd2b --- /dev/null +++ b/features/approvals/db-changes-and-model-sync.md @@ -0,0 +1,286 @@ +# 결재관리 DB 변경사항 및 API 모델 동기화 현황 + +> **작성일**: 2026-03-09 +> **상태**: 조사 완료 +> **관련**: [README.md](README.md) | [API 명세](api-reference.md) + +--- + +## 1. 개요 + +### 1.1 목적 + +2026-02-27 ~ 2026-03-05 기간에 결재관리 테이블에 대규모 컬럼 추가가 이루어졌다. 이 문서는 변경된 DB 스키마와 API/MNG 프로젝트 간 모델 동기화 상태를 기록한다. + +### 1.2 핵심 발견 + +- 마이그레이션 **15개** 실행 (API 프로젝트에서 관리) +- MNG 모델: ✅ 모든 신규 컬럼 반영 완료 +- API 모델: ❌ **`$fillable`/`$casts` 미반영** — 오류 원인 가능성 + +--- + +## 2. 마이그레이션 변경 타임라인 + +### 2.1 Phase 2 확장 (2026-02-27) + +| 마이그레이션 파일 | 대상 테이블 | 작업 | +|------------------|-----------|------| +| `add_columns_to_approvals_table` | `approvals` | `line_id`, `body`, `is_urgent`, `department_id` 추가 | +| `add_columns_to_approval_steps_table` | `approval_steps` | `approver_name`, `approver_department`, `approver_position` 추가 | +| `add_phase2_columns_to_approval_steps_table` | `approval_steps` | `parallel_group`, `acted_by`, `approval_type` 추가 | +| `add_phase2_columns_to_approvals_table` | `approvals` | `recall_reason`, `parent_doc_id` 추가 | +| `create_approval_delegations_table` | `approval_delegations` | 위임 테이블 신규 생성 | +| `add_linkable_to_approvals_table` | `approvals` | `linkable_type`, `linkable_id` 추가 (다형성) | + +### 2.2 도메인 연동 (2026-02-28) + +| 마이그레이션 파일 | 대상 테이블 | 작업 | +|------------------|-----------|------| +| `add_approval_id_to_leaves_table` | `leaves` | `approval_id` FK 추가 | +| `insert_leave_approval_form` | `approval_forms` | 휴가신청 양식 데이터 등록 | + +### 2.3 양식 확장 (2026-03-03 ~ 03-04) + +| 마이그레이션 파일 | 대상 테이블 | 작업 | +|------------------|-----------|------| +| `insert_attendance_approval_forms` | `approval_forms` | 근태신청, 사유서 양식 등록 | +| `add_body_template_to_approval_forms` | `approval_forms` | `body_template` 컬럼 추가 | +| `insert_expense_approval_form` | `approval_forms` | 지출결의서 양식 + body_template 등록 | +| `update_expense_approval_form_body_template` | `approval_forms` | 지출결의서 body_template 고도화 | + +### 2.4 추적 기능 (2026-03-05) + +| 마이그레이션 파일 | 대상 테이블 | 작업 | +|------------------|-----------|------| +| `add_drafter_read_at_to_approvals_table` | `approvals` | `drafter_read_at` 추가 | +| `add_resubmit_count_to_approvals_table` | `approvals` | `resubmit_count` 추가 | +| `add_rejection_history_to_approvals_table` | `approvals` | `rejection_history` 추가 | + +--- + +## 3. 추가된 컬럼 상세 + +### 3.1 `approvals` 테이블 (11개 컬럼 추가) + +| 컬럼 | 타입 | 기본값 | 추가일 | 용도 | +|------|------|--------|--------|------| +| `line_id` | BIGINT FK NULL | NULL | 02-27 | 결재선 템플릿 참조 | +| `body` | LONGTEXT NULL | NULL | 02-27 | 문서 본문 HTML | +| `is_urgent` | BOOLEAN | false | 02-27 | 긴급 여부 | +| `department_id` | BIGINT NULL | NULL | 02-27 | 기안 부서 | +| `recall_reason` | TEXT NULL | NULL | 02-27 | 회수 사유 | +| `parent_doc_id` | BIGINT FK NULL | NULL | 02-27 | 재기안 원본 문서 | +| `linkable_type` | VARCHAR NULL | NULL | 02-27 | 다형성 모델 타입 | +| `linkable_id` | BIGINT NULL | NULL | 02-27 | 다형성 모델 ID | +| `drafter_read_at` | TIMESTAMP NULL | NULL | 03-05 | 기안자 열람 시각 | +| `resubmit_count` | TINYINT UNSIGNED | 0 | 03-05 | 재상신 횟수 | +| `rejection_history` | JSON NULL | NULL | 03-05 | 반려 이력 배열 | + +### 3.2 `approval_steps` 테이블 (6개 컬럼 추가) + +| 컬럼 | 타입 | 기본값 | 추가일 | 용도 | +|------|------|--------|--------|------| +| `approver_name` | VARCHAR(50) NULL | NULL | 02-27 | 결재자명 스냅샷 | +| `approver_department` | VARCHAR(100) NULL | NULL | 02-27 | 결재자 부서 스냅샷 | +| `approver_position` | VARCHAR(50) NULL | NULL | 02-27 | 결재자 직급 스냅샷 | +| `parallel_group` | INT NULL | NULL | 02-27 | 병렬 결재 그룹 (Phase 3) | +| `acted_by` | BIGINT FK NULL | NULL | 02-27 | 실제 처리자 (대결) | +| `approval_type` | VARCHAR(20) | 'normal' | 02-27 | normal/pre_decided/delegated | + +### 3.3 `approval_forms` 테이블 (1개 컬럼 추가) + +| 컬럼 | 타입 | 기본값 | 추가일 | 용도 | +|------|------|--------|--------|------| +| `body_template` | TEXT NULL | NULL | 03-04 | HTML 양식 렌더링 템플릿 | + +### 3.4 `approval_delegations` 테이블 (신규 생성) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `tenant_id` | BIGINT FK | 테넌트 격리 | +| `delegator_id` | BIGINT FK | 위임자 | +| `delegate_id` | BIGINT FK | 대리인 | +| `start_date` | DATE | 위임 시작일 | +| `end_date` | DATE | 위임 종료일 | +| `form_ids` | JSON NULL | 대상 양식 (NULL=전체) | +| `notify_delegator` | BOOLEAN | 대결 시 보고 여부 | +| `is_active` | BOOLEAN | 활성 여부 | +| `reason` | VARCHAR(200) | 위임 사유 | + +--- + +## 4. API/MNG 모델 동기화 현황 + +### 4.1 Approval 모델 비교 + +| 항목 | MNG (`mng/app/Models/Approvals/Approval.php`) | API (`api/app/Models/Tenants/Approval.php`) | +|------|:---:|:---:| +| `line_id` in $fillable | ✅ | ❌ | +| `body` in $fillable | ✅ | ❌ | +| `is_urgent` in $fillable/$casts | ✅ boolean | ❌ | +| `department_id` in $fillable | ✅ | ❌ | +| `recall_reason` in $fillable | ✅ | ❌ | +| `parent_doc_id` in $fillable | ✅ | ❌ | +| `linkable_type/id` in $fillable | ✅ | ✅ | +| `drafter_read_at` in $fillable/$casts | ✅ datetime | ❌ | +| `resubmit_count` in $fillable/$casts | ✅ integer | ❌ | +| `rejection_history` in $fillable/$casts | ✅ array | ❌ | + +### 4.2 ApprovalStep 모델 비교 + +| 항목 | MNG | API | +|------|:---:|:---:| +| `approver_name` in $fillable | ✅ | ❌ | +| `approver_department` in $fillable | ✅ | ❌ | +| `approver_position` in $fillable | ✅ | ❌ | +| `parallel_group` in $fillable | ✅ | ❌ | +| `acted_by` in $fillable | ✅ | ❌ | +| `approval_type` in $fillable | ✅ | ❌ | + +### 4.3 ApprovalForm 모델 비교 + +| 항목 | MNG | API | +|------|:---:|:---:| +| `body_template` in $fillable | ✅ | ❌ | + +### 4.4 ApprovalDelegation 모델 + +| 항목 | MNG | API | +|------|:---:|:---:| +| 모델 파일 존재 | ✅ | ❌ 미생성 | + +--- + +## 5. 오류 영향 분석 + +### 5.1 API 모델 미반영으로 인한 잠재적 오류 + +API 프로젝트의 모델 `$fillable`에 신규 컬럼이 누락되어, API 엔드포인트를 통한 결재 문서 처리 시 다음 오류가 발생할 수 있다: + +| 증상 | 원인 | 영향 범위 | +|------|------|----------| +| `create()`/`update()` 시 신규 필드 저장 안 됨 | `$fillable` 미포함 → mass assignment 차단 | API v1 결재 CRUD | +| JSON 필드(`rejection_history`) 문자열로 반환 | `$casts` 미정의 → 타입 변환 안 됨 | API 응답 파싱 오류 | +| `drafter_read_at` 날짜 비교 실패 | `$casts` datetime 미정의 → Carbon 미변환 | 열람 추적 기능 | +| `is_urgent` 비교 오류 | `$casts` boolean 미정의 → 문자열 비교 | 긴급 필터링 | +| 위임(delegation) 기능 완전 불가 | 모델 자체 미생성 | Phase 3 기능 전체 | + +### 5.2 MNG는 정상 + +MNG 프로젝트의 모델은 모든 신규 컬럼이 `$fillable`, `$casts`, `$attributes`에 반영되어 있으며, `ApprovalService`에서 정상 사용 중이다. + +``` +MNG 정상 동작 확인 기능: +✅ 반려 이력 저장 (rejection_history) +✅ 재상신 횟수 추적 (resubmit_count) +✅ 기안자 열람 추적 (drafter_read_at) +✅ 결재자 스냅샷 저장 (approver_name/department/position) +✅ 전결 처리 (approval_type = pre_decided) +✅ 회수 사유 기록 (recall_reason) +``` + +--- + +## 6. 수정 필요 파일 목록 + +### 6.1 API 모델 업데이트 필요 + +| 파일 | 수정 내용 | +|------|----------| +| `api/app/Models/Tenants/Approval.php` | `$fillable`에 9개 필드, `$casts`에 4개 필드 추가 | +| `api/app/Models/Tenants/ApprovalStep.php` | `$fillable`에 6개 필드 추가 | +| `api/app/Models/Tenants/ApprovalForm.php` | `$fillable`에 `body_template` 추가 | +| `api/app/Models/Tenants/ApprovalDelegation.php` | 모델 파일 신규 생성 | + +### 6.2 Approval.php 수정 상세 + +**`$fillable` 추가 필요:** + +```php +'line_id', +'body', +'is_urgent', +'department_id', +'recall_reason', +'parent_doc_id', +'drafter_read_at', +'resubmit_count', +'rejection_history', +``` + +**`$casts` 추가 필요:** + +```php +'drafter_read_at' => 'datetime', +'resubmit_count' => 'integer', +'rejection_history' => 'array', +'is_urgent' => 'boolean', +``` + +### 6.3 ApprovalStep.php 수정 상세 + +**`$fillable` 추가 필요:** + +```php +'approver_name', +'approver_department', +'approver_position', +'parallel_group', +'acted_by', +'approval_type', +``` + +### 6.4 ApprovalForm.php 수정 상세 + +**`$fillable` 추가 필요:** + +```php +'body_template', +``` + +--- + +## 7. 연관 테이블 참조 변경 + +결재 시스템과 연동된 다른 테이블의 변경사항: + +| 테이블 | 추가 컬럼 | 추가일 | 용도 | +|--------|----------|--------|------| +| `leaves` | `approval_id` (BIGINT FK) | 02-28 | 휴가 ↔ 결재 연동 | +| `purchases` | `approval_id` (BIGINT FK) | (기존) | 구매 ↔ 결재 연동 | + +--- + +## 8. 등록된 결재 양식 (13종) + +2026-02-28 ~ 03-07 기간에 마이그레이션으로 등록된 양식: + +| 코드 | 양식명 | 카테고리 | 등록일 | +|------|--------|---------|--------| +| `leave` | 휴가신청서 | request | 02-28 | +| `attendance_request` | 근태신청서 | request | 03-03 | +| `reason_report` | 사유서 | request | 03-03 | +| `expense` | 지출결의서 | expense | 03-04 | +| `employment_cert` | 재직증명서 | request | 03-05 | +| `career_cert` | 경력증명서 | request | 03-05 | +| `appointment_cert` | 위촉증명서 | request | 03-05 | +| `resignation` | 사직서 | request | 03-06 | +| `seal_usage` | 사용인감계 | request | 03-06 | +| `delegation` | 위임장 | request | 03-06 | +| `board_minutes` | 이사회의사록 | request | 03-06 | +| `quotation` | 견적서 | request | 03-06 | +| `official_letter` | 공문서 | request | 03-07 | + +--- + +## 관련 문서 + +- [결재관리 시스템 개요](README.md) — 아키텍처, 상태 관리, 권한 +- [API 명세](api-reference.md) — 20개 엔드포인트 상세 +- [워크플로우 상세](workflows.md) — 승인/반려/회수/보류/전결 흐름 +- [기획서 원본](../../plans/approval-management-system-plan.md) — Phase 1~4 전체 기획 + +--- + +**최종 업데이트**: 2026-03-09 diff --git a/features/approvals/form-types.md b/features/approvals/form-types.md new file mode 100644 index 0000000..3242b78 --- /dev/null +++ b/features/approvals/form-types.md @@ -0,0 +1,999 @@ +# 결재 양식 기술 명세 + +> **작성일**: 2026-03-06 +> **상태**: Phase 2 구현 완료 +> **관련**: [README.md](README.md) | [워크플로우](workflows.md) | [API 명세](api-reference.md) | [UI 화면](ui-screens.md) + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM MNG 결재관리의 **기안함 양식** 기술 명세. 각 양식의 필드 구조, JSON Content 데이터 형식, UI 인터랙션, 특수 로직을 정의한다. + +### 1.2 양식 목록 + +| 코드 | 양식명 | 분류 | Blade 파일 | 설명 | +|------|--------|------|------------|------| +| `BUSINESS_DRAFT` | 업무기안서 | 일반 | (body 편집기) | 일반 업무 보고·요청 | +| `leave` | 휴가신청 | 인사/근태 | `_leave-form.blade.php` | 연차, 휴가, 근태 신청 | +| `attendance_request` | 근태신청 | 인사/근태 | `_leave-form.blade.php` | 외근, 출장, 조퇴 등 | +| `reason_report` | 사유서 | 인사/근태 | `_leave-form.blade.php` | 지각, 결근 등 사유 소명 | +| `resignation` | 사직서 | 인사/근태 | `_resignation-form.blade.php` | 퇴직 서류 | +| `employment_cert` | 재직증명서 | 증명서 | `_certificate-form.blade.php` | 재직 증명 발급 (PDF) | +| `career_cert` | 경력증명서 | 증명서 | `_career-cert-form.blade.php` | 경력 증명 발급 (PDF) | +| `appointment_cert` | 위촉증명서 | 증명서 | `_appointment-cert-form.blade.php` | 위촉/임명 증명 발급 (PDF) | +| `pr_expense` | 지출품의서 | 품의 | `_purchase-request-form.blade.php` | 지출 전 사전 승인 | +| `pr_contract` | 계약체결품의서 | 품의 | `_purchase-request-form.blade.php` | 계약 체결 전 승인 | +| `pr_purchase` | 구매품의서 | 품의 | `_purchase-request-form.blade.php` | 물품 구매 전 승인 | +| `pr_trip` | 출장품의서 | 품의 | `_purchase-request-form.blade.php` | 출장 계획 승인 | +| `pr_settlement` | 비용정산품의서 | 품의 | `_purchase-request-form.blade.php` | 비용 정산 승인 | +| `expense` | 지출결의서 | 재무 | `_expense-form.blade.php` | 법인카드/송금/자동이체 지출 | + +### 1.3 공통 구조 + +모든 양식은 동일한 패턴으로 동작한다: + +``` +양식 선택 (form_id) + ↓ +양식별 Blade 파셜 렌더링 (create.blade.php 내 조건부 display) + ↓ +사용자 입력 → Alpine.js / JavaScript 인터랙션 + ↓ +getFormData() → JSON content 생성 + ↓ +ApprovalService::createApproval() → Approval.content (JSON 컬럼) 저장 +``` + +### 1.4 양식 선택 UI (2단계 분류 + 설명 카드) + +양식 선택은 **2단계 드롭다운 + 설명 카드** 레이아웃으로 구성된다. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 양식 * │ +│ ┌──── 30% ────────┐ ┌─────────────── 70% ───────────────────────────┐ │ +│ │ 📋 품의 ▼ │ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ │ │ 📋 지출품의서 │ │ │ +│ │ 지출품의서 ▼ │ │ │ 지출이 발생하기 전 사전 승인을 받는 │ │ │ +│ │ │ │ │ 문서입니다. 예산 범위 내에서 지출 항목과 │ │ │ +│ │ │ │ │ 금액을 기재하여 사전에 승락을 받습니다. │ │ │ +│ └──────────────────┘ │ └─────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 1단계: 분류 선택 (`form_category`) + +| 분류 | 아이콘 | 포함 양식 | +|------|--------|----------| +| 일반 | 📄 | 업무기안서 | +| 인사/근태 | 👤 | 휴가신청, 근태신청, 사유서, 사직서 | +| 증명서 | 📜 | 재직증명서, 경력증명서, 위촉증명서 | +| 품의 | 📋 | 지출품의서, 계약체결품의서, 구매품의서, 출장품의서, 비용정산품의서 | +| 재무 | 💰 | 지출결의서 | + +#### 2단계: 양식 선택 (`form_id`) + +- 1단계 분류 선택 시 해당 분류에 속하지 않는 양식은 `display:none` + `disabled` +- 분류 내 첫 번째 양식 자동 선택 + +#### 설명 카드 (`formDescriptions`) + +- 양식 선택 시 우측에 해당 양식의 아이콘/제목/설명 텍스트 표시 +- 14종 전체 양식에 대한 설명 정의 (create/edit 공통) +- 색상: 양식별 Tailwind 테마 색상 (`border-*-200 bg-*-50`) + +#### 핵심 JavaScript 함수 + +| 함수 | 설명 | +|------|------| +| `buildCategoryOptions()` | 사용 가능한 카테고리만 `form_category` 옵션으로 생성 | +| `filterFormsByCategory(cat)` | 선택된 분류 외 양식 옵션 숨김/비활성화 | +| `selectCategoryByFormId(formId)` | formId로 카테고리 역산하여 자동 선택 | +| `updateFormDescription(formId)` | 설명 카드 DOM 업데이트 | + +### 1.5 파일 구조 + +``` +resources/views/approvals/ +├── create.blade.php ← 기안 작성 (2단계 양식 선택 + 설명 카드 + 동적 폼) +├── edit.blade.php ← 기안 수정 (create와 동일한 2단계 선택 구조) +├── show.blade.php ← 상세 조회 (양식별 조회 컴포넌트) +└── partials/ + ├── _leave-form.blade.php ← 휴가신청 폼 + ├── _expense-form.blade.php ← 지출결의서 폼 + ├── _expense-show.blade.php ← 지출결의서 조회 + ├── _purchase-request-form.blade.php ← 품의서 5종 통합 폼 (Alpine.js) + ├── _purchase-request-show.blade.php ← 품의서 5종 통합 조회 + ├── _certificate-form.blade.php ← 재직증명서 폼 + ├── _certificate-show.blade.php ← 재직증명서 조회 + ├── _career-cert-form.blade.php ← 경력증명서 폼 + ├── _career-cert-show.blade.php ← 경력증명서 조회 + ├── _appointment-cert-form.blade.php ← 위촉증명서 폼 + ├── _appointment-cert-show.blade.php ← 위촉증명서 조회 + ├── _resignation-form.blade.php ← 사직서 폼 + ├── _resignation-show.blade.php ← 사직서 조회 + ├── _approval-stamp-table.blade.php ← 결재 도장 테이블 + └── _approval-line-editor.blade.php ← 결재선 편집기 +``` + +--- + +## 2. 휴가신청 (leave) + +### 2.1 폼 필드 + +| 필드 ID | 라벨 | 타입 | 필수 | 기본값 | 설명 | +|---------|------|------|------|--------|------| +| `leave-user-id` | 신청자 | select | 필수 | `auth()->id()` | 활성 사원 목록 | +| `leave-type` | 유형 | select | 필수 | - | 휴가/근태신청/사유서 | +| `leave-start-date` | 시작일 | date | 필수 | - | `YYYY-MM-DD` | +| `leave-end-date` | 종료일 | date | 필수 | - | `YYYY-MM-DD` | +| `leave-reason` | 사유 | textarea | 선택 | - | 자유 텍스트 | + +### 2.2 Content JSON + +```json +{ + "user_id": "10", + "leave_type": "연차", + "start_date": "2026-03-06", + "end_date": "2026-03-07", + "reason": "개인 사유" +} +``` + +### 2.3 특수 로직 + +- **자동 선택**: 로그인 사용자가 기본 선택 (`auth()->id()`) +- **직원 목록**: `$employees` Props로 전달 (활성 사원만) +- **단순 구조**: Alpine.js 없이 Blade 폼으로 구현 + +--- + +## 3. 지출결의서 (expense) + +### 3.1 폼 구조 (Alpine.js 기반) + +```javascript +x-data="expenseForm(initialData, authUserName, initialFiles, cardsData, accountsData)" +``` + +### 3.2 기본 정보 필드 + +| 필드 | 라벨 | 타입 | 필수 | 기본값 | +|------|------|------|------|--------| +| `expense_type` | 지출형식 | radio | 필수 | `corporate_card` | +| `tax_invoice` | 세금계산서 | radio | 필수 | `normal` | +| `write_date` | 작성일자 | date | 선택 | 오늘 | +| `approval_date` | 결재일자 | date | 선택 | 오늘 | +| `department` | 부서 | text | 선택 | `경리부` | +| `writer_name` | 이름 | text | 선택 | 인증 사용자명 | + +### 3.3 지출형식별 선택 + +| 지출형식 | 코드 | 연결 데이터 | +|---------|------|------------| +| 법인카드 | `corporate_card` | `$cards` → `selected_card` | +| 송금 | `transfer` | `$accounts` → `selected_account` | +| 자동이체 출금 | `auto_transfer` | `$accounts` → `selected_account` | +| 현금/가지급정산 | `cash_advance` | 없음 | + +**법인카드 선택 시 저장 구조:** + +```json +{ + "selected_card": { + "id": 1, + "card_name": "삼성카드", + "card_company": "삼성", + "card_number_last4": "1234", + "card_holder_name": "홍길동" + } +} +``` + +**계좌 선택 시 저장 구조:** + +```json +{ + "selected_account": { + "id": 1, + "bank_name": "국민은행", + "account_number": "123-456-789012", + "account_holder": "주일기업" + } +} +``` + +### 3.4 세금계산서 옵션 + +| 옵션 | 코드 | +|------|------| +| 일반 | `normal` | +| 이월발행 | `deferred` | +| 없음 | `none` | + +### 3.5 내역 테이블 + +**동적 rows** (`.items` 배열): + +| 필드 | 라벨 | 타입 | 설명 | +|------|------|------|------| +| `date` | 일자 | date | `YYYY-MM-DD` | +| `description` | 적요 | text | 지출 설명 | +| `amount` | 금액 | number | 콤마 제거 정수 | +| `vendor` | 거래처 | text | Autocomplete 검색 | +| `vendor_id` | 거래처 ID | hidden | API 연결 ID | +| `vendor_biz_no` | 사업자번호 | hidden | 자동 채움 | +| `bank` | 은행명 | text | 수동 입력 | +| `account_no` | 계좌번호 | text | 수동 입력 | +| `depositor` | 예금주 | text | 수동 입력 | +| `remark` | 비고 | text | 메모 | + +### 3.6 Content JSON (전체) + +```json +{ + "expense_type": "corporate_card", + "tax_invoice": "normal", + "write_date": "2026-03-06", + "approval_date": "2026-03-06", + "department": "경리부", + "writer_name": "홍길동", + "items": [ + { + "date": "2026-03-05", + "description": "사무용품 구매", + "amount": 150000, + "vendor": "오피스디포", + "vendor_id": 123, + "vendor_biz_no": "123-45-67890", + "bank": "", + "account_no": "", + "depositor": "", + "remark": "" + } + ], + "total_amount": 150000, + "attachment_memo": "영수증 첨부", + "selected_card": { ... }, + "selected_account": null +} +``` + +### 3.7 특수 기능 + +#### 거래처 검색 (Autocomplete) + +``` +입력 → 250ms 디바운싱 → API 호출 → 드롭다운 렌더링 + +API: /barobill/tax-invoice/search-partners?keyword=... +키보드: ↑↓(네비게이션), Enter(선택), Esc(닫기) +마우스: 항목 클릭(선택) +``` + +#### 금액 입력 포맷팅 + +``` +입력 시: 콤마 제거 → 정수 저장 (parseMoney) +표시 시: 콤마 포맷 (formatMoney) +합계: totalAmount getter → footer 실시간 업데이트 +``` + +#### 파일 업로드 + +``` +드래그 앤 드롭 + 파일 입력 +최대: 20MB +형식: pdf, doc, docx, xls, xlsx, ppt, pptx, txt, jpg, jpeg, png, gif, zip, rar +API: POST /api/admin/approvals/upload-file +진행률: XHR 업로드 이벤트 +``` + +#### 카드/계좌 연동 + +``` +카드 선택 → 모든 내역 행에 "결제카드" 자동 표시 +계좌 선택 → 모든 내역 행에 "은행/계좌/예금주" 자동 채움 +``` + +### 3.8 조회 화면 (_expense-show.blade.php) + +| 섹션 | 내용 | +|------|------| +| 기본 정보 | 지출형식, 세금계산서, 작성일, 결재일, 부서, 이름 | +| 선택 카드/계좌 | 유색 박스로 표시 | +| 내역 테이블 | 읽기 전용, `number_format()` 금액 | +| 첨부서류 메모 | `whitespace-pre-wrap` | +| 첨부파일 목록 | 다운로드 링크 + 파일 크기 | + +--- + +## 4. 증명서 양식 공통 + +### 4.1 공통 패턴 + +모든 증명서 양식은 동일한 패턴을 따른다: + +``` +사원 선택 → loadXxxInfo(userId) → API 호출 → 읽기 전용 필드 자동 채움 + ↓ + 일부 필드만 수정 가능 + ↓ + 미리보기 모달 (인쇄 가능) +``` + +### 4.2 공통 함수 + +| 함수 | 설명 | +|------|------| +| `loadXxxInfo(userId)` | 사원 선택 시 인적/재직 정보 로드 | +| `openXxxPreview()` | 미리보기 모달 열기 | +| `printXxxPreview()` | 미리보기 인쇄 (`window.print()`) | +| `closeXxxPreview()` | 미리보기 닫기 | +| `onXxxPurposeChange()` | 용도 선택 시 직접입력 필드 표시 | + +### 4.3 조회 화면 공통 + +- 읽기 전용 필드 표시 +- PDF 다운로드: `route('api.admin.approvals.cert-pdf', $approval->id)` + +--- + +## 5. 재직증명서 (employment_cert) + +### 5.1 폼 필드 + +| 섹션 | 필드 ID | 라벨 | 타입 | 수정 | 설명 | +|------|---------|------|------|------|------| +| 인적사항 | `cert-name` | 성명 | text | readonly | DB 자동 채움 | +| | `cert-resident` | 주민등록번호 | text | readonly | DB 자동 채움 | +| | `cert-address` | 주소 | text | editable | 직접 입력 | +| 재직사항 | `cert-company` | 회사명 | text | readonly | DB 자동 채움 | +| | `cert-business-num` | 사업자번호 | text | readonly | DB 자동 채움 | +| | `cert-department` | 근무부서 | text | readonly | DB 자동 채움 | +| | `cert-position` | 직급 | text | readonly | DB 자동 채움 | +| | `cert-hire-date` | 재직기간 | text | readonly | DB 자동 채움 | +| 발급정보 | `cert-purpose-select` | 사용용도 | select | editable | 드롭다운 선택 | +| | (custom) | 기타 용도 | text | editable | "기타" 선택 시 표시 | +| | `cert-issue-date` | 발급일 | text | readonly | `now()->format('Y-m-d')` | + +### 5.2 Content JSON + +```json +{ + "name": "홍길동", + "resident_number": "900101-1XXXXXX", + "address": "서울특별시 강남구", + "company_name": "(주)코드브릿지엑스", + "business_num": "123-45-67890", + "department": "개발팀", + "position": "과장", + "hire_date": "2020-03-01", + "purpose": "은행 제출용", + "issue_date": "2026-03-06" +} +``` + +--- + +## 6. 경력증명서 (career_cert) + +### 6.1 폼 필드 (재직증명서 대비 추가/변경) + +| 섹션 | 필드 ID | 라벨 | 타입 | 수정 | 설명 | +|------|---------|------|------|------|------| +| 인적사항 | `cc-birth-date` | 생년월일 | text | readonly | DB 자동 채움 | +| 경력사항 | `cc-ceo-name` | 대표자 | text | readonly | DB 자동 채움 | +| | `cc-phone` | 대표전화 | text | readonly | DB 자동 채움 | +| | `cc-company-address` | 소재지 | text | readonly | DB 자동 채움 | +| | `cc-department` | 소속부서 | text | readonly | DB 자동 채움 | +| | `cc-position` | 직위/직급 | text | readonly | DB 자동 채움 | +| | `cc-hire-date` | 근무기간 시작 | text | readonly | DB 자동 채움 | +| | `cc-resign-date` | 근무기간 종료 | date | editable | 직접 입력 | +| | `cc-job-description` | 담당업무 | text | editable | 직접 입력 | +| 발급정보 | 용도 | select | editable | + "이직 제출용" 옵션 | + +### 6.2 Content JSON + +```json +{ + "name": "홍길동", + "birth_date": "1990-01-01", + "address": "서울특별시 강남구", + "company_name": "(주)코드브릿지엑스", + "business_num": "123-45-67890", + "ceo_name": "김대표", + "phone": "02-1234-5678", + "company_address": "서울특별시 강남구 테헤란로", + "department": "개발팀", + "position": "과장", + "hire_date": "2020-03-01", + "resign_date": "2026-02-28", + "job_description": "웹 애플리케이션 개발", + "purpose": "이직 제출용", + "issue_date": "2026-03-06" +} +``` + +--- + +## 7. 위촉증명서 (appointment_cert) + +### 7.1 폼 필드 + +| 섹션 | 필드 ID | 라벨 | 타입 | 수정 | 설명 | +|------|---------|------|------|------|------| +| 인적사항 | `ac-name` | 성명 | text | readonly | DB 자동 채움 | +| | `ac-resident` | 주민등록번호 | text | readonly | DB 자동 채움 | +| | `ac-department` | 소속 | text | readonly | DB 자동 채움 | +| | `ac-phone` | 연락처 | text | editable | 직접 입력 | +| 위촉정보 | `ac-hire-date` | 위촉기간 시작 | text | readonly | DB 자동 채움 | +| | `ac-resign-date` | 위촉기간 종료 | date | editable | 직접 입력 | +| | `ac-contract-type` | 계약자격 | text | editable | 직접 입력 | +| 발급정보 | `ac-purpose-select` | 용도 | select | editable | 드롭다운 선택 | +| | `ac-issue-date` | 발급일 | text | readonly | 자동 설정 | +| (숨김) | `ac-company-name` | 회사명 | hidden | - | 미리보기용 | +| | `ac-ceo-name` | 대표자명 | hidden | - | 미리보기용 | + +### 7.2 Content JSON + +```json +{ + "name": "홍길동", + "resident_number": "900101-1XXXXXX", + "department": "기술자문팀", + "phone": "010-1234-5678", + "hire_date": "2024-01-01", + "resign_date": "2026-12-31", + "contract_type": "기술자문위원", + "purpose": "관공서 제출용", + "issue_date": "2026-03-06" +} +``` + +--- + +## 8. 사직서 (resignation) + +### 8.1 폼 필드 + +| 섹션 | 필드 ID | 라벨 | 타입 | 수정 | 필수 | +|------|---------|------|------|------|------| +| 인적사항 | `rg-department` | 소속 | text | readonly | - | +| | `rg-position` | 직위 | text | readonly | - | +| | `rg-name` | 성명 | text | readonly | - | +| | `rg-resident` | 주민등록번호 | text | readonly | - | +| | `rg-hire-date` | 입사일 | text | readonly | - | +| | `rg-resign-date` | 퇴사(예정)일 | date | editable | 필수 | +| | `rg-address` | 주소 | text | editable | - | +| 사직사유 | `rg-reason-select` | 사유 | select | editable | 필수 | +| | (custom) | 기타 사유 | text | editable | - | +| 제출일 | `rg-issue-date` | 제출일 | text | readonly | - | + +### 8.2 사직사유 옵션 + +| 옵션 | +|------| +| 일신상의 사유 | +| 가사 사정 | +| 건강상의 이유 | +| 진학/학업 | +| 이직 | +| 기타 (직접입력) | + +### 8.3 Content JSON + +```json +{ + "department": "개발팀", + "position": "대리", + "name": "홍길동", + "resident_number": "900101-1XXXXXX", + "hire_date": "2020-03-01", + "resign_date": "2026-04-01", + "address": "서울특별시 강남구", + "reason": "이직", + "issue_date": "2026-03-06" +} +``` + +--- + +## 9. 품의서 5종 공통 (_purchase-request-form/show) + +### 9.1 통합 Alpine.js 컴포넌트 + +품의서 5종은 **단일 Blade 파일**(`_purchase-request-form.blade.php`)에서 `prType` 프로퍼티로 동적 전환된다. + +```javascript +x-data="purchaseRequestForm(initialData, authUserName, initialFiles)" +``` + +#### 타입 전환 메커니즘 + +``` +create.blade.php → switchFormMode() + ↓ +code.startsWith('pr_') 감지 + ↓ +#purchase-request-form-container display: block + ↓ +setTimeout(50ms) → _x_dataStack[0].setPrType(code) + ↓ +Alpine.js x-if 분기 → 해당 폼 렌더링 +``` + +#### prType 코드 및 라벨 + +| prType | 라벨 | 색상 | +|--------|------|------| +| `pr_expense` | 지출품의서 | `bg-orange-50 text-orange-700` | +| `pr_contract` | 계약체결품의서 | `bg-purple-50 text-purple-700` | +| `pr_purchase` | 구매품의서 | `bg-blue-50 text-blue-700` | +| `pr_trip` | 출장품의서 | `bg-green-50 text-green-700` | +| `pr_settlement` | 비용정산품의서 | `bg-teal-50 text-teal-700` | + +### 9.2 공통 필드 (모든 품의서) + +| 필드 | 라벨 | 타입 | 기본값 | +|------|------|------|--------| +| `write_date` | 작성일자 | date | 오늘 | +| `department` | 요청부서 | text | - | +| `writer_name` | 요청자 | text | 인증 사용자명 | +| `attachment_memo` | 첨부서류 메모 | textarea | - | +| `files` | 파일 업로드 | file[] | - | + +### 9.3 공통 함수 + +| 함수 | 설명 | +|------|------| +| `setPrType(type)` | 외부에서 prType 설정 (switchFormMode에서 호출) | +| `getFormData()` | prType별 다른 JSON 구조 반환 (base에 `pr_type` 포함) | +| `addItem()` | 내역 행 추가 | +| `removeItem(index)` | 내역 행 삭제 | +| `formatMoney(val)` | 숫자 → 콤마 포맷 | +| `parseMoney(str)` | 콤마 문자열 → 정수 | +| `prVendorSearch(target, fieldName)` | 범용 거래처 Autocomplete 검색 | + +### 9.4 조회 화면 분기 (show.blade.php) + +```php +// show.blade.php에서 pr_ prefix로 분기 +@if(str_starts_with($approval->form?->code ?? '', 'pr_')) + @include('approvals.partials._purchase-request-show', ['content' => $content]) +@endif +``` + +`_purchase-request-show.blade.php`에서 `$content['pr_type']`으로 5종 분기 렌더링. + +--- + +## 10. 지출품의서 (pr_expense) + +### 10.1 추가 필드 + +| 필드 | 라벨 | 타입 | 필수 | +|------|------|------|------| +| `expense_category` | 지출항목 | text | 선택 | +| `usage_date` | 사용일자 | date | 선택 | +| `purpose` | 사용목적 | textarea | 필수 | + +### 10.2 내역 테이블 + +| 컬럼 | 라벨 | 타입 | +|------|------|------| +| `description` | 항목 | text | +| `amount` | 금액 | number (콤마 포맷) | +| `remark` | 비고 | text | + +### 10.3 Content JSON + +```json +{ + "pr_type": "pr_expense", + "write_date": "2026-03-06", + "department": "개발팀", + "writer_name": "홍길동", + "expense_category": "사무용품", + "usage_date": "2026-03-05", + "purpose": "업무용 모니터 구매", + "items": [ + { "description": "27인치 모니터", "amount": 350000, "remark": "LG전자" } + ], + "total_amount": 350000, + "attachment_memo": "견적서 첨부" +} +``` + +--- + +## 11. 계약체결품의서 (pr_contract) + +### 11.1 추가 필드 + +| 필드 | 라벨 | 타입 | 필수 | +|------|------|------|------| +| `contract_party` | 계약상대방 | text + Autocomplete | 필수 | +| `contract_party_biz_no` | 사업자번호 | text (자동) | - | +| `contract_content` | 계약내용 | textarea | 필수 | +| `contract_period_start` | 계약기간 시작 | date | 선택 | +| `contract_period_end` | 계약기간 종료 | date | 선택 | +| `contract_amount` | 계약금액 | number (콤마) | 필수 | +| `contract_conditions` | 주요조건 | textarea | 선택 | + +### 11.2 Content JSON + +```json +{ + "pr_type": "pr_contract", + "write_date": "2026-03-06", + "department": "경영지원팀", + "writer_name": "홍길동", + "contract_party": "(주)에이비씨", + "contract_party_biz_no": "123-45-67890", + "contract_content": "연간 IT 유지보수 계약", + "contract_period_start": "2026-04-01", + "contract_period_end": "2027-03-31", + "contract_amount": 12000000, + "contract_conditions": "월 1회 정기점검, 장애 발생 시 4시간 내 대응", + "attachment_memo": "계약서 초안 첨부" +} +``` + +### 11.3 특수 로직 + +- **거래처 검색**: `prVendorSearch(formData, 'contract_party')` — 계약상대방 필드에 Autocomplete 적용 +- 선택 시 `contract_party_biz_no` 자동 채움 + +--- + +## 12. 구매품의서 (pr_purchase) + +### 12.1 추가 필드 + +| 필드 | 라벨 | 타입 | 필수 | +|------|------|------|------| +| `vendor` | 납품업체 | text + Autocomplete | 선택 | +| `vendor_biz_no` | 사업자번호 | text (자동) | - | +| `delivery_date` | 납품예정일 | date | 선택 | +| `delivery_location` | 납품장소 | text | 선택 | + +### 12.2 내역 테이블 + +| 컬럼 | 라벨 | 타입 | +|------|------|------| +| `name` | 품목 | text | +| `spec` | 규격 | text | +| `quantity` | 수량 | number | +| `unit_price` | 단가 | number (콤마) | +| `amount` | 금액 | number (자동: 수량×단가) | +| `remark` | 비고 | text | + +### 12.3 Content JSON + +```json +{ + "pr_type": "pr_purchase", + "write_date": "2026-03-06", + "department": "생산팀", + "writer_name": "홍길동", + "vendor": "(주)공급사", + "vendor_biz_no": "987-65-43210", + "delivery_date": "2026-03-20", + "delivery_location": "본사 1층 창고", + "items": [ + { "name": "A4용지", "spec": "80g 500매", "quantity": 10, "unit_price": 25000, "amount": 250000, "remark": "" } + ], + "total_amount": 250000, + "attachment_memo": "" +} +``` + +### 12.4 특수 로직 + +- **금액 자동 계산**: `quantity × unit_price → amount` (x-effect 반응) +- **거래처 검색**: `prVendorSearch(formData, 'vendor')` — 납품업체 필드에 Autocomplete 적용 + +--- + +## 13. 출장품의서 (pr_trip) + +### 13.1 추가 필드 + +| 필드 | 라벨 | 타입 | 필수 | +|------|------|------|------| +| `destination` | 출장지 | text | 필수 | +| `trip_period_start` | 출장기간 시작 | date | 필수 | +| `trip_period_end` | 출장기간 종료 | date | 필수 | +| `trip_purpose` | 출장목적 | textarea | 필수 | + +### 13.2 일정표 (items) + +| 컬럼 | 라벨 | 타입 | +|------|------|------| +| `date` | 일자 | date | +| `schedule` | 일정 | text | +| `remark` | 비고 | text | + +### 13.3 경비 내역 (expenses) + +| 필드 | 라벨 | 타입 | +|------|------|------| +| `transport` | 교통비 | number (콤마) | +| `accommodation` | 숙박비 | number (콤마) | +| `meals` | 식비 | number (콤마) | +| `others` | 기타 | number (콤마) | +| (자동) | 합계 | number (합산) | + +### 13.4 Content JSON + +```json +{ + "pr_type": "pr_trip", + "write_date": "2026-03-06", + "department": "영업팀", + "writer_name": "홍길동", + "destination": "부산 해운대", + "trip_period_start": "2026-03-10", + "trip_period_end": "2026-03-11", + "trip_purpose": "거래처 방문 및 현장 점검", + "items": [ + { "date": "2026-03-10", "schedule": "거래처 미팅", "remark": "오전 10시" }, + { "date": "2026-03-11", "schedule": "현장 점검 및 복귀", "remark": "" } + ], + "expenses": { + "transport": 120000, + "accommodation": 80000, + "meals": 40000, + "others": 0 + }, + "total_amount": 240000, + "attachment_memo": "" +} +``` + +### 13.5 조회 화면 특수 구조 + +- **일정표**: 테이블 형태로 일자/일정/비고 렌더링 +- **경비 카드**: 교통비/숙박비/식비/기타 4개 항목 + 합계를 카드 그리드로 표시 + +--- + +## 14. 비용정산품의서 (pr_settlement) + +### 14.1 추가 필드 + +| 필드 | 라벨 | 타입 | 필수 | +|------|------|------|------| +| `settlement_period_start` | 정산기간 시작 | date | 선택 | +| `settlement_period_end` | 정산기간 종료 | date | 선택 | +| `payment_method` | 지급방법 | radio | 필수 | + +### 14.2 지급방법 옵션 + +| 값 | 라벨 | +|----|------| +| `corporate_card` | 법인카드 사용 | +| `personal_advance` | 개인 선지출 (환급 요청) | + +### 14.3 내역 테이블 + +| 컬럼 | 라벨 | 타입 | +|------|------|------| +| `date` | 사용일자 | date | +| `description` | 항목 | text | +| `amount` | 금액 | number (콤마) | +| `remark` | 비고 | text | + +### 14.4 Content JSON + +```json +{ + "pr_type": "pr_settlement", + "write_date": "2026-03-06", + "department": "개발팀", + "writer_name": "홍길동", + "settlement_period_start": "2026-02-01", + "settlement_period_end": "2026-02-28", + "payment_method": "personal_advance", + "items": [ + { "date": "2026-02-15", "description": "택시비", "amount": 25000, "remark": "야근 귀가" }, + { "date": "2026-02-20", "description": "회의 다과", "amount": 15000, "remark": "팀 미팅" } + ], + "total_amount": 40000, + "attachment_memo": "영수증 첨부" +} +``` + +### 14.5 조회 화면 특수 구조 + +- **지급방법 표시**: `corporate_card` → "법인카드 사용", `personal_advance` → "개인 선지출 (환급 요청)" +- 해당 라벨을 뱃지 형태로 표시 + +--- + +## 15. 결재 도장 테이블 (_approval-stamp-table.blade.php) + +### 15.1 구조 + +전통 한글 결재 양식의 도장 테이블을 구현한다. + +``` +┌──────┬────────┬────────┬────────┐ +│ │ 과장 │ 부장 │ 이사 │ ← 1행: 직급 헤더 +│ 결재 ├────────┼────────┼────────┤ +│ │ [승인] │ [대기] │ [대기] │ ← 2행: 서명/도장 영역 +│ ├────────┼────────┼────────┤ +│ │ 김과장 │ 박부장 │ 이이사 │ ← 3행: 이름 + 처리일 +│ │ 03/06 │ │ │ +└──────┴────────┴────────┴────────┘ +``` + +### 15.2 상태별 표시 + +| 상태 | approval_type | 표시 | 색상 | +|------|---------------|------|------| +| 승인 | `normal` | 빨간 원형 "승인" | `bg-red-500` | +| 전결 | `pre_decided` | 파란 원형 "전결" | `bg-blue-500` | +| 반려 | - | 빨간 원형 "반려" | `bg-red-500` | +| 보류 | - | 주황 원형 "보류" | `bg-amber-500` | +| 건너뜀 | - | 회색 "-" | `bg-gray-300` | + +--- + +## 16. 결재선 편집기 (_approval-line-editor.blade.php) + +### 16.1 2패널 구조 + +``` +┌─────────────────────┬─────────────────────┐ +│ 인원 목록 │ 결재선 │ +│ │ │ +│ [검색 input] │ [템플릿 선택 ▼] │ +│ │ │ +│ ▼ 개발팀 │ ① 김과장 (결재) [✗] │ +│ 홍길동 과장 [+] │ ② 박부장 (합의) [✗] │ +│ 김영희 대리 [+] │ ③ 이대리 (참조) [✗] │ +│ │ │ +│ ▼ 경영지원팀 │ (드래그로 순서 변경) │ +│ 박부장 부장 [+] │ │ +│ │ │ +├─────────────────────┴─────────────────────┤ +│ 결재: 1명 합의: 1명 참조: 1명 합계: 3명 │ +└───────────────────────────────────────────┘ +``` + +### 16.2 기능 + +| 기능 | 설명 | +|------|------| +| **인원 검색** | 이름/부서 실시간 검색 | +| **부서별 접기** | 부서 헤더 클릭으로 인원 접기/펼치기 | +| **드래그 정렬** | SortableJS로 결재선 순서 변경 | +| **유형 선택** | 각 단계별 approval/agreement/reference 선택 | +| **템플릿 로드** | 저장된 결재선 템플릿 드롭다운 | + +### 16.3 데이터 소스 + +``` +API: /api/admin/tenant-users/list + +응답: +[ + { + "department_id": 1, + "department_name": "개발팀", + "users": [ + { "id": 10, "name": "홍길동", "position": "과장", "job_title": "팀장" } + ] + } +] +``` + +### 16.4 Hidden Inputs (form 전송) + +```html + + + + +``` + +--- + +## 17. ApprovalForm 모델 + +### 17.1 테이블 스키마 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | 테넌트 격리 | +| `name` | VARCHAR | 양식명 (예: "휴가신청서") | +| `code` | VARCHAR UNIQUE | 양식 코드 (예: `leave`) | +| `category` | ENUM | `request`, `expense`, `certificate`, `expense_estimate` | +| `template` | JSON | 필드 정의 메타데이터 | +| `body_template` | LONGTEXT NULL | HTML 본문 템플릿 | +| `is_active` | BOOLEAN | 활성 여부 | + +### 17.2 카테고리 + +#### DB 카테고리 (ApprovalForm.category) + +| 카테고리 | 설명 | 양식 코드 | +|---------|------|----------| +| `request` | 신청서 | `leave`, `attendance_request`, `reason_report` | +| `expense` | 지출결의서 | `expense` | +| `certificate` | 증명서/서류 | `employment_cert`, `career_cert`, `appointment_cert`, `resignation` | +| `expense_estimate` | 품의서 | `pr_expense`, `pr_contract`, `pr_purchase`, `pr_trip`, `pr_settlement` | + +#### UI 분류 (formCategoryMap — 2단계 선택용) + +| UI 분류 | 양식 코드 | +|---------|----------| +| 일반 | `BUSINESS_DRAFT` | +| 인사/근태 | `leave`, `attendance_request`, `reason_report`, `resignation` | +| 증명서 | `employment_cert`, `career_cert`, `appointment_cert` | +| 품의 | `pr_expense`, `pr_contract`, `pr_purchase`, `pr_trip`, `pr_settlement` | +| 재무 | `expense` | + +> **참고**: DB 카테고리와 UI 분류는 별도 매핑이다. DB는 `approval_forms.category` ENUM이고, UI 분류는 JavaScript `formCategoryMap` 객체로 정의된다. + +--- + +## 18. 양식별 저장/조회 흐름 + +### 18.1 저장 (create/update) + +``` +사용자 입력 + ↓ +getFormData() (JavaScript) + ↓ +POST /api/admin/approvals + body: { form_id, title, content: {...}, body, steps: [...] } + ↓ +ApprovalService::createApproval() + ↓ +Approval.content = JSON encode → DB 저장 +``` + +### 18.2 조회 (show) + +``` +GET /approval-mgmt/{id} + ↓ +ApprovalController::show() + ↓ +Blade: show.blade.php + ↓ +양식 코드별 분기: + leave → (본문에 인라인 표시) + expense → @include('_expense-show') + pr_* → @include('_purchase-request-show') ← str_starts_with 매칭 + employment_cert → @include('_certificate-show') + career_cert → @include('_career-cert-show') + appointment_cert → @include('_appointment-cert-show') + resignation → @include('_resignation-show') +``` + +> **품의서 분기**: `str_starts_with($approval->form?->code ?? '', 'pr_')` 조건으로 5종 모두 단일 include로 처리. `_purchase-request-show.blade.php` 내부에서 `$content['pr_type']`으로 세부 분기. + +--- + +## 관련 문서 + +- [README.md](README.md) — 결재관리 시스템 전체 개요 +- [워크플로우 상세](workflows.md) — 승인/반려/회수/보류/전결 흐름 +- [API 명세](api-reference.md) — 엔드포인트별 요청/응답 +- [UI 화면 구성](ui-screens.md) — 화면별 UI 요소 및 동작 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/approvals/ui-screens.md b/features/approvals/ui-screens.md new file mode 100644 index 0000000..f81b9ae --- /dev/null +++ b/features/approvals/ui-screens.md @@ -0,0 +1,381 @@ +# 결재관리 UI 화면 구성 + +> **작성일**: 2026-02-28 +> **상태**: Phase 2 구현 완료 +> **기술**: Blade + HTMX + Alpine.js + Tailwind CSS +> **관련**: [README.md](README.md) | [워크플로우](workflows.md) | [API 명세](api-reference.md) + +--- + +## 1. 개요 + +결재관리 화면은 MNG(관리자 웹)에서 Blade 템플릿으로 구현되며, API 호출은 `fetch()`를 사용한다. + +### 1.1 파일 구조 + +``` +resources/views/approvals/ +├── drafts.blade.php ← 기안함 (목록) +├── pending.blade.php ← 결재 대기함 (목록) +├── completed.blade.php ← 처리 완료함 (목록) +├── references.blade.php ← 참조함 (목록) +├── create.blade.php ← 기안 작성 +├── edit.blade.php ← 기안 수정 +├── show.blade.php ← 상세 조회 + 결재 처리 +└── partials/ + ├── _status-badge.blade.php ← 상태 뱃지 컴포넌트 + └── _step-progress.blade.php ← 결재 단계 진행 표시 +``` + +--- + +## 2. 목록 화면 + +### 2.1 기안함 (`/approval-mgmt/drafts`) + +내가 기안한 모든 문서를 표시한다. + +**UI 구성:** + +``` +┌──────────────────────────────────────────────────────────┐ +│ 기안함 [+ 새 기안] │ +├──────────────────────────────────────────────────────────┤ +│ [검색] [상태 필터 ▼] [긴급만 □] [날짜 범위] │ +├──────────────────────────────────────────────────────────┤ +│ 문서번호 │ 제목 │ 양식 │ 상태 │ 기안일 │ +│ APR-260228-001│ 휴가 신청 │ 휴가서 │ 🟢완료 │ 02-28 │ +│ APR-260228-002│ 출장 보고 │ 출장서 │ 🔵진행 │ 02-28 │ +│ APR-260227-001│ 경비 청구 │ 경비서 │ ⬜임시 │ 02-27 │ +├──────────────────────────────────────────────────────────┤ +│ [◀ 이전] 1 / 3 [다음 ▶] │ +└──────────────────────────────────────────────────────────┘ +``` + +**상태 필터:** 전체, 임시저장, 진행, 완료, 반려, 회수, 보류 + +--- + +### 2.2 결재 대기함 (`/approval-mgmt/pending`) + +내가 현재 결재해야 할 문서를 표시한다. + +**UI 구성:** + +``` +┌──────────────────────────────────────────────────────────┐ +│ 결재 대기함 [뱃지: 3건] │ +├──────────────────────────────────────────────────────────┤ +│ 문서번호 │ 제목 │ 기안자 │ 양식 │ 상신일 │ +│ 🔴 APR-260..│ 긴급 승인 │ 홍길동 │ 구매서 │ 02-28 │ +│ APR-260..│ 휴가 신청 │ 김영희 │ 휴가서 │ 02-27 │ +└──────────────────────────────────────────────────────────┘ +``` + +> 긴급 문서는 🔴 아이콘과 함께 상단에 표시 + +--- + +### 2.3 참조함 (`/approval-mgmt/references`) + +내가 참조자로 지정된 문서를 표시한다. + +**UI 구성:** + +``` +┌──────────────────────────────────────────────────────────┐ +│ 참조함 │ +├──────────────────────────────────────────────────────────┤ +│ [전체] [미열람 (5)] [열람완료] │ +├──────────────────────────────────────────────────────────┤ +│ 문서번호 │ 제목 │ 기안자 │ 상태 │ 열람 │ +│ APR-260228-001│ 회의록 │ 박부장 │ 🟢완료 │ ❌미열람│ +│ APR-260227-003│ 인사발령 │ 이팀장 │ 🔵진행 │ ✅열람 │ +└──────────────────────────────────────────────────────────┘ +``` + +**열람 추적:** +- 문서 클릭 시 `mark-read` API가 자동 호출된다 +- 미열람/열람완료 탭으로 필터링 가능 +- 미열람 건수가 뱃지로 표시된다 + +--- + +## 3. 상세 화면 (`/approval-mgmt/{id}`) + +### 3.1 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 결재 상세 [수정] [목록으로] │ +│ APR-260228-001 │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ 상태: [🔵 진행] [🔴 긴급] │ +│ 양식: 휴가신청서 기안자: 홍길동 │ +│ 기안일: 2026-02-28 10:05 완료일: - │ +│ 원본 문서: APR-260225-003 (재기안 시 표시) │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 회수 사유 (cancelled 상태에서만) │ │ +│ │ 내용 수정이 필요하여 회수합니다. │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ 제목: 2월 연차 사용 신청 │ +│ 본문: 2월 27일~28일 연차 사용합니다... │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ 결재 진행 │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ [결재 단계 프로그레스 바] │ │ +│ │ ✓김과장(승인) → ●박부장(대기) → ③이사(대기) │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ 결재 의견 │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ ✓ 김과장 2026-02-28 11:00 │ │ +│ │ 승인합니다. │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ 결재 처리 (현재 결재자에게만 표시) │ +│ [결재 의견 textarea] │ +│ [승인] [반려] [보류] [전결] │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ 보류 해제 (on_hold + 보류한 본인에게만) │ +│ [보류 해제] │ +├──────────────────────────────────────────────────────────┤ +│ 회수 (기안자 + pending/on_hold) │ +│ [회수 사유 textarea] │ +│ [결재 회수] │ +├──────────────────────────────────────────────────────────┤ +│ 복사 재기안 (기안자 + approved/rejected/cancelled) │ +│ [복사하여 재기안] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 3.2 조건부 섹션 표시 + +| 섹션 | 표시 조건 | +|------|----------| +| **수정 버튼** | 기안자 + `draft`/`rejected` | +| **회수 사유** | `cancelled` + `recall_reason` 존재 | +| **원본 문서 링크** | `parent_doc_id` 존재 (재기안 문서) | +| **결재 처리** | `pending` + 현재 결재자 | +| **보류 해제** | `on_hold` + 보류한 본인 | +| **회수** | 기안자 + `pending`/`on_hold` | +| **복사 재기안** | 기안자 + `approved`/`rejected`/`cancelled` | + +--- + +## 4. 파셜 컴포넌트 + +### 4.1 상태 뱃지 (`_status-badge.blade.php`) + +문서 상태를 색상 뱃지로 표시한다. + +| 상태 | 라벨 | 스타일 | +|------|------|--------| +| `draft` | 임시저장 | `bg-gray-100 text-gray-700` | +| `pending` | 진행 | `bg-blue-100 text-blue-700` | +| `approved` | 완료 | `bg-green-100 text-green-700` | +| `rejected` | 반려 | `bg-red-100 text-red-700` | +| `cancelled` | 회수 | `bg-yellow-100 text-yellow-700` | +| `on_hold` | 보류 | `bg-amber-100 text-amber-700` | + +--- + +### 4.2 결재 단계 프로그레스 (`_step-progress.blade.php`) + +결재선의 각 단계를 가로 프로그레스 바로 표시한다. + +**단계 아이콘:** + +| 상태 | 아이콘 | 배경색 | 텍스트색 | +|------|--------|--------|---------| +| `approved` (normal) | ✓ | `bg-green-500` | white | +| `approved` (pre_decided) | ⚡ | `bg-indigo-500` | white | +| `rejected` | ✗ | `bg-red-500` | white | +| `on_hold` | ⏸ | `bg-amber-400` | white | +| `skipped` | — | `bg-gray-300` | gray | +| `pending` (현재 차례) | 번호 | `bg-blue-500` | white | +| `pending` (대기) | 번호 | `bg-gray-200` | gray | + +**레이아웃:** + +``` +┌──────────────────────────────────────────────────────┐ +│ │ +│ ✓ ──── ⚡ ──── — ──── — ──── ● ──── 3 │ +│ 김과장 박부장 이사장 팀장 최대리 참조자 │ +│ 경영팀 경영팀 대표실 개발팀 개발팀 인사팀 │ +│ (승인) (전결) (건너뜀)(건너뜀)(대기) (참조) │ +│ │ +└──────────────────────────────────────────────────────┘ +``` + +**특수 표시:** +- **전결** step: ⚡ 아이콘 + "전결" 라벨 (남색) +- **보류** step: ⏸ 아이콘 + "보류" 라벨 (노란색) +- **건너뜀** step: 이름에 취소선 (line-through) +- **참조** step: 별도 구분 없이 동일 프로그레스 바에 표시 +- **연결선**: 단계 사이 가로선 (`border-t-2`) + +--- + +## 5. 결재 처리 인터랙션 + +### 5.1 승인 + +``` +[승인 버튼 클릭] + → confirm("승인하시겠습니까?") + → POST /api/admin/approvals/{id}/approve + body: { comment: "의견 텍스트" } + → 성공 시: 토스트("승인되었습니다") + 페이지 리로드 +``` + +### 5.2 반려 + +``` +[반려 버튼 클릭] + → comment 빈 값 체크 → 경고 토스트("반려 시 사유를 입력해주세요") + → confirm("반려하시겠습니까?") + → POST /api/admin/approvals/{id}/reject + body: { comment: "사유" } + → 성공 시: 토스트("반려되었습니다") + 페이지 리로드 +``` + +### 5.3 보류 + +``` +[보류 버튼 클릭] + → comment 빈 값 체크 → 경고 토스트("보류 사유를 입력해주세요") + → confirm("이 결재를 보류하시겠습니까?") + → POST /api/admin/approvals/{id}/hold + body: { comment: "사유" } + → 성공 시: 토스트("보류되었습니다") + 페이지 리로드 +``` + +### 5.4 전결 + +``` +[전결 버튼 클릭] + → confirm("전결 처리하시겠습니까?\n이후 모든 결재를 건너뛰고 문서를 최종 승인합니다.") + → POST /api/admin/approvals/{id}/pre-decide + body: { comment: "의견(선택)" } + → 성공 시: 토스트("전결 처리되었습니다") + 페이지 리로드 +``` + +### 5.5 보류 해제 + +``` +[보류 해제 버튼 클릭] + → confirm("보류를 해제하시겠습니까?") + → POST /api/admin/approvals/{id}/release-hold + → 성공 시: 토스트("보류가 해제되었습니다") + 페이지 리로드 +``` + +### 5.6 회수 + +``` +[결재 회수 버튼 클릭] + → confirm("결재를 회수하시겠습니까? 이 작업은 되돌릴 수 없습니다.") + → POST /api/admin/approvals/{id}/cancel + body: { recall_reason: "사유(선택)" } + → 성공 시: 토스트("결재가 회수되었습니다") + 페이지 리로드 +``` + +### 5.7 복사 재기안 + +``` +[복사하여 재기안 버튼 클릭] + → confirm("이 문서를 복사하여 새 결재를 작성하시겠습니까?") + → POST /api/admin/approvals/{id}/copy + → 성공 시: 토스트("문서가 복사되었습니다") + → /approval-mgmt/{newId}/edit로 이동 +``` + +--- + +## 6. 결재 의견 표시 + +상세 페이지에서 결재 의견이 있는 step을 카드 형태로 표시한다. + +``` +┌──────────────────────────────────────┐ +│ ✓ 김과장 2026-02-28 11:00 │ +│ 승인합니다. │ +├──────────────────────────────────────┤ +│ ⚡ 박부장 (전결) 2026-02-28 14:00 │ +│ 전결 처리합니다. │ +├──────────────────────────────────────┤ +│ ⏸ 이사장 (보류) 2026-02-28 15:00 │ +│ 추가 자료 검토 필요 │ +├──────────────────────────────────────┤ +│ ✗ 팀장 2026-02-28 16:00 │ +│ 예산 초과로 반려합니다. │ +└──────────────────────────────────────┘ +``` + +**아이콘 색상:** +- ✓ 승인: 녹색 (`bg-green-100 text-green-600`) +- ⚡ 전결: 남색 (`bg-indigo-100 text-indigo-600`) +- ⏸ 보류: 노란색 (`bg-amber-100 text-amber-600`) +- ✗ 반려: 적색 (`bg-red-100 text-red-600`) + +--- + +## 7. 참조함 열람 추적 UI + +### 7.1 탭 필터 + +``` +[전체] [미열람 (5)] [열람완료] +``` + +- 탭 클릭 시 `is_read` 파라미터로 API 재호출 +- 미열람 탭에 건수 뱃지 표시 + +### 7.2 열람 상태 표시 + +| 상태 | 표시 | +|------|------| +| 미열람 | `bg-red-100 text-red-700` "미열람" | +| 열람완료 | `bg-green-100 text-green-700` "열람완료" | + +### 7.3 자동 열람 처리 + +문서 행 클릭 시: +1. `mark-read` API 호출 (비동기) +2. 상세 페이지로 이동 + +--- + +## 8. 버튼 스타일 가이드 + +| 버튼 | 색상 | Tailwind 클래스 | +|------|------|----------------| +| 승인 | 녹색 | `bg-green-600 hover:bg-green-700` | +| 반려 | 적색 | `bg-red-600 hover:bg-red-700` | +| 보류 | 노란색 | `bg-amber-500 hover:bg-amber-600` | +| 전결 | 남색 | `bg-indigo-600 hover:bg-indigo-700` | +| 보류 해제 | 노란색 | `bg-amber-500 hover:bg-amber-600` | +| 회수 | 노란색 | `bg-yellow-500 hover:bg-yellow-600` | +| 복사 재기안 | 회색 | `bg-gray-600 hover:bg-gray-700` | +| 수정 | 회색 | `bg-gray-600 hover:bg-gray-700` | + +--- + +## 관련 문서 + +- [README.md](README.md) — 시스템 전체 개요 +- [워크플로우 상세](workflows.md) — 각 동작의 상세 흐름 +- [API 명세](api-reference.md) — 엔드포인트별 요청/응답 + +--- + +**최종 업데이트**: 2026-02-28 diff --git a/features/approvals/workflows.md b/features/approvals/workflows.md new file mode 100644 index 0000000..202d525 --- /dev/null +++ b/features/approvals/workflows.md @@ -0,0 +1,565 @@ +# 결재관리 워크플로우 상세 + +> **작성일**: 2026-02-28 +> **상태**: Phase 2 구현 완료 +> **관련**: [README.md](README.md) | [API 명세](api-reference.md) | [UI 화면](ui-screens.md) + +--- + +## 1. 개요 + +이 문서는 결재관리 시스템의 각 동작(Action)에 대한 상세 워크플로우를 정의한다. +모든 워크플로우는 `ApprovalService`에서 트랜잭션으로 처리된다. + +### 1.1 용어 정의 + +| 용어 | 설명 | +|------|------| +| **기안자** | 결재 문서를 작성한 사람 (`drafter_id`) | +| **현재 결재자** | 결재선에서 현재 차례인 사람 (가장 작은 `step_order`의 `pending` step) | +| **결재자** | `step_type`이 `approval` 또는 `agreement`인 참여자 | +| **참조자** | `step_type`이 `reference`인 참여자 (의사결정 권한 없음) | +| **전결** | 현재 결재자가 이후 모든 결재를 건너뛰고 즉시 최종 승인 | + +--- + +## 2. 기안 작성 (createApproval) + +### 2.1 흐름 + +``` +사용자 → [양식 선택] → [제목/본문 입력] → [결재선 설정] → [임시저장] + │ + ▼ + 새 Approval 생성 + status = 'draft' + current_step = 0 +``` + +### 2.2 조건 + +- 모든 로그인 사용자가 작성 가능 +- `form_id` 필수 (양식 선택) +- 결재선(steps)은 저장 시 선택사항 (상신 시 필수) + +### 2.3 처리 로직 + +1. 문서번호 자동 채번 (`APR-YYMMDD-001` 형식) +2. `numbering_sequences` 테이블로 일일 순번 관리 +3. 결재선 설정 시 `approval_steps` 저장 + 사용자 정보 스냅샷 (이름, 부서, 직급) +4. `status = 'draft'`, `current_step = 0` + +--- + +## 3. 상신 (submit) + +### 3.1 흐름 + +``` +기안자 → [상신 버튼] → 유효성 검사 → 결재선 검사 → 상신 완료 + │ + ▼ + status = 'pending' + current_step = 1 + drafted_at = now() +``` + +### 3.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `draft` 또는 `rejected` | +| 결재선 | 결재/합의 step 1명 이상 필수 | +| 요청자 | 기안자만 | + +### 3.3 처리 로직 + +1. `isSubmittable()` 검증 → `draft` 또는 `rejected`인지 확인 +2. 결재/합의 step 존재 확인 +3. **반려 후 재상신인 경우**: 모든 step을 `pending`으로 초기화 (comment, acted_at도 초기화) +4. `status → pending`, `drafted_at → now()`, `current_step → 1` + +### 3.4 반려 후 재상신 + +``` +rejected 문서 + │ + ├── 기안자가 내용 수정 (updateApproval) + │ + └── 상신 (submit) + ├── 모든 steps → pending (초기화) + ├── status → pending + └── current_step → 1 (처음부터 다시) +``` + +> 반려 후 재상신 시 결재선이 초기화되므로, 이전 결재 의견(comment)은 사라진다. + +--- + +## 4. 승인 (approve) + +### 4.1 흐름 + +``` +현재 결재자 → [의견 입력(선택)] → [승인 버튼] + │ + ┌──────────┴──────────┐ + │ 현재 step │ + │ status → 'approved' │ + │ comment → (입력값) │ + │ acted_at → now() │ + └──────────┬──────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + 다음 pending step 있음 마지막 결재자 + │ │ + current_step 갱신 status → 'approved' + (다음 순서 결재자 대기) completed_at → now() +``` + +### 4.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `pending` | +| 요청자 | 현재 차례 결재자 (`approver_id === auth()->id()`) | + +### 4.3 처리 로직 + +1. `isActionable()` 검증 → `pending` 상태인지 확인 +2. `getCurrentApproverStep()` → 현재 차례 step 조회 +3. 현재 step → `approved` + comment + acted_at +4. 다음 pending 결재/합의 step 조회 + - **있으면**: `current_step` 갱신 + - **없으면**: 문서 `approved` + `completed_at` + +### 4.4 순차결재 순서 결정 + +``` +step_order = 1 (결재) → step_order = 2 (합의) → step_order = 3 (결재) + │ │ │ + 1번째 승인 → 2번째 승인 → 3번째 승인 → 문서 완료 +``` + +> 결재와 합의는 동일한 순차 흐름을 따른다. `step_order` 순서대로 처리된다. + +--- + +## 5. 반려 (reject) + +### 5.1 흐름 + +``` +현재 결재자 → [반려 사유 입력(필수)] → [반려 버튼] + │ + ┌──────────┴──────────┐ + │ 현재 step │ + │ status → 'rejected' │ + │ comment → (사유) │ + │ acted_at → now() │ + └──────────┬──────────┘ + │ + ▼ + 문서 status → 'rejected' + completed_at → now() +``` + +### 5.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `pending` | +| 요청자 | 현재 차례 결재자 | +| 반려 사유 | **필수** (빈 값 불가) | + +### 5.3 처리 로직 + +1. `isActionable()` 검증 +2. 현재 결재자 확인 +3. 반려 사유 빈 값 체크 +4. 현재 step → `rejected` + comment + acted_at +5. 문서 → `rejected` + completed_at + +### 5.4 반려 후 가능한 동작 + +``` +rejected 문서 + │ + ├── 기안자가 수정 → 재상신 (submit) + │ └── 결재선 초기화, 처음부터 다시 진행 + │ + └── 기안자가 복사 재기안 (copyForRedraft) + └── 새 문서 생성 (draft), 원본은 그대로 유지 +``` + +--- + +## 6. 회수 (cancel) + +### 6.1 흐름 + +``` +기안자 → [회수 사유 입력(선택)] → [회수 버튼] + │ + ┌──────────┴──────────┐ + │ 회수 가능 여부 판단 │ + │ (첫 결재자 미처리?) │ + └──────────┬──────────┘ + │ + ┌───────────┴───────────┐ + │ │ + 첫 결재자 첫 결재자 이미 + pending/on_hold 승인/반려 + │ │ + 회수 진행 회수 불가 + │ (에러 반환) + ▼ + 모든 pending/on_hold steps → 'skipped' + 문서 status → 'cancelled' + recall_reason → (입력값) + completed_at → now() +``` + +### 6.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `pending` 또는 `on_hold` | +| 요청자 | 기안자만 (`drafter_id === auth()->id()`) | +| 첫 결재자 상태 | `pending` 또는 `on_hold` (이미 처리했으면 불가) | + +### 6.3 회수 가능 판단 로직 + +```php +// 1단계: 문서 상태 확인 +$approval->isCancellable() // pending 또는 on_hold + +// 2단계: 기안자 확인 +$approval->drafter_id === auth()->id() + +// 3단계: 첫 결재자 상태 확인 +$firstStep = steps.approvalOnly().orderBy('step_order').first() +$firstStep->status === 'pending' || 'on_hold' // 미처리 상태여야 함 +``` + +### 6.4 처리 로직 + +1. `isCancellable()` 검증 → `pending` 또는 `on_hold` +2. 기안자 확인 +3. 첫 번째 결재/합의 step의 상태 확인 → `pending`/`on_hold`이 아니면 거부 +4. 모든 `pending`/`on_hold` steps → `skipped` +5. 문서 → `cancelled` + `recall_reason` + `completed_at` + +--- + +## 7. 보류 (hold) + +### 7.1 흐름 + +``` +현재 결재자 → [보류 사유 입력(필수)] → [보류 버튼] + │ + ┌──────────┴──────────┐ + │ 현재 step │ + │ status → 'on_hold' │ + │ comment → (사유) │ + │ acted_at → now() │ + └──────────┬──────────┘ + │ + ▼ + 문서 status → 'on_hold' +``` + +### 7.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `pending` (`isHoldable()`) | +| 요청자 | 현재 차례 결재자 | +| 보류 사유 | **필수** (빈 값 불가) | + +### 7.3 처리 로직 + +1. `isHoldable()` 검증 → `pending` 상태인지 확인 +2. `getCurrentApproverStep()` → 현재 차례 step 조회 +3. 현재 결재자 확인 (`approver_id === auth()->id()`) +4. 보류 사유 빈 값 체크 +5. 현재 step → `on_hold` + comment + acted_at +6. 문서 → `on_hold` + +### 7.4 보류 상태의 영향 + +``` +on_hold 상태에서: +├── 다른 결재자는 아무 동작 불가 (결재 흐름 중단) +├── 기안자는 회수 가능 (첫 결재자가 미처리 상태이면) +└── 보류한 결재자만 보류 해제 가능 +``` + +--- + +## 8. 보류 해제 (releaseHold) + +### 8.1 흐름 + +``` +보류한 결재자 → [보류 해제 버튼] + │ + ┌──────────┴──────────┐ + │ on_hold step │ + │ status → 'pending' │ + │ comment → null │ + │ acted_at → null │ + └──────────┬──────────┘ + │ + ▼ + 문서 status → 'pending' + (결재 흐름 재개) +``` + +### 8.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `on_hold` (`isHoldReleasable()`) | +| 요청자 | 보류한 본인만 (`on_hold` step의 `approver_id === auth()->id()`) | + +### 8.3 처리 로직 + +1. `isHoldReleasable()` 검증 → `on_hold` 상태인지 확인 +2. `on_hold` 상태인 step 조회 +3. 해당 step의 `approver_id`가 현재 사용자인지 확인 +4. step → `pending` + comment/acted_at 초기화 +5. 문서 → `pending` + +--- + +## 9. 전결 (preDecide) + +### 9.1 흐름 + +``` +현재 결재자 → [의견 입력(선택)] → [전결 버튼] → 확인 팝업 + │ + ┌──────────┴──────────┐ + │ 현재 step │ + │ status → 'approved' │ + │ approval_type → │ + │ 'pre_decided' │ + │ comment → (입력값) │ + │ acted_at → now() │ + └──────────┬──────────┘ + │ + ▼ + 이후 모든 pending + approval/agreement steps + → status = 'skipped' + │ + ▼ + 문서 status → 'approved' + completed_at → now() +``` + +### 9.2 조건 + +| 조건 | 설명 | +|------|------| +| 문서 상태 | `pending` (`isActionable()`) | +| 요청자 | 현재 차례 결재자 | + +### 9.3 처리 로직 + +1. `isActionable()` 검증 +2. `getCurrentApproverStep()` → 현재 차례 step 조회 +3. 현재 결재자 확인 +4. 현재 step → `approved` + `approval_type = 'pre_decided'` + comment + acted_at +5. 이후 모든 pending 결재/합의 steps → `skipped` +6. 문서 → `approved` + `completed_at` + +### 9.4 전결 예시 + +``` +step_order=1 (이사장, 결재) → approved (normal) +step_order=2 (부장, 결재) → approved (pre_decided) ← 여기서 전결 +step_order=3 (과장, 합의) → skipped (전결로 건너뜀) +step_order=4 (팀장, 결재) → skipped (전결로 건너뜀) +step_order=5 (참조자, 참조) → (참조는 영향 없음, 그대로 유지) + +문서 → approved, completed_at = now() +``` + +> 전결은 결재/합의 step만 건너뛴다. 참조 step은 영향받지 않는다. + +--- + +## 10. 복사 재기안 (copyForRedraft) + +### 10.1 흐름 + +``` +기안자 → [복사하여 재기안 버튼] + │ + ▼ + ┌─────────────────────────────┐ + │ 원본 문서에서 복사 │ + │ ├── form_id │ + │ ├── title │ + │ ├── content (양식 데이터) │ + │ ├── body │ + │ ├── is_urgent │ + │ ├── department_id │ + │ └── 결재선 (모두 pending) │ + └─────────────┬───────────────┘ + │ + ▼ + 새 문서 생성 (status = 'draft') + parent_doc_id = 원본.id + 새 문서번호 채번 + │ + ▼ + 수정 페이지로 이동 + (/approval-mgmt/{newId}/edit) +``` + +### 10.2 조건 + +| 조건 | 설명 | +|------|------| +| 원본 문서 상태 | `approved`, `rejected`, `cancelled` (`isCopyable()`) | +| 요청자 | 기안자만 (`drafter_id === auth()->id()`) | + +### 10.3 처리 로직 + +1. `isCopyable()` 검증 → `approved`/`rejected`/`cancelled` 중 하나 +2. 기안자 확인 +3. 새 문서 생성: + - 새 문서번호 채번 + - 원본의 양식, 제목, 내용, 본문, 긴급 여부, 부서 복사 + - `parent_doc_id = 원본.id` + - `status = 'draft'`, `current_step = 0` +4. 결재선 복사: 원본의 모든 steps를 새 문서에 복사 (모두 `pending` 상태) +5. 새 문서의 edit 페이지로 리다이렉트 + +### 10.4 원본과의 관계 + +``` +원본 문서 (approved/rejected/cancelled) + │ + └── parent_doc_id로 연결 + │ + ▼ + 새 문서 (draft) + ├── 상세 페이지에서 "원본 문서" 링크 표시 + └── 기안자가 내용 수정 후 상신 가능 +``` + +--- + +## 11. 참조 열람 추적 (markAsRead) + +### 11.1 흐름 + +``` +참조자 → [참조함 목록에서 문서 클릭] + │ + ├── markAsRead API 호출 + │ ├── is_read → true + │ └── read_at → now() + │ + └── 상세 페이지로 이동 +``` + +### 11.2 조건 + +| 조건 | 설명 | +|------|------| +| 요청자 | 해당 문서의 참조자 (`step_type = 'reference'`) | + +### 11.3 처리 로직 + +1. 현재 사용자의 참조 step 조회 +2. `is_read = false`인 step → `is_read = true`, `read_at = now()` +3. 이미 열람한 경우 중복 업데이트 없음 (`where('is_read', false)`) + +--- + +## 12. 전체 상태 전이 요약 + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ │ +│ draft ──submit()──→ pending ──approve()──→ (다음 step 또는) │ +│ ▲ │ │ approved │ +│ │ │ │ │ +│ │ │ ├──reject()──→ rejected │ +│ │ │ │ │ │ +│ │ │ │ ├── 수정 → submit() │ +│ │ │ │ │ (재상신, draft X) │ +│ │ │ │ │ │ +│ │ │ │ └── copyForRedraft() │ +│ │ │ │ → 새 draft 생성 │ +│ │ │ │ │ +│ │ │ ├──hold()──→ on_hold │ +│ │ │ │ │ │ +│ │ │ │ ├── releaseHold() │ +│ │ │ │ │ → pending 복원 │ +│ │ │ │ │ │ +│ │ │ │ └── cancel() (기안자) │ +│ │ │ │ → cancelled │ +│ │ │ │ │ +│ │ │ ├──preDecide()──→ approved │ +│ │ │ │ (이후 steps → skipped) │ +│ │ │ │ │ +│ │ │ └──cancel()──→ cancelled │ +│ │ │ (기안자, 첫결재자 미처리 시) │ +│ │ │ │ │ +│ │ │ └── copyForRedraft() │ +│ │ │ → 새 draft 생성 │ +│ │ │ │ +│ │ └── approved ──copyForRedraft() │ +│ │ → 새 draft 생성 │ +│ │ │ +│ └── updateApproval() (draft/rejected 상태에서 수정) │ +│ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 13. 에러 케이스 정리 + +| 동작 | 에러 조건 | 에러 메시지 | +|------|----------|------------| +| submit | 상태가 draft/rejected 아님 | "상신할 수 없는 상태입니다." | +| submit | 결재선 없음 | "결재선을 설정해주세요." | +| approve | 상태가 pending 아님 | "승인할 수 없는 상태입니다." | +| approve | 현재 결재자 아님 | "현재 결재자가 아닙니다." | +| reject | 상태가 pending 아님 | "반려할 수 없는 상태입니다." | +| reject | 사유 미입력 | "반려 사유를 입력해주세요." | +| cancel | 상태가 pending/on_hold 아님 | "회수할 수 없는 상태입니다." | +| cancel | 기안자 아님 | "기안자만 회수할 수 있습니다." | +| cancel | 첫 결재자 이미 처리 | "첫 번째 결재자가 이미 처리하여 회수할 수 없습니다." | +| hold | 상태가 pending 아님 | "보류할 수 없는 상태입니다." | +| hold | 현재 결재자 아님 | "현재 결재자가 아닙니다." | +| hold | 사유 미입력 | "보류 사유를 입력해주세요." | +| releaseHold | 상태가 on_hold 아님 | "보류 해제할 수 없는 상태입니다." | +| releaseHold | 보류한 본인 아님 | "보류한 결재자만 해제할 수 있습니다." | +| preDecide | 상태가 pending 아님 | "전결할 수 없는 상태입니다." | +| preDecide | 현재 결재자 아님 | "현재 결재자가 아닙니다." | +| copyForRedraft | 상태가 approved/rejected/cancelled 아님 | "복사할 수 없는 상태입니다." | +| copyForRedraft | 기안자 아님 | "기안자만 복사할 수 있습니다." | +| update | 상태가 draft/rejected 아님 | "수정할 수 없는 상태입니다." | +| delete | 상태가 draft 아님 | "삭제할 수 없는 상태입니다." | + +--- + +## 관련 문서 + +- [README.md](README.md) — 시스템 전체 개요 +- [API 명세](api-reference.md) — 엔드포인트별 요청/응답 +- [UI 화면 구성](ui-screens.md) — 화면별 동작 + +--- + +**최종 업데이트**: 2026-02-28 diff --git a/features/barobill-kakaotalk/esign-notification-guide.md b/features/barobill-kakaotalk/esign-notification-guide.md new file mode 100644 index 0000000..dce2345 --- /dev/null +++ b/features/barobill-kakaotalk/esign-notification-guide.md @@ -0,0 +1,250 @@ +# 전자계약 알림톡/SMS 환경별 설정 가이드 + +> **작성일**: 2026-02-27 +> **상태**: 운영 중 +> **대상 프로젝트**: MNG + +--- + +## 1. 개요 + +### 1.1 목적 + +전자계약(E-Sign) 시스템의 카카오톡 알림톡, SMS, 이메일 발송을 **3개 환경(로컬/개발/운영)**에서 올바르게 설정하고 테스트하기 위한 가이드이다. + +### 1.2 핵심 원칙 + +- **역할 기반 알림**: 본사(creator)는 이메일, 상대방(counterpart)은 카카오톡/SMS +- **환경별 템플릿 분리**: 운영은 원본 템플릿, 개발은 `_DEV` 접미사 템플릿 사용 +- **URL 자동 분기**: `config('app.url')`로 환경별 도메인 자동 적용 + +--- + +## 2. 환경별 설정 + +### 2.1 도메인 및 APP_URL + +| 환경 | `APP_ENV` | `APP_URL` | 알림톡 버튼 URL 도메인 | +|------|-----------|-----------|----------------------| +| 로컬 (Docker) | `local` | `https://mng.sam.kr` | 로컬 — 알림톡 미사용 | +| 개발 서버 | `local` | `https://admin.codebridge-x.com` | `admin.codebridge-x.com` | +| 운영 서버 | `production` | `https://mng.codebridge-x.com` | `mng.codebridge-x.com` | + +### 2.2 바로빌 서버 모드 + +`barobill_members.server_mode` 컬럼으로 바로빌 API 엔드포인트를 결정한다: + +| server_mode | WSDL (카카오톡) | WSDL (SMS) | 용도 | +|-------------|----------------|------------|------| +| `test` | `testws.baroservice.com/KAKAOTALK.asmx` | `testws.baroservice.com/SMS.asmx` | 테스트 | +| `production` | `ws.baroservice.com/KAKAOTALK.asmx` | `ws.baroservice.com/SMS.asmx` | 실제 발송 | + +> `server_mode`는 환경(로컬/개발/운영)과 독립적이다. 개발서버에서도 `production` 모드로 실제 발송 가능. + +### 2.3 알림톡 템플릿 환경별 분기 + +코드에서 `resolveTemplateName()` 메서드가 `APP_ENV`에 따라 템플릿명을 자동 결정한다: + +```php +private function resolveTemplateName(string $baseName): string +{ + return $baseName . (app()->environment('production') ? '' : '_DEV'); +} +``` + +| 기본 템플릿명 | 운영 (`production`) | 개발/로컬 (기타) | +|-------------|--------------------|--------------------| +| `전자계약_서명요청` | `전자계약_서명요청` | `전자계약_서명요청_DEV` | +| `전자계약_완료` | `전자계약_완료` | `전자계약_완료_DEV` | +| `전자계약_리마인드` | `전자계약_리마인드` | `전자계약_리마인드_DEV` | + +--- + +## 3. 등록된 알림톡 템플릿 + +### 3.1 운영 템플릿 (mng.codebridge-x.com) + +| 템플릿명 | 용도 | 상태 | 버튼 URL | +|---------|------|------|---------| +| `전자계약_서명요청` | 서명 요청 알림 | 승인 완료 | `https://mng.codebridge-x.com/esign/sign/#{토큰}` | +| `전자계약_완료` | 서명 완료 알림 | 승인 완료 | `https://mng.codebridge-x.com/esign/sign/#{토큰}` | +| `전자계약_리마인드` | 서명 독촉 알림 | 승인 완료 | `https://mng.codebridge-x.com/esign/sign/#{토큰}` | + +### 3.2 개발 템플릿 (admin.codebridge-x.com) + +| 템플릿명 | 용도 | 상태 | 버튼 URL | +|---------|------|------|---------| +| `전자계약_서명요청_DEV` | 서명 요청 알림 | 심사 중 | `https://admin.codebridge-x.com/esign/sign/#{토큰}` | +| `전자계약_완료_DEV` | 서명 완료 알림 | 심사 중 | `https://admin.codebridge-x.com/esign/sign/#{토큰}` | +| `전자계약_리마인드_DEV` | 서명 독촉 알림 | 심사 중 | `https://admin.codebridge-x.com/esign/sign/#{토큰}` | + +> 개발 템플릿 본문은 운영 템플릿과 동일하며, 버튼 URL 도메인만 다르다. + +### 3.3 템플릿 변수 + +| 변수 | 용도 | 사용 템플릿 | +|------|------|-----------| +| `#{이름}` | 서명자 이름 | 서명요청, 완료, 리마인드 | +| `#{계약명}` | 계약 제목 | 서명요청, 완료, 리마인드 | +| `#{기한}` | 서명 기한 | 서명요청, 리마인드 | +| `#{완료일}` | 계약 완료일 | 완료 | +| `#{토큰}` | 서명자 액세스 토큰 | 버튼 URL | + +--- + +## 4. 역할 기반 알림 흐름 + +### 4.1 전체 흐름 + +``` +① 계약 발송 ─→ 본사: 이메일 / 상대방: 카카오톡 알림톡 +② OTP 인증 ─→ 본사: 이메일 / 상대방: SMS +③ 다음 서명자 ─→ 본사: 이메일 / 상대방: 카카오톡 알림톡 +④ 서명 완료 ─→ 본사: 이메일(PDF) / 상대방: 카카오톡(PDF 다운로드) +``` + +### 4.2 역할 판별 + +```php +$isCounterpart = $signer->role === EsignSigner::ROLE_COUNTERPART; +``` + +| 역할 | 상수 | 알림톡 | SMS(OTP) | 이메일 | +|------|------|--------|----------|--------| +| 본사 (creator) | `ROLE_CREATOR` | ❌ | ❌ | ✅ 항상 | +| 상대방 (counterpart) | `ROLE_COUNTERPART` | ✅ 우선 | ✅ OTP만 | ✅ 폴백 | + +### 4.3 이메일 폴백 조건 + +상대방(counterpart)에게도 이메일을 보내는 경우: +- 전화번호가 없을 때 (`$signer->phone` 없음) +- 알림톡 발송 실패 시 (`$alimtalkFailed = true`) +- 발송 방식이 `email` 또는 `both`일 때 + +### 4.4 완료 알림 특수 처리 + +완료 알림톡 버튼은 **서명 페이지가 아닌 문서 다운로드 URL**로 강제 변경된다: + +```php +// sendCompletionAlimtalk() 내부 +$documentUrl = config('app.url') . '/esign/sign/' . $signer->access_token . '/api/document'; + +// 버튼 URL 강제 변경 (서명페이지 → 문서 다운로드) +if (str_contains($btn[$urlKey], '/esign/sign/') && !str_contains($btn[$urlKey], '/api/document')) { + $btn[$urlKey] = $documentUrl; +} +``` + +--- + +## 5. SMS (OTP 인증) + +### 5.1 발송 조건 + +상대방(counterpart)이 `alimtalk` 또는 `both` 발송 방식이고 전화번호가 있을 때 SMS로 OTP 발송: + +```php +if (in_array($sendMethod, ['alimtalk', 'both']) + && $signer->phone + && $signer->role === EsignSigner::ROLE_COUNTERPART) { + $this->sendOtpViaSms($contract, $signer, $otpCode); +} +``` + +### 5.2 SMS 발송 파라미터 + +| 항목 | 값 | +|------|-----| +| API | `BarobillService::sendSMSMessage()` | +| 발신번호 | `barobill_members.manager_hp` | +| 수신번호 | `esign_signers.phone` | +| 메시지 | `[SAM] 전자계약 인증코드: {코드} (5분 이내 입력)` | +| OTP 유효시간 | 5분 | +| 최대 시도 | 5회 | + +### 5.3 SMS 실패 시 이메일 폴백 + +SMS 발송 실패 → 이메일 OTP 폴백 → 이메일도 없으면 500 에러 반환. + +--- + +## 6. 바로빌 템플릿 등록 절차 + +### 6.1 관리자 페이지 + +``` +https://www.barobill.co.kr 로그인 → 카카오톡 → 템플릿관리 +``` + +### 6.2 DEV 템플릿 등록 시 주의사항 + +1. **본문**: 운영 템플릿과 **완전히 동일** (1글자도 다르면 안 됨) +2. **버튼 URL**: 도메인만 `admin.codebridge-x.com`으로 변경 +3. **템플릿명**: 운영 이름 + `_DEV` 접미사 (예: `전자계약_서명요청_DEV`) +4. **검수 기간**: 영업일 기준 2~3일 + +### 6.3 새 템플릿 추가 시 체크리스트 + +- [ ] 바로빌에서 운영용 + 개발용 2개 등록 +- [ ] 코드에서 `resolveTemplateName('기본명')`으로 호출 +- [ ] 본문의 변수 치환 로직 추가 (str_replace) +- [ ] 버튼 URL의 `#{토큰}` 치환 확인 +- [ ] 2단계 검증 (SendKey → GetSendKakaotalk) 포함 + +--- + +## 7. 관련 파일 + +| 파일 | 역할 | +|------|------| +| `app/Http/Controllers/ESign/EsignApiController.php` | 계약 발송, `sendAlimtalk()`, `resolveTemplateName()` | +| `app/Http/Controllers/ESign/EsignPublicController.php` | OTP SMS, 완료 알림톡, `sendCompletionAlimtalk()` | +| `app/Services/Barobill/BarobillService.php` | SOAP 클라이언트 (`sendATKakaotalkEx`, `sendSMSMessage`) | +| `app/Models/ESign/EsignSigner.php` | `ROLE_CREATOR`, `ROLE_COUNTERPART` 상수 | +| `app/Mail/EsignCompletedMail.php` | 완료 이메일 (PDF 다운로드 링크) | +| `app/Services/ESign/PdfSignatureService.php` | 서명 PDF 합성 (`mergeSignatures`) | + +--- + +## 8. 트러블슈팅 + +### 8.1 환경별 템플릿 미스매치 + +**증상**: `ResultCode=4` (템플릿 데이터 일치 오류) +**원인**: 개발서버에서 운영용 템플릿(`전자계약_서명요청`)으로 발송 시 버튼 URL 도메인 불일치 +**해결**: DEV 템플릿 등록 후 `APP_ENV`가 `production`이 아닌지 확인 + +### 8.2 서명 PDF 누락 (이메일) + +**증상**: 완료 이메일의 다운로드 링크가 서명 없는 초안 PDF 반환 +**원인**: `mergeSignatures()` 실패 → `signed_file_path` 미설정 → preview PDF 폴백 +**해결**: `downloadDocument()`가 완료 상태에서 자동 재생성 시도. 로그에서 trace 확인: + +```bash +# 개발서버 로그 확인 +ssh pro@114.203.209.83 "tail -100 /home/webservice/mng/storage/logs/laravel.log | grep 'PDF 서명'" +``` + +**주요 실패 원인**: +- `storage/fonts/Pretendard-Regular.ttf` 폰트 파일 누락 +- FPDI/TCPDF 패키지 미설치 → `composer install` 필요 +- `storage/app/esign/{tenant_id}/signed/` 디렉토리 권한 문제 + +### 8.3 MNG 모델 상수 누락 + +**증상**: `Undefined constant App\Models\ESign\EsignSigner::ROLE_COUNTERPART` +**원인**: API 프로젝트와 MNG 프로젝트의 모델이 독립적 — API에만 상수 정의됨 +**해결**: MNG `EsignSigner.php`에도 동일한 상수 추가 (2026-02-26 핫픽스 완료) + +--- + +## 관련 문서 + +- [바로빌 카카오톡 연동 README](./README.md) — SOAP API 전체 연동 가이드 +- [E-Sign 기술 설계](../../projects/e-sign/technical-design.md) — 전자계약 아키텍처 +- [E-Sign API 명세](../../projects/e-sign/api-specification.md) — API 엔드포인트 +- [알림톡 연동 계획](../../plans/esign-alimtalk-integration.md) — 초기 계획 (구현 완료) + +--- + +**최종 업데이트**: 2026-02-27 diff --git a/features/business-card-request.md b/features/business-card-request.md new file mode 100644 index 0000000..b574f00 --- /dev/null +++ b/features/business-card-request.md @@ -0,0 +1,173 @@ +# 명함신청 관리 + +> **작성일**: 2026-02-25 +> **상태**: 구현 완료 + +--- + +## 1. 개요 + +### 1.1 목적 + +영업파트너가 명함을 신청하면 본사에서 제작소에 의뢰하고, 완료 후 처리하는 3단계 워크플로우를 제공한다. + +### 1.2 워크플로우 + +``` +요청(pending) ──제작의뢰──→ 제작중(ordered) ──처리완료──→ 완료(processed) + 노랑 파랑 초록 +``` + +### 1.3 메뉴 구조 + +| 메뉴 | URL | 대상 | 설명 | +|------|-----|------|------| +| 파트너 명함신청 | `/sales/business-cards` | 모든 사용자 | 신청폼 + 내 이력 | +| 명함신청 처리 | `/sales/business-cards/manage` | 관리자 전용 | 3단계 처리 + 뱃지 | + +--- + +## 2. 테이블 구조 + +### 2.1 `business_card_requests` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `id` | bigint | PK | +| `tenant_id` | bigint | 테넌트 ID | +| `user_id` | bigint | 신청자 ID | +| `name` | varchar(50) | 성함 | +| `phone` | varchar(20) | 전화번호 | +| `title` | varchar(50) | 직함 (nullable) | +| `email` | varchar(100) | 이메일 (nullable) | +| `quantity` | int | 수량 (기본 100) | +| `memo` | text | 비고 (nullable) | +| `status` | varchar(20) | 상태: `pending`, `ordered`, `processed` | +| `ordered_by` | bigint | 제작의뢰 처리자 ID (nullable) | +| `ordered_at` | timestamp | 제작의뢰 일시 (nullable) | +| `processed_by` | bigint | 처리완료 처리자 ID (nullable) | +| `processed_at` | timestamp | 처리완료 일시 (nullable) | +| `process_memo` | text | 처리 메모 (nullable) | +| `created_at` | timestamp | 생성일 | +| `updated_at` | timestamp | 수정일 | + +**인덱스**: `(tenant_id, status)`, `user_id` + +--- + +## 3. 상태 전이 + +``` +pending ──→ ordered ──→ processed + │ ▲ + └── (역방향 전이 없음) ──┘ +``` + +| 상태 | 라벨 | 색상 | 설명 | +|------|------|------|------| +| `pending` | 요청 | 노랑 | 파트너가 신청, 관리자 확인 대기 | +| `ordered` | 제작의뢰 | 파랑 | 관리자가 제작소에 의뢰 | +| `processed` | 처리완료 | 초록 | 제작 완료, 전달 완료 | + +--- + +## 4. API 엔드포인트 + +| Method | Path | 이름 | 설명 | +|--------|------|------|------| +| GET | `/sales/business-cards` | `sales.business-cards.index` | 파트너 명함신청 (신청폼 + 이력) | +| POST | `/sales/business-cards` | `sales.business-cards.store` | 신청 등록 | +| GET | `/sales/business-cards/manage` | `sales.business-cards.manage` | 관리자 처리 화면 | +| POST | `/sales/business-cards/{id}/order` | `sales.business-cards.order` | 제작의뢰 (관리자) | +| POST | `/sales/business-cards/{id}/process` | `sales.business-cards.process` | 처리완료 (관리자) | + +--- + +## 5. 파일 구조 + +### 5.1 API 프로젝트 + +| 파일 | 설명 | +|------|------| +| `database/migrations/2026_02_24_100000_create_business_card_requests_table.php` | 테이블 생성 | +| `database/migrations/2026_02_25_100000_add_ordered_columns_to_business_card_requests_table.php` | ordered 컬럼 추가 | + +### 5.2 MNG 프로젝트 + +| 파일 | 설명 | +|------|------| +| `app/Models/Sales/BusinessCardRequest.php` | 모델 (상태 상수, 스코프, 헬퍼) | +| `app/Services/Sales/BusinessCardRequestService.php` | 서비스 (CRUD, 통계, 뱃지) | +| `app/Http/Controllers/Sales/BusinessCardRequestController.php` | 컨트롤러 | +| `app/Providers/ViewServiceProvider.php` | 사이드바 뱃지 연동 | +| `routes/web.php` | 라우트 5개 | +| `resources/views/sales/business-cards/admin-index.blade.php` | 관리자 뷰 | +| `resources/views/sales/business-cards/partner-index.blade.php` | 파트너 뷰 | + +--- + +## 6. 화면 구성 + +### 6.1 파트너 명함신청 (`partner-index`) + +``` +┌─ 회사 정보 안내 (코드브릿지엑스) ──────────────┐ +├─ 신청 폼 ─────────────────────────────────────┤ +│ 성함* │ 직함 │ 전화번호* │ 이메일 │ +│ 수량 │ 메모 │ [명함 신청하기] │ +├─ 내 신청 이력 ────────────────────────────────┤ +│ 신청일 │ 성함 │ 직함 │ 전화번호 │ 수량 │ 상태 │ +│ (요청=노랑, 제작중=파랑, 처리완료=초록) │ +└───────────────────────────────────────────────┘ +``` + +- 로그인 사용자 정보(name, phone, email)로 자동 채움 +- 관리자도 동일한 화면 접근 가능 + +### 6.2 명함신청 처리 (`admin-index`) + +``` +┌─ 통계 ──────────────────────────────────────┐ +│ 신규요청(노랑) │ 제작의뢰(파랑) │ 오늘처리(초록) │ 전체 │ +├─────────────────┬───────────────────────────┤ +│ 신규 요청 │ 제작 중 │ +│ [제작의뢰] 버튼 │ 의뢰일 + [처리완료] 버튼 │ +├─────────────────┴───────────────────────────┤ +│ 처리 완료 이력 (하단 스크롤 테이블) │ +└─────────────────────────────────────────────┘ +``` + +- 사이드바 뱃지: 요청 + 제작의뢰 합산 건수 표시 +- 처리 버튼 클릭 시 `showConfirm()` 확인 다이얼로그 + +--- + +## 7. 뱃지 연동 + +`ViewServiceProvider`에서 `BusinessCardRequestService::getPendingCount()`를 호출하여 사이드바 메뉴 뱃지에 대기 건수를 표시한다. + +- **카운트 기준**: `pending` + `ordered` 합산 +- **표시 위치**: "명함신청 처리" 메뉴 (`sales.business-cards.manage`) +- **0건일 때**: 뱃지 미표시 + +--- + +## 8. 메뉴 등록 정보 + +| ID | parent_id | 이름 | URL | sort_order | +|----|-----------|------|-----|------------| +| 15507 | 15456 | 파트너 명함신청 | `/sales/business-cards` | 5 | +| 15508 | 15456 | 명함신청 처리 | `/sales/business-cards/manage` | 6 | + +> 영업파트너에게는 "파트너 명함신청"만 보이도록 메뉴 권한 설정 필요 + +--- + +## 관련 문서 + +- 참고 패턴: `api/app/Models/CompanyRequest.php` (상태 관리 모델) +- 참고 뷰: `mng/resources/views/sales/managers/approvals.blade.php` (2분할 레이아웃) + +--- + +**최종 업데이트**: 2026-02-25 diff --git a/features/credit-evaluation/README.md b/features/credit-evaluation/README.md new file mode 100644 index 0000000..d4d38b3 --- /dev/null +++ b/features/credit-evaluation/README.md @@ -0,0 +1,284 @@ +# 신용평가 시스템 (쿠콘 연동) + +> **작성일**: 2026-03-02 +> **상태**: 운영중 + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM에서 거래처/협력업체의 **기업 신용정보를 조회**하여, 거래 안전성을 사전 판단하는 시스템이다. + +### 1.2 핵심 원칙 + +- **쿠콘(KooCon/나이스평가정보)** API로 기업 신용정보 7개 항목 조회 +- **국세청 공공데이터포털** API로 사업자등록 상태(영업/휴업/폐업) 확인 +- 모든 조회 결과는 DB에 원본 저장 (감사 추적용) +- 테넌트별 월 5건 무료, 초과 시 건당 2,000원 과금 + +--- + +## 2. 시스템 구조 + +### 2.1 전체 흐름 + +``` +사용자 (SAM MNG) + │ + ▼ +CreditController::search() + │ + ├──▶ CooconService::getAllCreditInfo() + │ ├── OA08: 기업 기본정보 + │ ├── OA12: 신용요약정보 + │ ├── OA13: 단기연체정보 + │ ├── OA14: 신용도판단정보 (KCI) + │ ├── OA15: 신용도판단정보 (CB) + │ ├── OA16: 당좌거래정지정보 + │ └── OA17: 법정관리/워크아웃 + │ + ├──▶ NtsBusinessService::getBusinessStatus() + │ └── 국세청 사업자등록 상태 조회 + │ + └──▶ CreditInquiry::createFromApiResponse() + └── DB에 조회 이력 저장 +``` + +### 2.2 파트너 구조 + +| 역할 | 대상 | 설명 | +|------|------|------| +| **API 제공사** | 쿠콘(KooCon) / 나이스평가정보 | 기업 신용정보 API 플랫폼 | +| **파트너사** | (주)코드브릿지엑스 | API 키 보유, 쿠콘과 직접 계약 | +| **이용사** | 각 테넌트 (주일, 경동 등) | SAM을 통해 신용조회 실행 | + +--- + +## 3. 쿠콘(KooCon) API + +### 3.1 API 엔드포인트 + +| 환경 | URL | +|------|-----| +| 테스트 | `https://dev2.coocon.co.kr:8443/sol/gateway/oapi_relay.jsp` | +| 운영 | `https://sgw.coocon.co.kr/sol/gateway/oapi_relay.jsp` | + +### 3.2 인증 방식 + +- **API_KEY**: 쿠콘에서 발급받은 인증키 (DB `coocon_configs` 테이블에서 관리) +- **API_ID**: 조회할 API 식별자 (OA08~OA17) +- **TR_SEQ**: 거래일련번호 (중복 방지용, `YmdHis` + 마이크로초 6자리) + +### 3.3 요청 형식 + +```json +{ + "API_KEY": "발급받은_API_키", + "API_ID": "OA12", + "TR_SEQ": "20260302173000123456", + "COMPANY_KEY": "1234567890" +} +``` + +- **Method**: POST +- **Content-Type**: application/json +- **Timeout**: 30초 + +### 3.4 API 목록 + +| API ID | 상수명 | 설명 | 데이터 출처 | +|--------|--------|------|------------| +| `OA08` | `API_COMPANY_INFO` | 기업 기본정보 | 나이스평가정보 | +| `OA12` | `API_CREDIT_SUMMARY` | 신용요약정보 (이슈 건수 요약) | 나이스평가정보 | +| `OA13` | `API_SHORT_TERM_OVERDUE` | 단기연체정보 | 한국신용정보원 | +| `OA14` | `API_NEGATIVE_INFO_KCI` | 신용도판단정보 (KCI) | 한국신용정보원 + 공공정보 | +| `OA15` | `API_NEGATIVE_INFO_CB` | 신용도판단정보 (CB) | 신용정보사 | +| `OA16` | `API_SUSPENSION_INFO` | 당좌거래정지정보 | 금융결제원 | +| `OA17` | `API_WORKOUT_INFO` | 법정관리/워크아웃정보 | 법원 | + +### 3.5 응답 형식 + +```json +{ + "RSLT_CD": "00000000", + "RSLT_MSG": "정상처리되었습니다.", + "RSLT_DATA": { ... } +} +``` + +- `RSLT_CD === '00000000'`: 성공 +- 기타 값: 에러 (에러 메시지는 `RSLT_MSG`에 포함) + +--- + +## 4. 국세청 사업자등록 조회 API + +### 4.1 API 정보 + +| 항목 | 값 | +|------|------| +| URL | `https://api.odcloud.kr/api/nts-businessman/v1/status` | +| 인증 | serviceKey (쿼리 파라미터) | +| 출처 | 공공데이터포털 | + +### 4.2 상태 코드 + +| 코드 | 상태 | 설명 | +|------|------|------| +| `01` | 계속사업자 | 정상 영업 중 | +| `02` | 휴업자 | 영업 중지 | +| `03` | 폐업자 | 사업 종료 | + +--- + +## 5. 데이터베이스 + +### 5.1 `coocon_configs` — API 설정 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `name` | VARCHAR(100) | 설정 이름 | +| `environment` | ENUM('test', 'production') | 환경 | +| `api_key` | VARCHAR(100) | 쿠콘 API 키 | +| `base_url` | VARCHAR(255) | API 기본 URL | +| `description` | TEXT | 설명 | +| `is_active` | BOOLEAN | 활성화 여부 | + +> **규칙**: 환경당 1개만 활성화 가능. 새 설정 활성화 시 기존 설정은 자동 비활성화. + +### 5.2 `credit_inquiries` — 조회 이력 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | 테넌트 | +| `inquiry_key` | VARCHAR(32) UNIQUE | 조회 고유키 | +| `company_key` | VARCHAR(20) | 사업자번호/법인번호 | +| `company_name` | VARCHAR | 업체명 | +| `user_id` | BIGINT FK | 조회자 | +| `inquired_at` | TIMESTAMP | 조회 일시 | +| `nts_status` | VARCHAR(20) | 국세청 상태 | +| `nts_status_code` | VARCHAR(2) | 국세청 상태코드 | +| `short_term_overdue_cnt` | UINT | 단기연체 건수 | +| `negative_info_kci_cnt` | UINT | KCI 건수 | +| `negative_info_pb_cnt` | UINT | 공공정보 건수 | +| `negative_info_cb_cnt` | UINT | CB 건수 | +| `suspension_info_cnt` | UINT | 당좌거래정지 건수 | +| `workout_cnt` | UINT | 법정관리/워크아웃 건수 | +| `raw_*` | JSON | 각 API 원본 응답 (7개 + NTS) | +| `status` | ENUM | success / partial / failed | + +--- + +## 6. 과금 정책 + +| 항목 | 값 | +|------|------| +| 월 무료 할당량 | **5건** | +| 초과 건당 요금 | **2,000원** | +| 계산식 | `max(0, (조회건수 - 5)) × 2,000` | + +### 요금 예시 + +| 월 조회 건수 | 무료 | 유료 | 요금 | +|-------------|------|------|------| +| 3건 | 3 | 0 | 0원 | +| 5건 | 5 | 0 | 0원 | +| 10건 | 5 | 5 | 10,000원 | +| 20건 | 5 | 15 | 30,000원 | + +--- + +## 7. 환경 설정 + +### 7.1 테스트/운영 분리 + +| 환경 | API URL | 설명 | +|------|---------|------| +| 테스트 | `dev2.coocon.co.kr:8443` | 개발/검증용 (과금 없음) | +| 운영 | `sgw.coocon.co.kr` | 실 서비스 (과금 발생) | + +- `coocon_configs` 테이블에서 환경별로 별도 설정 관리 +- 각 환경에서 `is_active=true`인 설정 1개만 사용 + +### 7.2 필요한 설정 + +| 항목 | 관리 위치 | 설명 | +|------|----------|------| +| 쿠콘 API 키 | DB (`coocon_configs`) | 쿠콘에서 발급 | +| 쿠콘 API URL | DB (`coocon_configs`) | 환경별 URL | +| 국세청 API 키 | 코드 내 하드코딩 | 공공데이터포털 발급 | + +--- + +## 8. MNG 라우트 + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/credit/inquiry` | 조회 이력 목록 | +| POST | `/credit/inquiry/search` | 신용정보 조회 실행 | +| POST | `/credit/inquiry/test` | API 연결 테스트 | +| GET | `/credit/inquiry/{key}/raw` | 원본 데이터 조회 | +| GET | `/credit/inquiry/{key}/report` | 리포트 조회 | +| DELETE | `/credit/inquiry/{id}` | 이력 삭제 | +| GET | `/credit/usage` | 조회회수 집계 | +| GET | `/credit/settings` | 설정 관리 | +| POST | `/credit/settings` | 설정 생성 | +| PUT | `/credit/settings/{id}` | 설정 수정 | +| DELETE | `/credit/settings/{id}` | 설정 삭제 | +| POST | `/credit/settings/{id}/toggle` | 활성화 토글 | + +--- + +## 9. 에러 코드 + +### 9.1 쿠콘 API + +| 코드 | 설명 | +|------|------| +| `NO_CONFIG` | API 설정 없음 | +| `HTTP_ERROR` | HTTP 통신 오류 | +| `EXCEPTION` | 예외 발생 | +| `RSLT_CD ≠ 00000000` | 쿠콘 API 에러 (RSLT_MSG 참조) | + +### 9.2 국세청 API + +| 코드 | 설명 | +|------|------| +| `INVALID_FORMAT` | 사업자번호 형식 오류 | +| `NOT_FOUND` | 조회 결과 없음 | +| `HTTP_ERROR` | HTTP 통신 오류 | + +--- + +## 10. 관련 파일 + +### MNG 프로젝트 + +| 구분 | 경로 | +|------|------| +| 컨트롤러 | `app/Http/Controllers/Credit/CreditController.php` | +| 컨트롤러 | `app/Http/Controllers/Credit/CreditUsageController.php` | +| 서비스 | `app/Services/Coocon/CooconService.php` | +| 서비스 | `app/Services/Nts/NtsBusinessService.php` | +| 모델 | `app/Models/Coocon/CooconConfig.php` | +| 모델 | `app/Models/Credit/CreditInquiry.php` | +| 뷰 | `resources/views/credit/inquiry/index.blade.php` | +| 뷰 | `resources/views/credit/usage/index.blade.php` | +| 뷰 | `resources/views/credit/settings/index.blade.php` | + +### API 프로젝트 (마이그레이션) + +| 경로 | +|------| +| `database/migrations/2026_01_22_192637_create_coocon_configs_table.php` | +| `database/migrations/2026_01_22_201143_create_credit_inquiries_table.php` | +| `database/migrations/2026_01_22_203001_add_company_info_to_credit_inquiries_table.php` | +| `database/migrations/2026_01_28_163000_add_tenant_id_to_credit_inquiries_table.php` | + +--- + +**최종 업데이트**: 2026-03-02 diff --git a/features/documents/mng-document-system.md b/features/documents/mng-document-system.md new file mode 100644 index 0000000..eae95a6 --- /dev/null +++ b/features/documents/mng-document-system.md @@ -0,0 +1,738 @@ +# MNG 문서관리 시스템 상세 기술 명세 + +> **작성일**: 2026-03-06 +> **상태**: 운영 중 +> **프로젝트**: SAM MNG (관리자 웹) +> **관련**: [README.md](README.md) (API 명세) + +--- + +## 1. 개요 + +### 1.1 목적 + +블라인드/스크린 제조 현장의 **검사 성적서, 작업일지, 수입검사 기록** 등 품질/생산 문서를 전자화하여 관리하는 시스템. 문서 양식(Template)을 정의하면 EAV 패턴으로 데이터를 동적 저장하며, 다단계 결재 워크플로우를 지원한다. + +### 1.2 핵심 특징 + +| 특징 | 설명 | +|------|------| +| **EAV 패턴** | 양식별로 다른 필드를 하나의 `document_data` 테이블에 저장 | +| **2가지 양식 빌더** | 레거시 빌더 (DB 정규화) + 블록 빌더 (A4 JSON 스키마) | +| **결재 워크플로우** | 작성 → 검토 → 승인 (다단계 순차 결재) | +| **자동 데이터 매핑** | 작업지시서/수주 데이터에서 기본필드 자동 채움 | +| **다형성 연결** | work_order, sales_order 등 다양한 모델과 연결 | +| **자재 LOT 추적** | 검사 문서에서 투입 자재의 LOT 이력 조회 | + +### 1.3 문서 구조 + +| 문서 | 설명 | +|------|------| +| [README.md](README.md) | API 엔드포인트, 모델 요약, FormRequest | +| **이 문서** | MNG 화면별 상세, 동작원리, 데이터 흐름 | + +--- + +## 2. 메뉴/탭 구조 + +``` +생산 관리 +└── 문서관리 + ├── 문서 목록 /documents ← 문서 검색/필터/관리 + ├── 새 문서 작성 /documents/create ← 템플릿 선택 → 폼 입력 + ├── 문서 상세 /documents/{id} ← 읽기 전용 + 결재 현황 + ├── 문서 수정 /documents/{id}/edit ← DRAFT/REJECTED만 + ├── 인쇄 /documents/{id}/print ← 성적서 인쇄용 + │ + └── 문서양식 관리 + ├── 양식 목록 /document-templates ← 양식 검색/관리 + ├── 새 양식 (레거시) /document-templates/create ← 레거시 빌더 + ├── 양식 수정 /document-templates/{id}/edit ← 자동 빌더 판별 + ├── 양식 디자이너 /document-templates/block-create ← 블록 빌더 + └── 블록 수정 /document-templates/{id}/block-edit ← 블록 빌더 수정 +``` + +--- + +## 3. 파일 구조 + +``` +mng/ +├── app/Http/Controllers/ +│ ├── DocumentController.php ← 문서 CRUD 화면 +│ └── DocumentTemplateController.php ← 양식 관리 화면 +├── app/Models/Documents/ +│ ├── Document.php ← 문서 모델 +│ ├── DocumentApproval.php ← 결재 단계 +│ ├── DocumentData.php ← EAV 데이터 +│ ├── DocumentTemplate.php ← 양식 마스터 +│ └── ... (기타 템플릿 관련 모델) +└── resources/views/ + ├── documents/ + │ ├── index.blade.php ← 문서 목록 + │ ├── edit.blade.php ← 문서 작성/수정 + │ ├── show.blade.php ← 문서 상세 + │ └── print.blade.php ← 인쇄 전용 + └── document-templates/ + ├── index.blade.php ← 양식 목록 + ├── edit.blade.php ← 레거시 빌더 + ├── block-editor.blade.php ← 블록 빌더 + └── partials/ + ├── block-palette.blade.php ← 블록 타입 목록 + ├── block-canvas.blade.php ← 편집 캔버스 + └── block-properties.blade.php ← 속성 패널 +``` + +--- + +## 4. 데이터베이스 아키텍처 + +### 4.1 테이블 관계도 + +``` +document_templates (양식 마스터) +├── 1:N → document_template_approval_lines (결재선 정의) +├── 1:N → document_template_basic_fields (기본필드 정의) +├── 1:N → document_template_sections (섹션 정의) +│ └── 1:N → document_template_section_items (검사항목) +├── 1:N → document_template_columns (테이블 컬럼 정의) +├── 1:N → document_template_section_fields (섹션 필드) +├── 1:N → document_template_links (외부 연결 정의) +│ └── 1:N → document_template_link_values (템플릿 레벨 연결값) +│ +└── 1:N → documents (문서 인스턴스) + ├── 1:N → document_approvals (결재 진행) + ├── 1:N → document_data (EAV 필드값) + ├── 1:N → document_attachments (첨부파일) + └── 1:N → document_links (문서 레벨 연결) +``` + +### 4.2 documents (문서) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | 테넌트 격리 | +| `template_id` | BIGINT FK | 사용 양식 | +| `document_no` | VARCHAR UNIQUE | 문서번호 (자동 채번) | +| `title` | VARCHAR | 문서 제목 | +| `status` | VARCHAR(20) | 상태 (5가지) | +| `linkable_type` | VARCHAR NULL | 다형성 모델 타입 | +| `linkable_id` | BIGINT NULL | 다형성 모델 ID | +| `submitted_at` | TIMESTAMP NULL | 결재 요청 일시 | +| `completed_at` | TIMESTAMP NULL | 결재 완료 일시 | +| `created_by` | BIGINT FK | 작성자 | +| `deleted_at` | TIMESTAMP NULL | 소프트 삭제 | + +**인덱스**: `(tenant_id, status)`, `document_no`, `(linkable_type, linkable_id)` + +### 4.3 document_data (EAV 필드값) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `document_id` | BIGINT FK | 소속 문서 | +| `section_id` | BIGINT FK NULL | 소속 섹션 (NULL=기본필드) | +| `column_id` | BIGINT FK NULL | 소속 컬럼 (테이블 데이터용) | +| `row_index` | INT | 테이블 행 번호 (기본: 0) | +| `field_key` | VARCHAR | 필드 식별자 (`bf_1`, `cf_2`, `col_3`) | +| `field_value` | TEXT NULL | 실제 값 | + +**인덱스**: `(document_id, section_id)`, `(document_id, field_key)` + +### 4.4 document_approvals (결재) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `document_id` | BIGINT FK | 소속 문서 | +| `user_id` | BIGINT FK | 결재자 | +| `step` | INT | 결재 순서 (1, 2, 3...) | +| `role` | VARCHAR | 역할 (작성, 검토, 승인) | +| `status` | VARCHAR(20) | PENDING / APPROVED / REJECTED | +| `comment` | TEXT NULL | 결재 의견 | +| `acted_at` | TIMESTAMP NULL | 처리 일시 | + +**인덱스**: `(document_id, step)`, `(user_id, status)` + +### 4.5 document_attachments (첨부파일) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `document_id` | BIGINT FK | 소속 문서 | +| `file_id` | BIGINT FK | File 모델 연결 | +| `attachment_type` | VARCHAR | `general`, `signature`, `image`, `reference` | +| `description` | VARCHAR NULL | 설명 | +| `created_by` | BIGINT FK | 업로드자 | + +--- + +## 5. 양식(Template) 시스템 + +### 5.1 두 가지 빌더 방식 + +| 방식 | 필드명 | 저장 구조 | UI | 상태 | +|------|--------|----------|-----|------| +| **레거시 빌더** | `builder_type = null` | 정규화 테이블들 | `edit.blade.php` | 기존 양식용 | +| **블록 빌더** | `builder_type = 'block'` | `schema` JSON | `block-editor.blade.php` | 신규 양식용 | + +**자동 판별 로직:** + +```php +// DocumentTemplateController::edit() +if ($template->isBlockBuilder()) { + return $this->blockEdit($id); // block-editor.blade.php +} else { + return view('document-templates.edit'); // 레거시 +} +``` + +### 5.2 양식 마스터 (document_templates) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `name` | VARCHAR | 양식명 (예: "제품검사 성적서") | +| `category` | VARCHAR | 분류 (common_codes 기반) | +| `title` | VARCHAR NULL | 문서 제목 템플릿 | +| `company_name` | VARCHAR NULL | 회사명 | +| `company_address` | VARCHAR NULL | 회사 주소 | +| `company_contact` | VARCHAR NULL | 연락처 | +| `footer_remark_label` | VARCHAR NULL | 비고란 라벨 | +| `footer_judgement_label` | VARCHAR NULL | 판정란 라벨 | +| `footer_judgement_options` | JSON NULL | 판정 선택지 (적합/부적합) | +| `builder_type` | VARCHAR NULL | `block` 또는 NULL | +| `schema` | JSON NULL | 블록 빌더 JSON 스키마 | +| `page_config` | JSON NULL | 페이지 설정 (A4, 여백 등) | +| `is_active` | BOOLEAN | 활성 여부 | + +### 5.3 레거시 빌더 구성 요소 + +#### 결재선 (document_template_approval_lines) + +``` +step 1: 작성 (작성자 본인) +step 2: 검토 (팀장) +step 3: 승인 (부장) +``` + +| 컬럼 | 설명 | +|------|------| +| `name` | 라벨 (작성, 검토, 승인) | +| `dept` | 부서 | +| `role` | 역할 | +| `sort_order` | 순서 | + +#### 기본필드 (document_template_basic_fields) + +문서 상단의 고정 필드 영역. + +| 컬럼 | 설명 | +|------|------| +| `label` | 필드 라벨 (품명, LOT NO, 납기일 등) | +| `field_key` | 식별자 (EAV 저장 시 사용) | +| `field_type` | 입력 타입 (text, date, number, item_search) | +| `default_value` | 기본값 | +| `sort_order` | 순서 | + +**EAV 저장 시 field_key 패턴:** + +``` +bf_1 → 기본필드 ID 1 (예: 품명) +bf_2 → 기본필드 ID 2 (예: LOT NO) +bf_3 → 기본필드 ID 3 (예: 납기일) +``` + +#### 섹션 (document_template_sections) + +검사 기준서의 섹션 단위. + +| 컬럼 | 설명 | +|------|------| +| `title` | 섹션 제목 (예: "겉모양 검사", "치수 검사") | +| `image_path` | 도해 이미지 경로 (검사 부위 도면) | +| `sort_order` | 순서 | + +#### 검사항목 (document_template_section_items) + +각 섹션 내의 개별 검사항목. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `category` | VARCHAR | 구분 (겉모양, 치수, 재질) | +| `item` | VARCHAR | 검사항목명 | +| `standard` | VARCHAR | 검사기준 (100mm ±5mm) | +| `tolerance` | JSON NULL | 허용오차 (min/max) | +| `standard_criteria` | VARCHAR NULL | 판정기준 | +| `method` | VARCHAR | 검사방법 (육안, 측정) | +| `measurement_type` | VARCHAR NULL | 측정 유형 | +| `frequency_n` | INT NULL | 검사건수 N | +| `frequency_c` | INT NULL | 합격건수 C | +| `frequency` | VARCHAR NULL | 검사빈도 텍스트 | +| `field_values` | JSON NULL | 확장 필드 (마이그레이션 없이 추가) | + +#### 테이블 컬럼 (document_template_columns) + +검사 데이터 테이블의 컬럼 정의. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `label` | VARCHAR | 컬럼 라벨 | +| `width` | INT NULL | 너비 (px) | +| `column_type` | VARCHAR | `text`, `check`, `complex`, `measurement`, `select` | +| `group_name` | VARCHAR NULL | 상단 병합 헤더명 | +| `sub_labels` | JSON NULL | complex 타입 하위 라벨 | +| `sort_order` | INT | 순서 | + +**컬럼 타입 상세:** + +| 타입 | 설명 | 예시 | +|------|------|------| +| `text` | 단순 텍스트 입력 | 비고, 메모 | +| `check` | 체크박스 (합격/부적합) | 외관 검사 합격 여부 | +| `complex` | 여러 서브필드 조합 | 측정값 + 단위 + 판정 | +| `measurement` | 수치 입력 | 길이: 100.5mm | +| `select` | 드롭다운 선택 | 판정: 합격/불합격/보류 | + +#### 외부 연결 (document_template_links) + +템플릿에서 외부 테이블 데이터를 참조하기 위한 정의. + +| 컬럼 | 설명 | +|------|------| +| `link_key` | 연결 식별자 | +| `label` | 화면 라벨 | +| `link_type` | `single` (1개 선택) / `multiple` (다중 선택) | +| `source_table` | 소스 테이블 (`items`, `processes`, `users`) | +| `search_params` | API 검색 추가 조건 (JSON) | +| `display_fields` | 표시 필드 (title, subtitle) | +| `is_required` | 필수 여부 | + +### 5.4 블록 빌더 구조 + +**페이지 설정 (page_config):** + +```json +{ + "size": "A4", + "orientation": "portrait", + "margin": { + "top": 20, + "right": 15, + "bottom": 20, + "left": 15 + } +} +``` + +**스키마 (schema):** + +블록 배열로 레이아웃 정의. 드래그앤드롭으로 편집. + +```json +{ + "blocks": [ + { "type": "text", "x": 0, "y": 0, "width": 100, "content": "검사 성적서" }, + { "type": "table", "x": 0, "y": 50, "columns": [...], "rows": [...] }, + { "type": "image", "x": 200, "y": 100, "src": "..." } + ] +} +``` + +**블록 빌더 UI (3패널):** + +``` +┌──────────┬────────────────────┬──────────┐ +│ 블록 │ │ 속성 │ +│ 팔레트 │ A4 캔버스 │ 패널 │ +│ │ │ │ +│ [텍스트] │ ┌──────────────┐ │ 너비: _ │ +│ [이미지] │ │ 드래그앤드롭 │ │ 높이: _ │ +│ [표] │ │ 블록 배치 │ │ 색상: _ │ +│ [선] │ │ │ │ 폰트: _ │ +│ [도형] │ └──────────────┘ │ │ +└──────────┴────────────────────┴──────────┘ +``` + +--- + +## 6. EAV 데이터 저장 패턴 + +### 6.1 핵심 개념 + +하나의 `document_data` 테이블에 **모든 양식의 모든 필드값**을 저장. 양식이 다르면 field_key가 다르고, 같은 양식이라도 섹션/행이 다르면 section_id/row_index로 구분. + +### 6.2 저장 구조 + +``` +document_data 레코드 예시: + +기본필드 (상단 고정 영역): +┌─────────────┬────────────┬───────────┬───────────┬───────────┬─────────────┐ +│ document_id │ section_id │ column_id │ row_index │ field_key │ field_value │ +├─────────────┼────────────┼───────────┼───────────┼───────────┼─────────────┤ +│ 42 │ NULL │ NULL │ 0 │ bf_1 │ 블라인드A │ ← 품명 +│ 42 │ NULL │ NULL │ 0 │ bf_2 │ LOT-2026-001│ ← LOT NO +│ 42 │ NULL │ NULL │ 0 │ bf_3 │ 2026-03-15 │ ← 납기일 +├─────────────┼────────────┼───────────┼───────────┼───────────┼─────────────┤ + +테이블 데이터 (섹션별 검사 결과): +│ 42 │ 10 │ 20 │ 0 │ col_20 │ 합격 │ ← 섹션10, 컬럼20, 1행 +│ 42 │ 10 │ 20 │ 1 │ col_20 │ 부적합 │ ← 섹션10, 컬럼20, 2행 +│ 42 │ 10 │ 21 │ 0 │ col_21 │ 100.5 │ ← 섹션10, 컬럼21, 1행 +└─────────────┴────────────┴───────────┴───────────┴───────────┴─────────────┘ +``` + +### 6.3 field_key 네이밍 규칙 + +| 접두사 | 의미 | 예시 | +|--------|------|------| +| `bf_` | 기본필드 (BasicField) | `bf_1`, `bf_2` | +| `cf_` | 섹션필드 (SectionField) | `cf_5`, `cf_6` | +| `col_` | 컬럼 데이터 | `col_20`, `col_21` | + +### 6.4 데이터 조회 패턴 + +```php +// 기본필드 값 조회 +$data = DocumentData::where('document_id', $id) + ->whereNull('section_id') + ->get() + ->keyBy('field_key'); + +$productName = $data['bf_1']->field_value; + +// 섹션별 테이블 데이터 조회 +$rows = DocumentData::where('document_id', $id) + ->where('section_id', $sectionId) + ->get() + ->groupBy('row_index'); +``` + +--- + +## 7. 결재 워크플로우 + +### 7.1 상태 전이 + +``` +DRAFT (작성중) + │ + ├── submit() → PENDING (결재중) + │ │ + │ ├── approve() [step 1] → 다음 step 대기 + │ ├── approve() [step 2] → 다음 step 대기 + │ ├── approve() [마지막] → APPROVED (승인) + │ │ + │ └── reject() → REJECTED (반려) + │ │ + │ └── edit → submit() → PENDING (재요청) + │ + └── cancel() → CANCELLED (취소) +``` + +### 7.2 상태값 및 라벨 + +| 코드 | 라벨 | 색상 | 편집 가능 | +|------|------|------|----------| +| `DRAFT` | 작성중 | gray | 예 | +| `PENDING` | 결재중 | yellow | 아니오 | +| `APPROVED` | 승인 | green | 아니오 | +| `REJECTED` | 반려 | red | 예 (수정 후 재요청) | +| `CANCELLED` | 취소 | gray | 아니오 | + +### 7.3 결재 단계 (Approval) + +``` +DocumentTemplateApprovalLine (양식 정의) + ↓ (문서 생성 시 복사) +DocumentApproval (문서별 결재 레코드) + +step 1: 작성 → PENDING → 결재자 승인 → APPROVED +step 2: 검토 → PENDING → 결재자 승인 → APPROVED +step 3: 승인 → PENDING → 결재자 승인 → APPROVED → 문서 전체 APPROVED +``` + +### 7.4 결재 판단 메서드 + +```php +// Document 모델 +canEdit() // DRAFT 또는 REJECTED +canSubmit() // DRAFT 또는 REJECTED +canApprove() // PENDING (현재 결재자만) +canCancel() // DRAFT 또는 PENDING (작성자만) +``` + +--- + +## 8. 자동 데이터 매핑 + +### 8.1 개요 + +문서 작성/수정 시, 연결된 작업지시서(work_order)/수주(order) 데이터에서 기본필드를 **자동으로 채움**. 사용자 입력 부담을 줄이고 데이터 정확성을 보장. + +### 8.2 검사 성적서 매핑 (field_key 기반) + +| field_key | 라벨 | 소스 | +|-----------|------|------| +| `product_name` | 품명 | `workOrderItem.item_name` | +| `specification` | 규격 | `workOrderItem.specification` | +| `lot_no` | LOT NO | `order.order_no` | +| `lot_size` | LOT 크기 | `"N 개소"` (개소 수 기반) | +| `client` | 발주처 | `order.client_name` | +| `site_name` | 현장명 | `workOrder.project_name` | +| `inspection_date` | 검사일 | `workOrderItem.options.inspection_data.inspected_at` | +| `inspector` | 검사자 | 검사자 이름 | + +### 8.3 작업일지 매핑 (label 기반) + +| label 포함 문자열 | 소스 | +|------------------|------| +| `발주처` | `order.client_name` | +| `현장명` | `workOrder.project_name` | +| `작업일자` | `now()` | +| `LOT NO`, `LOT` | `order.order_no` | +| `납기일`, `납기` | `order.delivery_date` | +| `작업지시번호` | `workOrder.work_order_no` | +| `수주일` | `order.received_at` 또는 `order.created_at` | + +### 8.4 자동 매핑 흐름 + +``` +문서 작성/수정 페이지 로드 + ↓ +DocumentController::edit() + ↓ +resolveAndBackfillBasicFields($template, $document) + ↓ +linkable_type 확인 (work_order? order?) + ↓ +field_key 또는 label 매칭 + ↓ +DB에 값이 없으면 → 소스 데이터에서 resolve + ↓ +뷰에 자동 채움된 값 전달 +``` + +--- + +## 9. 자재 LOT 추적 + +### 9.1 개요 + +검사 성적서에서 해당 작업지시의 **투입 자재 LOT 이력**을 조회. `stock_transactions` 테이블의 OUT(투입)/IN(취소) 트랜잭션을 상쇄하여 순수 투입량을 계산. + +### 9.2 추적 구조 + +``` +work_orders (작업지시) + │ + ├── stock_transactions (재고 트랜잭션) + │ ├── OUT (투입): qty < 0 + │ └── IN (취소/반납): qty > 0 + │ → 순수 투입량 = ABS(SUM(qty)) where qty < 0 + │ + └── work_order_material_inputs (개소별 투입자재) + └── stock_lots (LOT 정보) JOIN +``` + +### 9.3 표시 내용 + +| 항목 | 설명 | +|------|------| +| 자재명 | 투입된 원자재/부자재 이름 | +| LOT 번호 | 자재의 LOT 식별 번호 | +| 투입 수량 | OUT 트랜잭션 합계 (절대값) | +| 투입일 | 트랜잭션 일시 | + +--- + +## 10. 화면별 상세 + +### 10.1 문서 목록 (/documents) + +**필터 항목:** + +| 필터 | 타입 | 설명 | +|------|------|------| +| 검색 | text | 문서번호 또는 제목 | +| 상태 | dropdown | DRAFT, PENDING, APPROVED, REJECTED, CANCELLED, 휴지통(admin) | +| 양식분류 | dropdown | category | +| 템플릿 | dropdown | template_id | +| 날짜 범위 | date | created_at (from ~ to) | + +**목록 테이블 컬럼:** + +``` +문서번호 | 제목 | 양식 | 상태 | 작성자 | 작성일 | 결재현황 +``` + +### 10.2 문서 작성/수정 (/documents/create, /documents/{id}/edit) + +**폼 구성:** + +``` +┌──────────────────────────────────────────────┐ +│ 템플릿 선택 (읽기전용) │ +│ 제목 (필수) │ +├──────────────────────────────────────────────┤ +│ 기본 필드 (template.basicFields) │ +│ ┌─────────────────┬─────────────────┐ │ +│ │ 품명: [자동채움] │ LOT NO: [자동] │ │ +│ │ 납기일: [날짜] │ 발주처: [자동] │ │ +│ └─────────────────┴─────────────────┘ │ +├──────────────────────────────────────────────┤ +│ 섹션 1: 겉모양 검사 │ +│ ┌──────────────────────────────────────┐ │ +│ │ 도해 이미지 (있으면) │ │ +│ ├──────┬──────┬──────┬──────┬──────┤ │ +│ │ 구분 │ 항목 │ 기준 │ 결과1│ 결과2│ │ +│ ├──────┼──────┼──────┼──────┼──────┤ │ +│ │ 치수 │ 길이 │±5mm │ [ ] │ [ ] │ │ +│ │ 외관 │ 흠집 │ 없음 │ [✓] │ [✓] │ │ +│ ├──────┴──────┴──────┴──────┴──────┤ │ +│ │ [+ 행 추가] [행 삭제] │ │ +│ └──────────────────────────────────────┘ │ +├──────────────────────────────────────────────┤ +│ 외부 연결 (template.links) │ +│ 품목 선택: [검색 드롭다운] │ +├──────────────────────────────────────────────┤ +│ 첨부파일 │ +│ [일반 문서] [서명 이미지] [검사 사진] [참고 자료] │ +├──────────────────────────────────────────────┤ +│ [임시저장] [결재 요청] │ +└──────────────────────────────────────────────┘ +``` + +### 10.3 문서 상세 (/documents/{id}) + +**읽기 전용 표시:** + +``` +┌──────────────────────────────────────────────┐ +│ 문서번호: DOC-260306-001 상태: [🟢 승인] │ +│ 제목: 블라인드A 검사 성적서 │ +├──────────────────────────────────────────────┤ +│ 기본 필드 (읽기 전용) │ +├──────────────────────────────────────────────┤ +│ 검사 데이터 테이블 (읽기 전용) │ +├──────────────────────────────────────────────┤ +│ 결재 현황 │ +│ ┌────────┬────────┬────────┐ │ +│ │ 작성 │ 검토 │ 승인 │ │ +│ │ 홍길동 │ 김과장 │ 박부장 │ │ +│ │ ✓승인 │ ✓승인 │ ●대기 │ │ +│ └────────┴────────┴────────┘ │ +├──────────────────────────────────────────────┤ +│ 자재 투입 LOT (작업지시 연결 시) │ +│ ┌────────┬──────────┬──────┬──────┐ │ +│ │ 자재명 │ LOT 번호 │ 수량 │ 투입일│ │ +│ └────────┴──────────┴──────┴──────┘ │ +├──────────────────────────────────────────────┤ +│ 첨부파일 목록 │ +├──────────────────────────────────────────────┤ +│ [수정] [인쇄] [결재 승인] [결재 반려] │ +└──────────────────────────────────────────────┘ +``` + +### 10.4 인쇄 (/documents/{id}/print) + +성적서 형식의 인쇄 전용 화면. `window.print()` 호출. 작업지시 관련 자재(work_order_items) 데이터 포함. + +### 10.5 양식 목록 (/document-templates) + +**필터:** +- 검색: 양식명, 제목, 분류 +- 카테고리: common_codes 기반 + 기존 데이터 폴백 +- 활성 상태: 활성 / 비활성 / 휴지통(admin) + +**HTMX**: 필터 변경 시 테이블 영역만 부분 로드 + +--- + +## 11. 첨부파일 유형 + +| 유형 | 코드 | 용도 | 예시 | +|------|------|------|------| +| 일반 문서 | `general` | PDF, 엑셀 등 | 규격서, 보고서 | +| 서명 이미지 | `signature` | 검사 완료 서명 | 검사자 서명 사진 | +| 검사 사진 | `image` | 검사 증빙 사진 | 불량 부위 촬영 | +| 참고 자료 | `reference` | 참고용 문서 | KS 규격, 작업 지침 | + +--- + +## 12. API 연동 (MNG → API) + +MNG 뷰에서 데이터 저장/삭제는 **API 서버를 호출**하여 처리. GET 요청(뷰 렌더링)은 MNG 컨트롤러가 직접 처리. + +| 작업 | MNG (GET 요청) | API (POST/PUT/DELETE) | +|------|---------------|----------------------| +| 목록 조회 | `DocumentController::index()` | `GET /v1/documents` | +| 상세 조회 | `DocumentController::show()` | `GET /v1/documents/{id}` | +| 생성 | 폼 표시만 | `POST /v1/documents` | +| 수정 | 폼 표시만 | `PATCH /v1/documents/{id}` | +| 삭제 | - | `DELETE /v1/documents/{id}` | +| 결재 요청 | - | `POST /v1/documents/{id}/submit` | +| 승인 | - | `POST /v1/documents/{id}/approve` | +| 반려 | - | `POST /v1/documents/{id}/reject` | + +--- + +## 13. 카테고리 해결 로직 + +양식 카테고리는 **common_codes 테이블**에서 조회하되, 없으면 **기존 데이터에서 추출**하여 폴백. + +```php +// DocumentTemplateController::getCategories() +$categories = CommonCode::where('group', 'document_category') + ->orderBy('sort_order') + ->get(); + +if ($categories->isEmpty()) { + // 폴백: 기존 템플릿의 category 값에서 중복 제거 + $categories = DocumentTemplate::distinct('category') + ->pluck('category') + ->filter(); +} +``` + +--- + +## 14. 검사항목 확장 (field_values JSON) + +`document_template_section_items.field_values` JSON 컬럼으로 마이그레이션 없이 새 필드를 추가할 수 있다. + +```json +{ + "custom_field_1": "추가 기준값", + "min_value": 95.0, + "max_value": 105.0, + "unit": "mm" +} +``` + +> options JSON 컬럼 정책(`docs/standards/options-column-policy.md`) 준용 + +--- + +## 15. HTMX 전체 페이지 로드 규칙 + +문서관리 페이지들은 JavaScript를 사용하므로 HTMX 부분 로드 시 스크립트 미실행 문제가 있다. 컨트롤러에서 HX-Request 감지 시 **HX-Redirect로 전체 페이지 리로드 강제**. + +```php +if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('documents.index')); +} +``` + +--- + +## 관련 문서 + +- [README.md](README.md) — API 엔드포인트, 모델 요약, FormRequest +- [DB 스키마 — 문서/전자서명](../../system/database/documents.md) — 테이블 상세 +- [게시판 시스템](../boards/README.md) — 유사한 EAV 패턴 참고 +- [결재관리 시스템](../approvals/README.md) — 별도 결재 시스템 (문서관리와 독립) + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/documents/mng-document-template.md b/features/documents/mng-document-template.md new file mode 100644 index 0000000..5570865 --- /dev/null +++ b/features/documents/mng-document-template.md @@ -0,0 +1,826 @@ +# MNG 문서양식관리 (Document Template Management) + +> **작성일**: 2026-03-06 +> **상태**: 운영 중 +> **라우트**: `/document-templates` +> **관련**: [README.md](README.md) | [MNG 문서관리](mng-document-system.md) + +--- + +## 1. 개요 + +문서관리 시스템에서 사용하는 **서식(Template)**을 생성, 편집, 복제, 관리하는 기능. 검사 성적서, 작업지시서 등 다양한 문서 양식을 정의하며, 2가지 빌더 타입을 지원한다. + +| 빌더 | builder_type | UI 명칭 | 설명 | +|------|-------------|---------|------| +| **Legacy Builder** | `legacy` 또는 null | 새 양식 | 탭 기반 폼 UI (순수 JavaScript) | +| **Block Builder** | `block` | 양식 디자이너 | WYSIWYG 캔버스 편집기 (Alpine.js + SortableJS) | + +> **명칭 변경 이력**: Block Builder의 UI 표시 명칭이 '블록 빌더' → '양식 디자이너'로 변경됨 (2026-02-28) + +**핵심 기능:** +- 결재선, 기본필드, 검사 기준서, 테이블 컬럼 정의 +- EAV 데이터 구조의 서식 스키마 관리 +- 양식 복제 (연결품목 제외) +- 프리셋 자동 제안 (카테고리별) +- 소프트 삭제 + 휴지통 관리 (슈퍼어드민) + +--- + +## 2. 라우트 + +### 2.1 웹 라우트 (페이지) + +``` +GET /document-templates → index (목록) +GET /document-templates/create → create (Legacy 신규 생성) +GET /document-templates/block-create → blockCreate (양식 디자이너 신규 생성) +GET /document-templates/{id}/edit → edit (Legacy 편집) +GET /document-templates/{id}/block-edit → blockEdit (양식 디자이너 편집) +``` + +### 2.2 API 라우트 (CRUD + 기능) + +``` +Prefix: /api/admin/document-templates (HQ 관리자 전용) + +GET / → index (HTMX 테이블) +POST / → store (생성) +GET /{id} → show (상세 조회) +PUT /{id} → update (수정) +DELETE /{id} → destroy (소프트 삭제) +DELETE /{id}/force → forceDestroy (영구삭제, 슈퍼어드민) +POST /{id}/restore → restore (복원, 슈퍼어드민) +POST /{id}/toggle-active → toggleActive (활성 토글) +POST /{id}/duplicate → duplicate (복제) +POST /upload-image → uploadImage (이미지 업로드) +GET /admin/common-codes/{group} → getCommonCodes (공통코드 조회) +``` + +--- + +## 3. 모델 구조 + +### 3.1 모델 관계도 + +``` +DocumentTemplate (서식 마스터) +├── 1:N DocumentTemplateApprovalLine (결재선) +├── 1:N DocumentTemplateBasicField (기본필드) +├── 1:N DocumentTemplateSection (섹션/기준서) +│ └── 1:N DocumentTemplateSectionItem (섹션 항목) +├── 1:N DocumentTemplateSectionField (섹션 필드) +├── 1:N DocumentTemplateColumn (테이블 컬럼) +└── 1:N DocumentTemplateLink (연결 설정) + └── 1:N DocumentTemplateLinkValue (연결 값) +``` + +### 3.2 DocumentTemplate 핵심 필드 + +```php +// 기본 정보 +builder_type // 'legacy' | 'block' +name // 양식명 +category // 분류명 +title // 문서 제목 + +// 회사 정보 +company_name // 회사명 +company_address // 회사 주소 +company_contact // 회사 연락처 + +// 하단 설정 +footer_remark_label // 비고 라벨 +footer_judgement_label // 판정 라벨 +footer_judgement_options // array - 판정 선택지 + +// Block Builder 전용 +schema // array - 블록 스키마 (JSON) +page_config // array - 페이지 설정 (A4/A3, 여백 등) + +// 연결 (레거시) +linked_item_ids // array - 연결 품목 ID 목록 +linked_process_id // int - 연결 공정 ID + +// 상태 +is_active // boolean - 활성 여부 +deleted_at // timestamp - 소프트 삭제 +deleted_by // int - 삭제자 +``` + +**Helper 메서드:** + +```php +isBlockBuilder(): bool // builder_type === 'block' +isLegacyBuilder(): bool // builder_type !== 'block' +``` + +### 3.3 DocumentTemplateApprovalLine (결재선) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `template_id` | FK | 서식 ID | +| `name` | string | 결재자 이름/직책 | +| `department` | string | 부서 | +| `role` | string | 역할 (작성/검토/승인) | +| `sort_order` | int | 순서 | + +### 3.4 DocumentTemplateBasicField (기본필드) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `template_id` | FK | 서식 ID | +| `field_key` | string | 필드 키 (bf_ 접두사) | +| `label` | string | 라벨 | +| `field_type` | string | text, date, select 등 | +| `default_value` | string | 기본값 | +| `is_required` | boolean | 필수 여부 | +| `sort_order` | int | 순서 | +| `options` | array | 선택지 (select 타입) | + +### 3.5 DocumentTemplateSection (섹션/검사 기준서) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `template_id` | FK | 서식 ID | +| `title` | string | 섹션 제목 | +| `image_path` | string | 섹션 이미지 경로 | +| `sort_order` | int | 순서 | + +**하위 관계:** + +``` +Section 1:N SectionItem + ├── category // 카테고리 (그룹핑) + ├── name // 항목명 + ├── standard // 기준 + ├── tolerance_type // 공차 유형 (symmetric/asymmetric/range/limit) + ├── tolerance_plus // +공차 + ├── tolerance_minus // -공차 + ├── reference_value // 기준값 + ├── method // 검사방법 + ├── measurement_type // 측정유형 + └── frequency // 검사주기 +``` + +### 3.6 DocumentTemplateColumn (테이블 컬럼) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `template_id` | FK | 서식 ID | +| `label` | string | 컬럼 라벨 | +| `group_name` | string | 그룹명 (다단계 "/" 구분) | +| `width` | int | 컬럼 너비 | +| `column_type` | string | text, check, complex, select, measurement | +| `sub_labels` | array | complex 타입 하위 라벨 | +| `sort_order` | int | 순서 | + +### 3.7 DocumentTemplateLink (연결 설정) + +| 필드 | 타입 | 설명 | +|------|------|------| +| `template_id` | FK | 서식 ID | +| `link_key` | string | 연결 키 | +| `label` | string | 라벨 | +| `link_type` | string | `single` / `multiple` | +| `source_table` | string | `items` / `processes` / `users` | +| `search_params` | array | 검색 파라미터 | +| `display_fields` | array | 표시 필드 | +| `is_required` | boolean | 필수 여부 | +| `sort_order` | int | 순서 | + +**하위 관계:** + +``` +Link 1:N LinkValue + ├── link_id // FK → Link + ├── linkable_id // 연결 엔티티 ID + └── (source_table에 따라 items/processes/users 참조) +``` + +**레거시 호환 처리:** + +```php +// 신규 links가 있으면 사용 +if ($template->links->isNotEmpty()) { + // template_links + link_values 사용 +} + +// 레거시만 있으면 가상 엔트리 생성 +if (!empty($template->linked_item_ids)) { + return [['link_key' => 'items', 'values' => [...]]] +} +``` + +--- + +## 4. 컨트롤러 상세 + +### 4.1 DocumentTemplateController (웹) + +| 메서드 | 동작 | +|--------|------| +| `index()` | HTMX 요청 → HX-Redirect 반환 (전체 페이지 로드 강제) | +| `create()` | Legacy 신규 생성 폼 렌더링 | +| `edit($id)` | Legacy 편집. 양식 디자이너 타입이면 `blockEdit`으로 자동 리다이렉트 | +| `blockCreate()` | 양식 디자이너 신규 생성 (빈 캔버스) | +| `blockEdit($id)` | 양식 디자이너 편집 (스키마 로드) | + +**공통 데이터 준비:** + +```php +// 현재 테넌트 조회 +$tenantId = getCurrentTenant(); // 세션의 selected_tenant_id + +// 카테고리 목록 = common_codes + 기존 템플릿 카테고리 +$categories = getCategories(); + +// 기본필드 키 옵션 +$basicFieldKeys = getBasicFieldKeys(); // common_codes 'doc_template_basic_field' +``` + +### 4.2 DocumentTemplateApiController (API) + +#### `index()` — HTMX 테이블 조회 + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 양식명/분류 검색 | +| `category` | string | 분류 필터 | +| `is_active` | string | `1` / `0` / `TRASHED` (휴지통) | + +```php +// 휴지통 모드 (슈퍼어드민 전용) +if ($isActive === 'TRASHED') { + $query->onlyTrashed(); +} +``` + +#### `store()` / `update()` — 생성/수정 + +``` +요청 데이터 + ↓ +검증 (직접 validate, FormRequest 미사용) + ↓ +연결품목 중복 검증 (checkLinkedItemDuplicates) + ↓ +DB::transaction 시작 + ↓ +Template 생성/수정 + ↓ +saveRelations() — 관계 데이터 upsert + ↓ +DB::transaction 완료 + ↓ +JSON 응답 +``` + +#### `duplicate()` — 양식 복제 + +```php +$source = DocumentTemplate::with([...all relationships...]); + +$newTemplate = DocumentTemplate::create([ + ...원본 데이터, + 'name' => request('name', '원본 (복사)'), + 'is_active' => false, // 비활성으로 생성 + 'linked_item_ids' => null, // 연결품목 제외 + 'linked_process_id' => null, // 연결공정 제외 +]); + +// 각 관계 데이터 복사 (approvalLines, basicFields, sections, columns...) +// linkValues는 복사 안 함 (동일 분류 내 중복 방지) +``` + +#### `forceDestroy()` — 영구삭제 + +```php +// 사전 검사: 참조하는 문서 존재 여부 +$documentCount = Document::withTrashed() + ->where('template_id', $id) + ->count(); + +if ($documentCount > 0) { + return 422; // "이 양식을 사용한 문서 {count}건이 있어 삭제 불가" +} +``` + +#### `uploadImage()` — 이미지 업로드 + +``` +요청 (multipart) + ↓ +ApiTokenService::exchangeToken($userId, $tenantId) + ↓ +API /files/upload 호출 (Bearer 토큰) + ↓ +응답: file_path (1/temp/2026/02/xxx.jpg) + ↓ +최종 URL: http://api.sam.kr/storage/tenants/{file_path} +``` + +--- + +## 5. 저장 메커니즘 (saveRelations) + +### 5.1 upsert 전략 + +| 관계 | 방식 | 이유 | +|------|------|------| +| approvalLines | 전체 삭제 → 재생성 | ID 참조 없음 | +| basicFields | 전체 삭제 → 재생성 | ID 참조 없음 | +| **sections** | **ID 보존 upsert** | document_data가 section_id 참조 | +| **sectionItems** | **ID 보존 upsert** | section 하위 항목 | +| **columns** | **ID 보존 upsert** | document_data가 column_id 참조 | +| sectionFields | 전체 삭제 → 재생성 | ID 참조 없음 | +| links + linkValues | 전체 삭제 → 재생성 | ID 참조 없음 | + +### 5.2 ID 보존 upsert 로직 + +```php +// 1. 요청 ID 수집 +$incomingIds = collect($data['sections'])->pluck('id')->filter(); + +// 2. 요청에 없는 항목 삭제 +$template->sections() + ->whereNotIn('id', $incomingIds) + ->each(function($s) { + $s->items()->delete(); + $s->delete(); + }); + +// 3. 각 항목 upsert +foreach ($data['sections'] as $section) { + if (!empty($section['id']) && $existing = $template->sections()->find($section['id'])) { + $existing->update($sectionData); // 기존: update + } else { + DocumentTemplateSection::create([...]); // 신규: create + } +} +``` + +> **ID 보존이 필수인 이유**: `document_data` 테이블이 `section_id`, `column_id`를 FK로 참조한다. 양식 수정 시 ID가 변경되면 기존 문서 데이터와의 매핑이 깨진다. + +--- + +## 6. 화면 구성 + +### 6.1 목록 화면 (`index.blade.php`) + +``` +┌─────────────────────────────────────────────────┐ +│ 문서양식관리 │ +│ [+ 새 양식] [+ 양식 디자이너] │ +├─────────────────────────────────────────────────┤ +│ 필터: [검색어] [분류 ▼] [활성/비활성/휴지통 ▼] │ +├─────────────────────────────────────────────────┤ +│ # │ 양식명 │ 분류 │ 활성 │ 수정일 │ 액션 │ +│ 1 │ FQC... │ 검사 │ ✅ │ 03-06 │ 편집 복제 삭제 │ +│ 2 │ 수입... │ 검사 │ ✅ │ 03-05 │ 편집 복제 삭제 │ +│ ...│ │ │ │ │ │ +└─────────────────────────────────────────────────┘ +``` + +**HTMX 테이블 로드:** + +```html +

+
+``` + +**액션 버튼:** +- **편집**: 새 양식 → `/document-templates/{id}/edit`, 양식 디자이너 → `/document-templates/{id}/block-edit` +- **복제**: `duplicateTemplate(id)` — 이름 입력 모달 후 POST +- **삭제**: `confirmDelete(id)` — 확인 후 DELETE +- **미리보기**: `previewTemplate(id)` — 모달 표시 +- **활성 토글**: `toggleActive(id)` — POST toggle-active +- **복원/영구삭제**: 휴지통 모드에서만 표시 (슈퍼어드민) + +### 6.2 Legacy Builder 편집 화면 (`edit.blade.php`) + +**4개 탭 구조:** + +``` +┌─────────────────────────────────────────────────────┐ +│ [기본정보] [기본필드] [검사 기준서] [테이블 컬럼] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ (각 탭 콘텐츠) │ +│ │ +├─────────────────────────────────────────────────────┤ +│ [미리보기] [저장] [취소] │ +└─────────────────────────────────────────────────────┘ +``` + +#### 탭 1: 기본정보 + +| 필드 | 설명 | +|------|------| +| 양식명 | 서식 이름 (필수) | +| 제목 | 문서 제목 | +| 분류 | 카테고리 (common_codes + 기존값) | +| 회사명 | 문서 헤더 회사명 | +| 회사 주소/연락처 | 문서 헤더 | +| 활성 | 체크박스 | +| 결재선 | 동적 행 추가/삭제 (이름, 부서, 역할) | + +#### 탭 2: 기본필드 + +| 항목 | 설명 | +|------|------| +| 필드 키 | `bf_` 접두사 (common_codes에서 선택) | +| 라벨 | 표시 라벨 | +| 필드 타입 | text, date, select 등 | +| 기본값 | 문서 생성 시 자동 입력 | +| 필수 여부 | 체크박스 | + +#### 탭 3: 검사 기준서 + +``` +┌──────────────────────────────────────────────────┐ +│ 섹션 1: [제목 입력] [이미지 업로드] [+ 항목 추가] │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ 카테고리 │ 항목 │ 기준 │ 공차 │ 기준값 │ ... │ │ +│ │ 외관 │ 색상 │ 기준 │ ±0.5 │ 5.0 │ ... │ │ +│ │ 외관 │ 흠집 │ 무 │ │ │ ... │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ 섹션 2: [제목 입력] [이미지 업로드] [+ 항목 추가] │ +│ ... │ +│ [+ 섹션 추가] │ +└──────────────────────────────────────────────────┘ +``` + +**공차 유형:** + +| 유형 | 입력 | 표시 예 | +|------|------|--------| +| `symmetric` | ± 값 | ±0.5 | +| `asymmetric` | +값, -값 | +0.3 / -0.2 | +| `range` | 최소~최대 | 4.5 ~ 5.5 | +| `limit` | 상한 또는 하한 | ≤ 10 | + +#### 탭 4: 테이블 컬럼 + +| 항목 | 설명 | +|------|------| +| 라벨 | 컬럼 헤더 | +| 그룹명 | 다단계 그룹 ("/" 구분) | +| 너비 | 컬럼 너비 (px 또는 %) | +| 컬럼 타입 | text, check, complex, select, measurement | +| 하위 라벨 | complex 타입 시 sub_labels | + +**자동 컬럼 생성:** + +``` +[기준서에서 자동 생성] 버튼 클릭 + ↓ +검사 기준서 섹션의 항목들을 분석 + ↓ +카테고리 그룹별 컬럼 자동 생성 + ↓ +measurement_type에 따라 컬럼 타입 결정 +``` + +### 6.3 양식 디자이너 편집 화면 (`block-editor.blade.php`) + +**3패널 레이아웃:** + +``` +┌──────────┬──────────────────────────┬───────────┐ +│ 팔레트 │ 캔버스 │ 속성 패널 │ +│ (220px) │ (flex: 1) │ (300px) │ +│ │ │ │ +│ 기본: │ ┌──────────────────────┐ │ 선택 블록: │ +│ □ 제목 │ │ [제목 블록] │ │ │ +│ □ 문단 │ │ [문단 블록] │ │ 제목: ... │ +│ □ 테이블 │ │ [테이블 블록] │ │ 크기: ... │ +│ □ 컬럼 │ │ [입력 필드 블록] │ │ 정렬: ... │ +│ □ 구분선 │ │ │ │ │ +│ □ 여백 │ └──────────────────────┘ │ │ +│ │ │ │ +│ 폼: │ │ │ +│ □ 텍스트 │ │ │ +│ □ 숫자 │ │ │ +│ □ 날짜 │ │ │ +│ □ 선택 │ │ │ +│ □ 체크 │ │ │ +│ □ 텍스트영역│ │ │ +│ □ 서명 │ │ │ +└──────────┴──────────────────────────┴───────────┘ +``` + +**블록 타입 (15개):** + +| 분류 | 타입 | 설명 | +|------|------|------| +| 기본 | `heading` | 제목 (h1~h6) | +| 기본 | `paragraph` | 문단 텍스트 | +| 기본 | `table` | 테이블 (행/열 편집) | +| 기본 | `columns` | 다단 컬럼 레이아웃 | +| 기본 | `divider` | 구분선 | +| 기본 | `spacer` | 여백 | +| 폼 | `text_field` | 텍스트 입력 | +| 폼 | `number_field` | 숫자 입력 | +| 폼 | `date_field` | 날짜 입력 | +| 폼 | `select_field` | 선택 드롭다운 | +| 폼 | `checkbox_field` | 체크박스 | +| 폼 | `textarea_field` | 긴 텍스트 입력 | +| 폼 | `signature_field` | 서명 영역 | + +**Alpine.js 상태 관리:** + +```javascript +blockEditor(initialSchema, templateId) { + blocks: [], // 블록 배열 + selectedBlockId: null, // 현재 선택 블록 + history: [], // Undo/Redo 스택 (최대 50) + historyIndex: -1, + pageConfig: { // 페이지 설정 + size: 'A4', // A4 / A3 + orientation: 'portrait', // portrait / landscape + margins: { top, right, bottom, left } + }, + templateName: '', + category: '' +} +``` + +**키보드 단축키:** + +| 단축키 | 기능 | +|--------|------| +| `Ctrl+Z` / `Cmd+Z` | Undo | +| `Ctrl+Shift+Z` / `Cmd+Shift+Z` | Redo | +| `Ctrl+S` / `Cmd+S` | 저장 | + +**SortableJS:** +- 캔버스 내 블록 드래그-앤-드롭 정렬 +- 팔레트에서 캔버스로 블록 추가 + +--- + +## 7. 미리보기 시스템 + +### 7.1 Legacy Builder 미리보기 + +```javascript +buildDocumentPreviewHtml(data) +├── 결재란 테이블 (역할별 칸) +├── 기본필드 (2열 15:35:15:35 비율) +├── 섹션별 이미지 (title + image 또는 placeholder) +├── 검사 데이터 테이블 +│ ├── 다단계 그룹 헤더 (group_name "/" 구분) +│ ├── sub_labels (complex 컬럼) +│ ├── 항목 행 (카테고리 그룹핑) +│ └── 측정치 셀 (measurement_type별 렌더) +└── 비고/종합판정 섹션 +``` + +### 7.2 양식 디자이너 미리보기 + +```javascript +buildBlockPreviewHtml(data) +├── 블록 타입별 HTML 렌더링 +├── 폼 필드 placeholder 표시 +└── A4/A3 레이아웃 시뮬레이션 +``` + +### 7.3 이미지 URL 처리 + +```javascript +_previewImageUrl(imagePath) +├── http(s):// 시작 → 그대로 사용 +├── /^\d+\// 패턴 → API tenant storage URL 생성 +│ → http://api.sam.kr/storage/tenants/{imagePath} +└── 기타 → MNG local storage (/storage/{imagePath}) +``` + +--- + +## 8. 분류(Category) 관리 + +### 8.1 소스 (우선순위) + +1. **common_codes** (code_group = `document_category`, is_active = true) + - tenant_id가 있는 것 우선 (테넌트 전용) + - tenant_id가 null인 것도 포함 (공통) + - code 기준 중복 제거 (테넌트 우선) +2. **기존 템플릿의 category** (common_codes에 없는 값) + - 기존 이름 그대로 추가 + +### 8.2 연동 공통코드 그룹 + +| 그룹 | 용도 | +|------|------| +| `document_category` | 문서 분류 | +| `doc_template_basic_field` | 기본필드 키 옵션 | +| `doc_inspection_method` | 검사방법 | +| `doc_measurement_type` | 측정유형 | + +--- + +## 9. 프리셋 시스템 + +### 9.1 테이블 + +``` +document_template_field_presets +├── name // 프리셋 이름 +├── category // 대상 카테고리 +├── description // 설명 +└── field_definitions // array - 필드 정의 목록 + [{ field_key, label, field_type, options, ... }] +``` + +### 9.2 동작 + +``` +분류(Category) 변경 + ↓ +매칭 프리셋 검색 + ↓ +기존 section_fields가 비어있으면 + ↓ +"'{category}' 카테고리에 맞는 프리셋을 적용할까요?" 확인 + ↓ +승인 시 field_definitions 자동 적용 +``` + +> **주의**: 초기 로드 시에는 제안하지 않음. 분류 변경 시에만 제안. + +--- + +## 10. 연결품목 중복 검증 + +### 10.1 규칙 + +같은 category 내 서로 다른 템플릿이 동일한 items를 연결할 수 없다. + +### 10.2 검증 로직 + +```php +checkLinkedItemDuplicates($templateId, $category, $itemIds) + +// 1. 같은 category의 다른 템플릿 조회 +$otherTemplates = DocumentTemplate::where('category', $category) + ->where('id', '!=', $templateId) + ->get(); + +// 2. 각 템플릿의 연결품목 수집 +foreach ($otherTemplates as $other) { + // 레거시: linked_item_ids (JSON 배열) + // 신규: template_links → linkValues (source_table = 'items') + $existingItemIds = ...; +} + +// 3. 교집합 검사 +$duplicates = array_intersect($itemIds, $existingItemIds); +if (!empty($duplicates)) { + return 422; // 중복 항목 목록과 함께 오류 반환 +} +``` + +--- + +## 11. JavaScript 상태 관리 (Legacy Builder) + +### 11.1 templateState 객체 + +```javascript +const templateState = { + // 기본정보 + id, name, category, title, + company_name, company_address, company_contact, + footer_remark_label, footer_judgement_label, + footer_judgement_options, + is_active, + + // 관계 데이터 + approval_lines: [], // 결재선 + basic_fields: [], // 기본필드 + sections: [], // 섹션 + items + columns: [], // 테이블 컬럼 + section_fields: [], // 섹션 필드 + template_links: [], // 연결 설정 + values +}; +``` + +### 11.2 저장 흐름 + +``` +사용자 입력 (Blade 폼) + ↓ +templateState 객체 갱신 + ↓ +saveTemplate() 호출 + ↓ +fetch POST/PUT /api/admin/document-templates + ↓ +DocumentTemplateApiController::store/update() + ↓ +검증 → 중복 검사 → DB 트랜잭션 → saveRelations() + ↓ +JSON 응답 + ↓ +showToast() 메시지 + ↓ +htmx.trigger('#template-table', 'filterSubmit') → 테이블 새로고침 +``` + +--- + +## 12. 양식 디자이너(Block Builder) vs 새 양식(Legacy Builder) 비교 + +| 항목 | 양식 디자이너 | 새 양식 | +|------|:------------:|:-------------:| +| builder_type | `block` | `legacy` 또는 null | +| 편집 UI | WYSIWYG 캔버스 (Alpine.js) | 탭 폼 (순수 JavaScript) | +| 데이터 저장 | `schema` JSON 컬럼 | 관계 테이블 (7개) | +| Undo/Redo | 히스토리 스택 (최대 50) | 불가 | +| 블록 타입 | 15개 (기본 6 + 폼 7 + 기타 2) | N/A | +| 드래그-앤-드롭 | SortableJS | 불가 | +| 페이지 설정 | A4/A3, 여백, 방향 | 없음 | +| 복제 | 스키마 JSON 복사 | 각 관계 데이터 개별 복사 | +| 미리보기 함수 | `buildBlockPreviewHtml()` | `buildDocumentPreviewHtml()` | +| 적합 용도 | 자유 레이아웃 문서 | 정형화된 검사 성적서 | + +--- + +## 13. 권한 및 보안 + +### 13.1 미들웨어 + +- **웹 라우트**: 일반 인증 (auth) +- **API 라우트**: HQ 관리자 미들웨어 (`admin` prefix) + +### 13.2 슈퍼어드민 전용 기능 + +| 기능 | 엔드포인트 | +|------|-----------| +| 영구삭제 | `DELETE /{id}/force` | +| 복원 | `POST /{id}/restore` | +| 휴지통 조회 | `GET /?is_active=TRASHED` | + +### 13.3 삭제 보호 + +- 소프트 삭제: `deleted_at` + `deleted_by` 기록 +- 영구삭제 전 참조 문서 검사 (Document 테이블) +- 참조 문서가 있으면 영구삭제 불가 (422 응답) + +--- + +## 14. API 프로젝트 연동 + +### 14.1 API 서비스 + +```php +// DocumentTemplateService (API) +list(array $params): LengthAwarePaginator + // 필터: is_active, category, search + +show(int $id): DocumentTemplate + // 전체 관계 로드 (approvalLines, basicFields, sections, columns...) +``` + +### 14.2 API 엔드포인트 + +``` +GET /v1/document-templates → index (목록) +GET /v1/document-templates/{id} → show (상세) +``` + +> API는 **읽기 전용**. 서식 생성/수정은 MNG에서만 수행. + +--- + +## 15. 주요 파일 경로 + +| 기능 | 경로 | +|------|------| +| 웹 컨트롤러 | `mng/app/Http/Controllers/DocumentTemplateController.php` | +| API 컨트롤러 | `mng/app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php` | +| 모델 (8개) | `mng/app/Models/DocumentTemplate*.php` | +| 뷰 - 목록 | `mng/resources/views/document-templates/index.blade.php` | +| 뷰 - Legacy 편집 | `mng/resources/views/document-templates/edit.blade.php` | +| 뷰 - 양식 디자이너 | `mng/resources/views/document-templates/block-editor.blade.php` | +| 뷰 - 테이블 | `mng/resources/views/document-templates/partials/table.blade.php` | +| 뷰 - 미리보기 | `mng/resources/views/document-templates/partials/preview-modal.blade.php` | +| API 서비스 | `api/app/Services/DocumentTemplateService.php` | +| API 모델 | `api/app/Models/Documents/DocumentTemplate*.php` | + +--- + +## 관련 문서 + +- [README.md](README.md) — 문서관리 시스템 개요 (API 중심) +- [MNG 문서관리](mng-document-system.md) — 문서 생성/편집/결재 (서식을 사용하는 측) +- [DB 스키마 — 문서](../../system/database/documents.md) + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/planning/README.md b/features/planning/README.md new file mode 100644 index 0000000..ef681d0 --- /dev/null +++ b/features/planning/README.md @@ -0,0 +1,129 @@ +# 주일기업 기획 메뉴 + +> **작성일**: 2026-03-06 +> **상태**: 운영 중 +> **프로젝트**: SAM MNG (관리자 웹) +> **라우트 접두사**: `/juil` + +--- + +## 1. 개요 + +### 1.1 목적 + +블라인드/스크린 제조업체의 현장 관리를 위한 기획 도구 모음. 견적부터 공사, 준공까지의 업무 흐름과 현장 기록(사진대지), 회의 기록(STT/AI 요약)을 제공한다. + +### 1.2 문서 구조 + +| 문서 | 설명 | +|------|------| +| **README.md** (이 문서) | 전체 개요, 메뉴 구조, 아키텍처 | +| [construction-photos.md](construction-photos.md) | 공사현장 사진대지 기술 명세 | +| [meeting-minutes.md](meeting-minutes.md) | 회의록 작성 기술 명세 (STT/AI 통합) | +| [planning-views.md](planning-views.md) | 견적/프로젝트/워크플로우 화면 명세 | + +### 1.3 하위 메뉴 구조 + +``` +주일기업 기획 +├── 견적/입찰/공사관리 /juil/estimate +├── 프로젝트관리/기성청구 /juil/project +├── 업무 Workflow /juil/workflow +├── 공사현장 사진대지 /juil/construction-photos +└── 회의록 작성 /juil/meeting-minutes +``` + +--- + +## 2. 아키텍처 + +### 2.1 기술 스택 + +| 계층 | 기술 | 설명 | +|------|------|------| +| 뷰 | Blade + React (인라인) + Babel | 브라우저 트랜스파일 React 컴포넌트 | +| API | Laravel Controller + Service | JSON API (AJAX) | +| 모델 | Eloquent ORM | Multi-tenant (BelongsToTenant) | +| 파일 저장 | Google Cloud Storage | 사진, 오디오 파일 | +| AI | Gemini API (Vertex AI) | 요약, 화자 분리 | +| STT | Google Speech-to-Text V1/V2 + Web Speech API | 음성 인식 | + +### 2.2 프로젝트 파일 구조 + +``` +mng/ +├── app/Http/Controllers/ +│ ├── PlanningController.php ← 견적/프로젝트/워크플로우 +│ ├── ConstructionSitePhotoController.php ← 사진대지 CRUD + 파일 관리 +│ └── MeetingMinuteController.php ← 회의록 CRUD + AI 기능 +├── app/Services/ +│ ├── ConstructionSitePhotoService.php ← 사진대지 비즈니스 로직 +│ └── MeetingMinuteService.php ← 회의록 + AI 통합 로직 +├── app/Models/ +│ ├── ConstructionSitePhoto.php ← 사진대지 모델 +│ ├── ConstructionSitePhotoRow.php ← 사진 행 모델 +│ ├── MeetingMinute.php ← 회의록 모델 +│ └── MeetingMinuteSegment.php ← 회의 세그먼트 모델 +└── resources/views/juil/ + ├── estimate.blade.php ← 견적/입찰/공사관리 + ├── project.blade.php ← 프로젝트관리/기성청구 + ├── workflow.blade.php ← 업무 Workflow + ├── construction-photos.blade.php ← 사진대지 SPA + └── meeting-minutes.blade.php ← 회의록 SPA +``` + +### 2.3 기능별 구현 현황 + +| 기능 | 구현 방식 | 백엔드 | DB | +|------|----------|--------|-----| +| 견적/입찰/공사관리 | React 뷰 (목데이터) | PlanningController (뷰 반환만) | 없음 | +| 프로젝트관리/기성청구 | React 뷰 (목데이터) | PlanningController (뷰 반환만) | 없음 | +| 업무 Workflow | React 뷰 (정적 데이터) | PlanningController (뷰 반환만) | 없음 | +| 공사현장 사진대지 | React SPA + API | Controller + Service | 2 테이블 | +| 회의록 작성 | React SPA + API | Controller + Service + AI | 2 테이블 | + +--- + +## 3. 외부 서비스 의존성 + +| 서비스 | 용도 | 추적 | +|--------|------|------| +| **Google Cloud Storage** | 사진/오디오 파일 저장 | `AiTokenHelper::saveGcsStorageUsage()` | +| **Google Speech-to-Text V2 (Chirp2)** | 자동 화자 분리 (최우선) | `AiTokenHelper::saveSttUsage()` | +| **Google Speech-to-Text V1** | 화자 분리 (V2 실패 시 폴백) | `AiTokenHelper::saveSttUsage()` | +| **Gemini API (Vertex AI)** | 요약 생성 + 화자 재분배 | `AiTokenHelper::saveGeminiUsage()` | +| **Web Speech API** | 브라우저 음성 입력 (현장명/설명) | `logSttUsage()` | + +### 3.1 도메인 용어 힌트 (STT 정확도 향상) + +``` +블라인드, 스크린, 롤스크린, 허니콤, 버티컬, +원단, 바텀레일, 헤드레일, 브라켓, +주일, 경동, 주일블라인드, 경동블라인드, +수주, 발주, 납기, 출하, 재고, 원가, 단가, +SAM, ERP, MES +``` + +--- + +## 4. HTMX 전체 페이지 로드 규칙 + +모든 `/juil/*` 페이지는 React 인라인 컴포넌트를 사용하므로, HTMX 부분 로드 시 스크립트가 실행되지 않는다. 각 컨트롤러 메서드에서 HTMX 요청 감지 시 **HX-Redirect로 전체 페이지 리로드를 강제**한다. + +```php +if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.estimate')); +} +``` + +--- + +## 5. 관련 문서 + +- [공사현장 사진대지](construction-photos.md) — GCS 파일 관리, 행 구조, 음성 입력 +- [회의록 작성](meeting-minutes.md) — STT/화자분리/AI 요약, 오디오 녹음 +- [견적/프로젝트/워크플로우](planning-views.md) — React 뷰 구성, 업무 프로세스 정의 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/planning/construction-photos.md b/features/planning/construction-photos.md new file mode 100644 index 0000000..0a85ffb --- /dev/null +++ b/features/planning/construction-photos.md @@ -0,0 +1,275 @@ +# 공사현장 사진대지 + +> **작성일**: 2026-03-06 +> **상태**: 운영 중 +> **라우트**: `/juil/construction-photos` +> **관련**: [README.md](README.md) | [회의록](meeting-minutes.md) | [뷰 화면](planning-views.md) + +--- + +## 1. 개요 + +건설/시공 현장의 작업 과정을 **작업전/작업중/작업후** 3단계 사진으로 기록하고 관리하는 기능. Google Cloud Storage에 사진을 저장하며, 음성 입력(Web Speech API)으로 현장명과 설명을 입력할 수 있다. + +--- + +## 2. 라우트 + +``` +/juil/construction-photos +├── GET / → index (목록 페이지) +├── GET /list → list (JSON 목록) +├── POST / → store (새 사진대지 등록) +├── POST /log-stt-usage → logSttUsage (STT 시간 기록) +├── GET /{id} → show (상세 조회) +├── PUT /{id} → update (메타데이터 수정) +├── DELETE /{id} → destroy (삭제) +├── POST /{id}/rows → addRow (행 추가) +├── DELETE /{id}/rows/{rowId} → deleteRow (행 삭제) +├── POST /{id}/rows/{rowId}/upload → uploadPhoto (사진 업로드) +├── DELETE /{id}/rows/{rowId}/photo/{type} → deletePhoto (사진 삭제) +└── GET /{id}/rows/{rowId}/download/{type} → downloadPhoto (다운로드) +``` + +--- + +## 3. 데이터베이스 + +### 3.1 construction_site_photos (사진대지) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | 테넌트 격리 | +| `user_id` | BIGINT FK | 등록자 | +| `site_name` | VARCHAR(200) | 현장명 (필수) | +| `work_date` | DATE | 작업일자 (필수) | +| `description` | TEXT NULL | 설명 | +| `deleted_at` | TIMESTAMP NULL | 소프트 삭제 | + +**인덱스**: `tenant_id`, `user_id`, `(tenant_id, work_date)` + +### 3.2 construction_site_photo_rows (사진 행) + +각 사진대지는 1개 이상의 행을 가지며, 각 행에 3개 타입(before/during/after) 사진 저장. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `construction_site_photo_id` | BIGINT FK | 부모 (cascade delete) | +| `sort_order` | INT | 정렬 순서 (0부터) | +| `before_photo_path` | VARCHAR(500) NULL | 작업전 GCS 경로 | +| `before_photo_gcs_uri` | VARCHAR(500) NULL | 작업전 GCS URI | +| `before_photo_size` | INT UNSIGNED NULL | 작업전 파일크기 (bytes) | +| `during_photo_path` | VARCHAR(500) NULL | 작업중 GCS 경로 | +| `during_photo_gcs_uri` | VARCHAR(500) NULL | 작업중 GCS URI | +| `during_photo_size` | INT UNSIGNED NULL | 작업중 파일크기 (bytes) | +| `after_photo_path` | VARCHAR(500) NULL | 작업후 GCS 경로 | +| `after_photo_gcs_uri` | VARCHAR(500) NULL | 작업후 GCS URI | +| `after_photo_size` | INT UNSIGNED NULL | 작업후 파일크기 (bytes) | + +### 3.3 테이블 관계 + +``` +construction_site_photos + │ 1:N + ▼ +construction_site_photo_rows (sort_order ASC) + ├── before_photo_* (작업전) + ├── during_photo_* (작업중) + └── after_photo_* (작업후) +``` + +--- + +## 4. API 명세 + +### 4.1 목록 조회 + +``` +GET /juil/construction-photos/list +``` + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 현장명 검색 | +| `date_from` | date | 시작일 | +| `date_to` | date | 종료일 | +| `per_page` | int | 페이지당 건수 | + +### 4.2 생성 + +``` +POST /juil/construction-photos +``` + +| 필드 | 규칙 | 설명 | +|------|------|------| +| `site_name` | required, max:200 | 현장명 | +| `work_date` | required, date | 작업일자 | +| `description` | nullable, max:2000 | 설명 | + +> 생성 시 빈 행 1개 자동 추가 + +### 4.3 사진 업로드 + +``` +POST /juil/construction-photos/{id}/rows/{rowId}/upload +``` + +| 필드 | 규칙 | 설명 | +|------|------|------| +| `type` | required, in:before,during,after | 사진 타입 | +| `photo` | required, image, mimes:jpeg,jpg,png,webp, max:10240 | 최대 10MB | + +### 4.4 사진 다운로드 + +``` +GET /juil/construction-photos/{id}/rows/{rowId}/download/{type}?inline=1 +``` + +| 파라미터 | 설명 | +|---------|------| +| `inline=1` | 브라우저 표시 (미지정 시 다운로드) | + +--- + +## 5. GCS 저장 구조 + +### 5.1 경로 패턴 + +``` +construction-site-photos/{tenant_id}/{photo_id}/{row_id}_{timestamp}_{type}.{ext} +``` + +**예시:** + +``` +construction-site-photos/1/42/15_1709723456_before.jpg +construction-site-photos/1/42/15_1709723456_during.jpg +construction-site-photos/1/42/15_1709723456_after.png +``` + +### 5.2 업로드 흐름 + +``` +클라이언트 (Canvas 이미지 압축: 1920px, quality 80%) + ↓ +FormData (multipart) 전송 + ↓ +컨트롤러: uploadPhoto() + ↓ +서비스: uploadPhoto() + ├── 기존 사진 있으면 GCS에서 삭제 + ├── GCS에 업로드 + ├── DB에 path + uri + size 저장 + └── AiTokenHelper::saveGcsStorageUsage() 호출 + ↓ +응답: { success, data: Photo with rows } +``` + +### 5.3 삭제 흐름 + +``` +사진 삭제: GCS 파일 삭제 → DB 필드 null +행 삭제: 행 내 모든 사진 GCS 삭제 → 행 삭제 → sort_order 재정렬 +사진대지 삭제: 모든 행의 모든 사진 GCS 삭제 → soft delete +``` + +--- + +## 6. 음성 입력 (Web Speech API) + +### 6.1 VoiceInputButton 컴포넌트 + +현장명, 설명 필드에 음성으로 텍스트 입력 가능. + +```javascript +// Web Speech Recognition 설정 +recognition.lang = 'ko-KR'; +recognition.continuous = true; +recognition.interimResults = true; +recognition.maxAlternatives = 1; +``` + +### 6.2 인식 상태 + +| 상태 | 표시 | 설명 | +|------|------|------| +| interim (미확정) | 이탤릭 + 회색 | 인식 중간 결과, 2초 후 소실 | +| final (확정) | 일반체 + 진한색 | 확정 텍스트, 영구 저장 | + +### 6.3 사용량 추적 + +``` +STT 사용 종료 시: +duration = Math.max(1, (Date.now() - startTime) / 1000) + ↓ +POST /juil/construction-photos/log-stt-usage + body: { duration_seconds } + ↓ +AiTokenHelper::saveSttUsage('공사현장사진대지-음성입력', seconds) +``` + +--- + +## 7. UI 구성 (React) + +### 7.1 사진 타입별 색상 + +| 타입 | 라벨 | 배경색 | 뱃지색 | +|------|------|--------|--------| +| `before` | 작업전 | `bg-blue-50` | `bg-blue-100 text-blue-800` | +| `during` | 작업중 | `bg-yellow-50` | `bg-yellow-100 text-yellow-800` | +| `after` | 작업후 | `bg-green-50` | `bg-green-100 text-green-800` | + +### 7.2 행 관리 + +- **행 추가**: sort_order 자동 계산 (마지막 + 1) +- **행 삭제**: 최소 1개 행 유지 필수 +- **행별 사진**: 각 행에 3개 타입 사진 독립 업로드/삭제 + +--- + +## 8. 모델 메서드 + +### 8.1 ConstructionSitePhoto + +```php +user() # BelongsTo User (등록자) +rows() # HasMany Row (sort_order ASC) +getPhotoCount(): int # 전체 사진 개수 (모든 행의 사진 합계) +``` + +### 8.2 ConstructionSitePhotoRow + +```php +constructionSitePhoto() # BelongsTo 부모 +hasPhoto(string $type): bool # 특정 타입 사진 존재 여부 +getPhotoCount(): int # 이 행의 사진 개수 (0~3) +``` + +### 8.3 ConstructionSitePhotoService + +```php +getList(array $filters) # 검색/필터 목록 (페이지네이션) +create(array $data) # 생성 + 빈 행 1개 자동 추가 +update(ConstructionSitePhoto, array $data) # 메타데이터만 수정 +delete(ConstructionSitePhoto) # GCS 전체 삭제 → soft delete +uploadPhoto(Row, UploadedFile, string $type) # GCS 업로드 + DB 기록 +deletePhotoByType(Row, string $type) # 특정 타입 GCS 삭제 +addRow(ConstructionSitePhoto) # 행 추가 (sort_order 자동) +deleteRow(Row) # 행 내 GCS 삭제 → 행 삭제 → 재정렬 +``` + +--- + +## 관련 문서 + +- [README.md](README.md) — 기획 메뉴 전체 개요 +- [회의록 작성](meeting-minutes.md) — STT/AI 통합 회의 기록 +- [견적/프로젝트/워크플로우](planning-views.md) — 화면 명세 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/planning/meeting-minutes.md b/features/planning/meeting-minutes.md new file mode 100644 index 0000000..09d089a --- /dev/null +++ b/features/planning/meeting-minutes.md @@ -0,0 +1,456 @@ +# 회의록 작성 + +> **작성일**: 2026-03-06 +> **상태**: 운영 중 +> **라우트**: `/juil/meeting-minutes` +> **관련**: [README.md](README.md) | [사진대지](construction-photos.md) | [뷰 화면](planning-views.md) + +--- + +## 1. 개요 + +음성으로 회의 내용을 기록하고, **Google STT(화자 분리)** + **Gemini AI(요약/결정사항/액션아이템)** 로 자동 정리하는 회의록 시스템. 브라우저 MediaRecorder로 녹음하고, GCS에 오디오를 저장하며, 세그먼트(화자별 발화)를 관리한다. + +--- + +## 2. 라우트 + +``` +/juil/meeting-minutes +├── GET / → index (목록 페이지) +├── GET /list → list (JSON 목록) +├── POST / → store (새 회의록 생성) +├── POST /log-stt-usage → logSttUsage (STT 시간 기록) +├── GET /{id} → show (상세 조회 + segments) +├── PUT /{id} → update (메타데이터 수정) +├── DELETE /{id} → destroy (삭제) +├── POST /{id}/segments → saveSegments (세그먼트 저장) +├── POST /{id}/upload-audio → uploadAudio (오디오 업로드) +├── POST /{id}/summarize → summarize (AI 요약 생성) +├── POST /{id}/diarize → diarize (자동 화자 분리) +└── GET /{id}/download-audio → downloadAudio (오디오 다운로드) +``` + +--- + +## 3. 데이터베이스 + +### 3.1 meeting_minutes (회의록) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `tenant_id` | BIGINT FK | 테넌트 격리 | +| `user_id` | BIGINT FK | 작성자 | +| `title` | VARCHAR(300) | 제목 (기본: "무제 회의록") | +| `folder` | VARCHAR(100) NULL | 폴더 분류 | +| `participants` | JSON NULL | 참여자 목록 배열 | +| `meeting_date` | DATE | 회의 날짜 | +| `meeting_time` | TIME NULL | 회의 시작 시간 | +| `duration_seconds` | INT UNSIGNED | 녹음 총 시간(초) | +| `audio_file_path` | VARCHAR(500) NULL | 오디오 GCS 경로 | +| `audio_gcs_uri` | VARCHAR(500) NULL | 오디오 GCS URI | +| `audio_file_size` | BIGINT UNSIGNED NULL | 오디오 파일 크기 (bytes) | +| `full_transcript` | LONGTEXT NULL | 전체 트랜스크립트 | +| `summary` | LONGTEXT NULL | AI 요약 | +| `decisions` | JSON NULL | 결정사항 배열 | +| `action_items` | JSON NULL | 액션아이템 배열 | +| `status` | VARCHAR(20) | 상태 (5가지) | +| `stt_language` | VARCHAR(10) | STT 언어 (기본: ko-KR) | +| `deleted_at` | TIMESTAMP NULL | 소프트 삭제 | + +**인덱스**: `tenant_id`, `user_id`, `(tenant_id, meeting_date)`, `status` + +### 3.2 meeting_minute_segments (세그먼트) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | BIGINT PK | | +| `meeting_minute_id` | BIGINT FK | 회의록 (cascade delete) | +| `segment_order` | INT UNSIGNED | 순서 | +| `speaker_name` | VARCHAR(100) | 화자 이름 (기본: "화자 1") | +| `speaker_label` | VARCHAR(20) NULL | 화자 라벨/번호 | +| `text` | TEXT | 발화 텍스트 | +| `start_time_ms` | INT UNSIGNED | 시작 시간 (ms, 기본: 0) | +| `end_time_ms` | INT UNSIGNED NULL | 종료 시간 (ms) | +| `is_manual_speaker` | BOOLEAN | 수동 화자 전환 여부 (기본: true) | + +**인덱스**: `meeting_minute_id`, `(meeting_minute_id, segment_order)` + +### 3.3 테이블 관계 + +``` +meeting_minutes + │ 1:N + ▼ +meeting_minute_segments (segment_order ASC) + ├── speaker_name (화자명) + ├── text (발화 내용) + └── start_time_ms / end_time_ms (타임스탬프) +``` + +--- + +## 4. 상태 관리 + +### 4.1 상태값 + +| 상태 | 코드 | 색상 | 설명 | +|------|------|------|------| +| 초안 | `DRAFT` | 회색 | 생성 직후, 편집 가능 | +| 녹음중 | `RECORDING` | 빨강 | (클라이언트 상태) | +| 처리중 | `PROCESSING` | 노랑 | AI 요약/화자분리 처리 중 | +| 완료 | `COMPLETED` | 초록 | AI 처리 완료 | +| 실패 | `FAILED` | 빨강 | AI 처리 실패 | + +### 4.2 상태 전이 + +``` +DRAFT + ↓ [오디오 업로드, 세그먼트 추가] +DRAFT (계속 편집) + ↓ [summarize() 호출] +PROCESSING + ↓ +COMPLETED (성공) 또는 FAILED (실패) + +DRAFT + ↓ [diarize() 호출 → 화자 분리] +DRAFT (세그먼트 갱신, 상태 유지) +``` + +--- + +## 5. API 명세 + +### 5.1 목록 조회 + +``` +GET /juil/meeting-minutes/list +``` + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `search` | string | 제목 검색 | +| `date_from` | date | 시작일 | +| `date_to` | date | 종료일 | +| `status` | string | 상태 필터 | +| `per_page` | int | 페이지당 건수 | + +### 5.2 생성 + +``` +POST /juil/meeting-minutes +``` + +| 필드 | 규칙 | 설명 | +|------|------|------| +| `title` | nullable, max:300 | 제목 (미입력 시 "무제 회의록") | +| `folder` | nullable, max:100 | 폴더 분류 | +| `participants` | nullable, array | 참여자 목록 | +| `meeting_date` | required, date | 회의 날짜 | +| `meeting_time` | nullable | 회의 시간 | +| `stt_language` | nullable, max:10 | STT 언어 (기본: ko-KR) | + +### 5.3 세그먼트 저장 + +``` +POST /juil/meeting-minutes/{id}/segments +``` + +```json +{ + "segments": [ + { + "speaker_name": "김과장", + "speaker_label": "1", + "text": "블라인드 납기일 확인 필요합니다.", + "start_time_ms": 0, + "end_time_ms": 5000, + "is_manual_speaker": true + } + ] +} +``` + +> **전처리**: 빈 텍스트 필터링, 언더스코어 노이즈 제거, 다중 공백 정규화 +> **자동 생성**: `full_transcript` = `[화자명] 발화텍스트\n...` 형식 + +### 5.4 오디오 업로드 + +``` +POST /juil/meeting-minutes/{id}/upload-audio +``` + +| 필드 | 규칙 | 설명 | +|------|------|------| +| `audio` | required, file | webm/mp3 등 | +| `duration_seconds` | required, integer, min:1 | 녹음 시간(초) | + +### 5.5 AI 요약 생성 + +``` +POST /juil/meeting-minutes/{id}/summarize +``` + +**요청**: 없음 (서버에서 `full_transcript` 사용) + +**응답 예시:** + +```json +{ + "success": true, + "message": "AI 요약이 완료되었습니다.", + "data": { + "summary": "블라인드 납품 일정과 현장 설치 계획을 논의했습니다...", + "decisions": [ + "납품일을 3월 15일로 확정", + "현장 실측은 3월 10일 진행" + ], + "action_items": [ + { + "assignee": "김과장", + "task": "거래처에 납기 확인 연락", + "deadline": "2026-03-08" + } + ], + "status": "COMPLETED" + } +} +``` + +### 5.6 자동 화자 분리 + +``` +POST /juil/meeting-minutes/{id}/diarize +``` + +| 필드 | 설명 | 기본값 | +|------|------|--------| +| `min_speakers` | 최소 화자 수 | 2 | +| `max_speakers` | 최대 화자 수 | 6 | + +**응답:** + +```json +{ + "success": true, + "message": "자동 화자 분리가 완료되었습니다. (3명 감지)", + "data": { /* Meeting with segments */ }, + "speaker_count": 3 +} +``` + +--- + +## 6. AI 통합 상세 + +### 6.1 화자 분리 (Diarization) 3단계 폴백 + +``` +[1단계] Google STT V2 (Chirp2) ← 최우선 + │ speechToTextWithDiarizationAuto() + │ 최신 모델, 높은 정확도 + │ 도메인 용어 힌트 포함 + │ + ↓ (실패 시) +[2단계] Google STT V1 (latest_long) ← 폴백 + │ 안정적이지만 약간 덜 정확 + │ + ↓ (1명만 인식 시) +[3단계] Gemini AI 화자 재분배 + splitSpeakersWithGemini() + 대화 맥락/호칭/질답 패턴/어투 변화 분석 + 2명 이상으로 재분배 +``` + +### 6.2 요약 생성 (Gemini API) + +``` +입력: full_transcript (전체 트랜스크립트) + ↓ +Gemini API 호출 + ├── 모드 1: Vertex AI (projectId, region, JWT) + └── 모드 2: Google AI Studio (API key) ← 폴백 + │ + │ Temperature: 0.3 (결정적) + │ Max tokens: 4096 + ↓ +출력 JSON: +{ + "summary": "3-5문장 요약", + "decisions": ["결정사항 1", "..."], + "action_items": [ + { "assignee": "담당자", "task": "할일", "deadline": "기한" } + ], + "keywords": ["키워드1", "..."] +} +``` + +### 6.3 Gemini 화자 재분배 + +Google STT가 1명만 인식할 때 Gemini로 대화 맥락 분석: + +``` +입력: 단일 화자 트랜스크립트 + 예상 화자 수 + ↓ +Gemini 프롬프트: + - 대화 맥락 분석 (호칭, 질답, 어투 변화) + - 지정된 수의 화자로 분리 + ↓ +출력: 화자별 세그먼트 배열 + → DB 세그먼트 교체 +``` + +--- + +## 7. 오디오 관리 (GCS) + +### 7.1 GCS 경로 패턴 + +``` +meeting-minutes/{tenant_id}/{meeting_id}/{timestamp}.webm +``` + +### 7.2 녹음 흐름 + +``` +브라우저 MediaRecorder API + ├── navigator.mediaDevices.getUserMedia({ audio: true }) + ├── new MediaRecorder(stream) + ├── recorder.ondataavailable → webm 블롭 수집 + └── 녹음 종료 → FormData로 업로드 + ↓ +POST /{id}/upload-audio + ├── GCS 업로드 + ├── DB: audio_file_path, audio_gcs_uri, audio_file_size, duration_seconds + └── AiTokenHelper::saveGcsStorageUsage() +``` + +### 7.3 다운로드 + +``` +GET /{id}/download-audio + → GCS에서 파일 콘텐츠 다운로드 + → Content-Disposition: attachment; filename="{title}.webm" +``` + +--- + +## 8. 세그먼트 처리 로직 + +### 8.1 저장 시 전처리 + +```php +// 1. 빈 텍스트 필터링 +trim($segment['text']) !== '' + +// 2. 언더스코어 노이즈 제거 +str_replace('_', '', $text) + +// 3. 다중 공백 정규화 +preg_replace('/\s{2,}/', ' ', $text) +``` + +### 8.2 전체 트랜스크립트 자동 생성 + +``` +[김과장] 블라인드 납기일 확인 필요합니다. +[박부장] 3월 15일로 확정합시다. +[김과장] 네, 거래처에 연락하겠습니다. +``` + +### 8.3 화자 분리 결과 세그먼트 변환 + +``` +Google STT 결과 → MeetingMinuteSegment 변환: +{ + segment_order: 순서, + speaker_name: "화자 N", + speaker_label: "N", + text: 발화 텍스트, + start_time_ms: 시작시간, + end_time_ms: 종료시간, + is_manual_speaker: false // 자동 분리 +} +``` + +--- + +## 9. UI 구성 (React) + +### 9.1 화자 색상 + +| 화자 | 배경색 | 뱃지색 | +|------|--------|--------| +| 화자 1 | `bg-blue-50` | `bg-blue-100 text-blue-800` | +| 화자 2 | `bg-green-50` | `bg-green-100 text-green-800` | +| 화자 3 | `bg-purple-50` | `bg-purple-100 text-purple-800` | +| 화자 4 | `bg-orange-50` | `bg-orange-100 text-orange-800` | + +### 9.2 지원 언어 + +| 코드 | 라벨 | +|------|------| +| `ko-KR` | 한국어 | +| `en-US` | English | +| `ja-JP` | 日本語 | +| `zh-CN` | 中文 | + +--- + +## 10. 사용량 추적 + +| 추적 항목 | 레이블 | Helper | +|----------|--------|--------| +| Web Speech API 사용 | `회의록-음성인식` | `AiTokenHelper::saveSttUsage()` | +| Google STT V1 화자 분리 | `회의록-화자분리` | `AiTokenHelper::saveSttUsage()` | +| Google STT V2 화자 분리 | `회의록-화자분리(Chirp2)` | `AiTokenHelper::saveSttUsage()` | +| GCS 오디오 저장 | `회의록-GCS저장` | `AiTokenHelper::saveGcsStorageUsage()` | +| Gemini 요약/분리 | `회의록-AI요약` | `AiTokenHelper::saveGeminiUsage()` | + +--- + +## 11. 모델 메서드 + +### 11.1 MeetingMinute + +```php +user() # BelongsTo User +segments() # HasMany Segment (segment_order ASC) +getFormattedDurationAttribute() # "H:MM:SS" 또는 "MM:SS" +``` + +**Cast**: `participants`, `decisions`, `action_items` → array, `meeting_date` → date + +### 11.2 MeetingMinuteService + +```php +# CRUD +getList(array $filters) # 검색/필터 목록 +create(array $data) # 생성 (DRAFT) +update(MeetingMinute, array $data) # 수정 +delete(MeetingMinute) # GCS 삭제 → soft delete + +# 세그먼트 +saveSegments(MeetingMinute, array $segments) # 전처리 + 저장 + 트랜스크립트 생성 +uploadAudio(MeetingMinute, UploadedFile, int $seconds) # GCS 업로드 +logSttUsage(int $seconds) # STT 사용량 기록 + +# AI +generateSummary(MeetingMinute) # Gemini 요약 생성 +processDiarization(MeetingMinute, int $min, int $max) # 3단계 화자 분리 +splitSpeakersWithGemini(string $text, int $expected) # Gemini 화자 재분배 +``` + +--- + +## 관련 문서 + +- [README.md](README.md) — 기획 메뉴 전체 개요 +- [공사현장 사진대지](construction-photos.md) — GCS 파일 관리, 음성 입력 +- [견적/프로젝트/워크플로우](planning-views.md) — 화면 명세 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/planning/planning-views.md b/features/planning/planning-views.md new file mode 100644 index 0000000..4b087ac --- /dev/null +++ b/features/planning/planning-views.md @@ -0,0 +1,222 @@ +# 견적/프로젝트/워크플로우 화면 명세 + +> **작성일**: 2026-03-06 +> **상태**: 뷰 구현 완료 (목데이터 기반, API 미연동) +> **라우트**: `/juil/estimate`, `/juil/project`, `/juil/workflow` +> **관련**: [README.md](README.md) | [사진대지](construction-photos.md) | [회의록](meeting-minutes.md) + +--- + +## 1. 개요 + +3개 화면 모두 **React 인라인 컴포넌트**(Babel 브라우저 트랜스파일)로 구현. 현재는 정적/목데이터 기반이며, 향후 API 연동 예정. PlanningController에서 뷰만 반환한다. + +--- + +## 2. 견적/입찰/공사관리 (/juil/estimate) + +### 2.1 개요 + +블라인드/스크린 설치 프로젝트의 견적서 작성, 입찰 관리, 공사 진행 현황을 한 화면에서 관리. + +### 2.2 데이터 구조 (initialEstimates) + +```javascript +{ + id: "string", + name: "프로젝트명", + client: "고객사명", + status: "견적중|입찰|계약|공사중|준공", + amount: number, // 금액 + startDate: "YYYY-MM-DD", + endDate: "YYYY-MM-DD", + manager: "담당자명", + items: [ // 품목 내역 + { name: "품목명", quantity: number, unitPrice: number } + ] +} +``` + +### 2.3 공사관리 정보 (initialConstructionData) + +```javascript +{ + id: "string", + estimateId: "string", // 연결 견적 + siteName: "현장명", + address: "현장 주소", + progress: number, // 진행률 (0~100) + workers: number, // 투입 인원 + safetyChecks: [ // 안전점검 + { date: "YYYY-MM-DD", result: "합격|불합격", inspector: "점검자" } + ] +} +``` + +### 2.4 상태별 배지 색상 + +| 상태 | 색상 | +|------|------| +| 견적중 | 파랑 | +| 입찰 | 보라 | +| 계약 | 초록 | +| 공사중 | 주황 | +| 준공 | 회색 | + +### 2.5 SAM 연계 + +- 견적서 작성 시 SAM 견적 시스템(`features/quotes/`) 데이터 활용 가능 +- 향후 `/juil/estimate` ↔ SAM 견적 API 연동 계획 + +--- + +## 3. 프로젝트관리/기성청구 (/juil/project) + +### 3.1 개요 + +계약된 프로젝트의 현장 관리, 발주/청구/인건비 상태 추적, 기성 청구 관리. + +### 3.2 데이터 구조 (initialProjects) + +```javascript +{ + id: "string", + name: "프로젝트명", + client: "발주처", + contractAmount: number, // 계약금액 + status: "진행중|완료|보류", + sites: [ // 현장 목록 + { + name: "현장명", + address: "주소", + progress: number // 진행률 + } + ], + orders: [ // 발주 내역 + { + vendor: "거래처", + amount: number, + status: "발주|납품|정산" + } + ], + claims: [ // 기성 청구 + { + round: number, // 차수 + amount: number, // 청구금액 + claimDate: "YYYY-MM-DD", + status: "청구|승인|입금" + } + ], + laborCosts: [ // 인건비 + { + month: "YYYY-MM", + amount: number, + workers: number + } + ] +} +``` + +### 3.3 금액 포맷 함수 + +```javascript +fmt(amount) // 1,234,567 (쉼표 포맷) +fmtBillion(amount) // 12.3억 (억 단위 축약) +``` + +--- + +## 4. 업무 Workflow (/juil/workflow) + +### 4.1 개요 + +블라인드/스크린 사업의 전체 업무 프로세스를 단계별로 시각화. 각 프로세스에 담당 부서, 산출물, 서브스텝을 정의. + +### 4.2 프로세스 데이터 구조 + +```javascript +{ + id: "S1-1", // 프로세스 ID + phase: "영업", // Phase 명 + name: "정보 수집", // 프로세스 이름 + icon: "icon-name", // 아이콘 + dept: "영업팀", // 담당 부서 + color: "#3B82F6", // 테마 색상 + description: "프로세스 설명", + documents: [ // 산출물 목록 + "현장조사서", "고객요구사항서" + ], + subSteps: [ // 상세 서브스텝 + { + name: "서브스텝명", + description: "상세 설명", + responsible: "담당자/팀", + output: "산출물" + } + ] +} +``` + +### 4.3 업무 Phase 목록 + +| Phase | ID 범위 | 설명 | +|-------|---------|------| +| **영업** | S1-1 ~ S1-4 | 정보 수집 → 현장 실측 → 고객 미팅 → 프로젝트 검토 | +| **견적서 작성** | S2-1 ~ S2-4 | 물량 산출 → 단가 산정 → 견적가 산출 → 견적서 작성/검토 | +| **입찰** | S3-* | 입찰 준비 → 제출 → 결과 확인 | +| **계약** | S4-* | 계약 협상 → 계약 체결 | +| **공사** | S5-* | 자재 발주 → 시공 → 현장 관리 | +| **준공** | S6-* | 검수 → 하자보수 → 준공 정산 | + +### 4.4 SAM 연계 포인트 + +```javascript +// 견적서 작성 Phase에서 SAM 견적 화면으로 연결 +{ samLink: '/juil/estimate', label: '견적서 작성 바로가기' } +``` + +--- + +## 5. 공통 특징 + +### 5.1 HTMX 전체 페이지 로드 + +3개 화면 모두 React 컴포넌트 사용하므로 HTMX 부분 로드 불가: + +```php +// PlanningController의 모든 메서드 +if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.xxx')); +} +return view('juil.xxx'); +``` + +### 5.2 현재 구현 상태 + +| 항목 | 상태 | +|------|------| +| UI 화면 | 구현 완료 (React 인라인) | +| 목데이터 | 블레이드에 하드코딩 | +| API 연동 | 미연동 (향후 계획) | +| DB 테이블 | 미생성 (향후 계획) | +| CRUD 기능 | 뷰 조회만 (생성/수정/삭제 미구현) | + +### 5.3 향후 개발 방향 + +1. 견적/프로젝트 DB 테이블 설계 (API 프로젝트) +2. API 엔드포인트 구현 +3. React 컴포넌트 API 연동 +4. SAM 견적 시스템과 데이터 동기화 + +--- + +## 관련 문서 + +- [README.md](README.md) — 기획 메뉴 전체 개요 +- [공사현장 사진대지](construction-photos.md) — GCS 파일 관리, 음성 입력 +- [회의록 작성](meeting-minutes.md) — STT/AI 통합 회의 기록 +- [견적 시스템](../quotes/README.md) — SAM 견적 관리 (BOM, 10단계 로직) + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/features/rd/README.md b/features/rd/README.md new file mode 100644 index 0000000..1483160 --- /dev/null +++ b/features/rd/README.md @@ -0,0 +1,110 @@ +# R&D 메뉴 + +> **작성일**: 2026-03-08 +> **상태**: 운영 중 +> **프로젝트**: SAM MNG (관리자 웹) +> **라우트 접두사**: `/rd` + +--- + +## 1. 개요 + +### 1.1 목적 + +R&D 메뉴는 SAM 플랫폼의 **연구개발 및 내부 도구** 모음이다. AI 견적, 조직도 관리, 기획디자인(스토리보드 에디터), 안전점검 등 실험적이거나 내부 운영 목적의 기능을 제공한다. + +### 1.2 문서 구조 + +| 문서 | 설명 | +|------|------| +| **README.md** (이 문서) | 전체 개요, 메뉴 구조, 컨트롤러 매핑 | +| [planning-design.md](planning-design.md) | 기획디자인 스토리보드 에디터 기술 명세 | +| [design-insight.md](design-insight.md) | 디자인 인사이트 UI/UX 연구 도구 (100종 패턴, AI 프롬프트) | + +### 1.3 하위 메뉴 구조 + +``` +R&D +├── 대시보드 /rd +├── 조직도 관리 /rd/org-chart +├── 중대재해처벌법 점검 /rd/safety-audit +├── AI 견적 /rd/ai-quotation +│ ├── 목록 /rd/ai-quotation +│ ├── 생성 /rd/ai-quotation/create +│ ├── 상세 /rd/ai-quotation/{id} +│ ├── 편집 /rd/ai-quotation/{id}/edit +│ └── 문서 /rd/ai-quotation/{id}/document +├── 기획디자인 /rd/planning-design +└── 디자인 인사이트 /rd/design-insight +``` + +--- + +## 2. 아키텍처 + +### 2.1 기술 스택 + +| 계층 | 기술 | 설명 | +|------|------|------| +| 뷰 | Blade + Alpine.js | 반응형 SPA (서버 렌더링 없음) | +| 컨트롤러 | `RdController` | 모든 R&D 라우트 처리 | +| 서비스 | `AiQuotationService` | AI 견적 비즈니스 로직 | +| 모델 | `AiQuotation`, `Employee`, `Department`, `Tenant` | Multi-tenant | +| 저장 | localStorage (기획디자인), DB (견적/조직도) | 용도별 분리 | + +### 2.2 컨트롤러 구조 + +**파일**: `app/Http/Controllers/RdController.php` + +| 메서드 | 라우트 | 설명 | +|--------|--------|------| +| `index()` | `GET /rd` | R&D 대시보드 | +| `orgChart()` | `GET /rd/org-chart` | 조직도 관리 | +| `orgChartAssign()` | `POST /rd/org-chart/assign` | 직원 부서 배치 | +| `orgChartUnassign()` | `POST /rd/org-chart/unassign` | 직원 부서 해제 | +| `orgChartReorder()` | `POST /rd/org-chart/reorder` | 직원 순서/이동 | +| `orgChartReorderDepts()` | `POST /rd/org-chart/reorder-depts` | 부서 순서 변경 | +| `orgChartToggleHide()` | `POST /rd/org-chart/toggle-hide` | 부서 숨기기/표시 | +| `safetyAudit()` | `GET /rd/safety-audit` | 중대재해처벌법 점검 | +| `quotations()` | `GET /rd/ai-quotation` | AI 견적 목록 | +| `createQuotation()` | `GET /rd/ai-quotation/create` | AI 견적 생성 폼 | +| `showQuotation()` | `GET /rd/ai-quotation/{id}` | AI 견적 상세 | +| `editQuotation()` | `GET /rd/ai-quotation/{id}/edit` | AI 견적 편집 | +| `documentQuotation()` | `GET /rd/ai-quotation/{id}/document` | AI 견적 문서 | +| `planningDesign()` | `GET /rd/planning-design` | 기획디자인 | +| `designInsight()` | `GET /rd/design-insight` | 디자인 인사이트 | + +### 2.3 HTMX 전체 페이지 로드 규칙 + +모든 `/rd/*` 페이지는 Alpine.js 또는 React 컴포넌트를 사용하므로, HTMX 부분 로드 시 스크립트가 실행되지 않는다. 각 메서드에서 `HX-Request` 감지 시 `HX-Redirect`로 전체 페이지 로드를 강제한다. + +```php +if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('rd.planning-design')); +} +``` + +--- + +## 3. 기능별 구현 현황 + +| 기능 | 구현 방식 | 백엔드 | DB | 상태 | +|------|----------|--------|-----|------| +| R&D 대시보드 | Blade | AiQuotationService | ai_quotations | 운영 중 | +| 조직도 관리 | Blade + Alpine.js | RdController (직접 쿼리) | employees, departments | 운영 중 | +| 중대재해처벌법 점검 | Blade (정적) | 없음 | 없음 | 운영 중 | +| AI 견적 | Blade + Alpine.js | AiQuotationService | ai_quotations | 운영 중 | +| **기획디자인** | **Blade + Alpine.js (SPA)** | **없음 (localStorage)** | **없음** | **운영 중** | +| **디자인 인사이트** | **Blade + Alpine.js (SPA)** | **없음 (localStorage)** | **없음** | **운영 중** | + +--- + +## 4. 관련 문서 + +- [기획디자인 스토리보드 에디터](planning-design.md) — 블록 에디터, 서식, 인쇄, 내보내기 +- [디자인 인사이트](design-insight.md) — UI/UX 연구 도구 (100종 패턴, AI 프롬프트 복사) +- [조직도 관리 기술문서](../../projects/org-chart/) — 조직도 시스템 상세 (별도 프로젝트 문서) + +--- + +**최종 업데이트**: 2026-03-08 diff --git a/features/rd/design-insight.md b/features/rd/design-insight.md new file mode 100644 index 0000000..683d02f --- /dev/null +++ b/features/rd/design-insight.md @@ -0,0 +1,246 @@ +# 디자인 인사이트 — UI/UX 연구 도구 + +> **작성일**: 2026-03-08 +> **상태**: 운영 중 +> **라우트**: `GET /rd/design-insight` +> **뷰**: `resources/views/rd/design-insight/index.blade.php` + +--- + +## 1. 개요 + +### 1.1 목적 + +디자인 인사이트는 UI/UX 패턴을 **수집·분석·비교**하는 연구 도구이다. 외부 서비스의 UI 레퍼런스를 카드 형태로 정리하고, CSS 와이어프레임 미리보기와 AI 프롬프트 생성 기능으로 디자인 의사결정을 지원한다. + +### 1.2 핵심 기능 + +| 기능 | 설명 | +|------|------| +| 카드 관리 | 레퍼런스/분석/패턴/Before-After 4종 카드 CRUD | +| 프로젝트 관리 | 다중 프로젝트, localStorage 저장 | +| CSS 와이어프레임 | 100종 UI 패턴의 순수 CSS 미니 와이어프레임 | +| 프리셋 템플릿 | 인기 UI 패턴 100종 원클릭 불러오기 | +| AI 프롬프트 복사 | 카드 정보를 AI용 구조화 프롬프트로 변환·복사 | +| 3종 뷰 | 보드(카테고리별)/갤러리(그리드)/리스트 뷰 | +| JSON 내보내기/가져오기 | 프로젝트 데이터 백업/복원 | + +--- + +## 2. 아키텍처 + +### 2.1 기술 스택 + +| 계층 | 기술 | 설명 | +|------|------|------| +| 뷰 | Blade + Alpine.js | 단일 파일 SPA | +| 컨트롤러 | `RdController::designInsight()` | HX-Redirect 패턴 | +| 저장 | localStorage | `di_projects`, `di_current` 키 사용 | +| 백엔드 | 없음 | 서버 API 호출 없이 클라이언트 단독 동작 | +| 스타일 | 커스텀 CSS 변수 | `--di-*` 접두사 (Tailwind 미사용) | + +### 2.2 데이터 구조 + +```json +{ + "id": "di_1709000000_abc123", + "title": "프로젝트명", + "cards": [ + { + "id": "di_1709000001_def456", + "type": "pattern", + "title": "KPI 대시보드", + "category": "dashboard", + "rating": 5, + "tags": ["대시보드", "KPI", "통계"], + "memo": "레퍼런스 설명", + "guidelines": "디자인 가이드라인", + "usedIn": ["Stripe", "Shopify"], + "components": [ + { "name": "KPI 요약 카드", "required": true }, + { "name": "필터 영역", "required": false } + ], + "image": null, + "createdAt": "2026-03-08T00:00:00.000Z" + } + ], + "createdAt": "2026-03-08T00:00:00.000Z", + "updatedAt": "2026-03-08T00:00:00.000Z" +} +``` + +--- + +## 3. 카드 유형 (4종) + +| 코드 | 라벨 | 용도 | +|------|------|------| +| `reference` | 레퍼런스 | 외부 서비스 UI 스크린샷 수집 | +| `analysis` | 분석 | CRAP 원칙 등 UX 분석 (8가지 디자인 원칙 평가) | +| `pattern` | 패턴 | 재사용 가능한 UI 패턴 정의 | +| `comparison` | Before/After | 개선 전후 비교 (이미지 2장) | + +--- + +## 4. 카테고리 (8종) + +| 코드 | 라벨 | 아이콘 | +|------|------|--------| +| `dashboard` | 대시보드 | 📊 | +| `list` | 목록 | 📋 | +| `form` | 상세/폼 | 📝 | +| `modal` | 모달/팝업 | 💬 | +| `navigation` | 네비게이션 | 🧭 | +| `auth` | 로그인 | 🔐 | +| `report` | 보고서 | 📄 | +| `etc` | 기타 | 📎 | + +--- + +## 5. CSS 와이어프레임 시스템 + +### 5.1 동작 원리 + +`getWireframe(card)` 함수가 카드의 `title`과 `tags`를 키워드 매칭하여 해당 패턴에 맞는 순수 CSS/HTML 미니 와이어프레임을 반환한다. + +```javascript +getWireframe(card) { + const t = (card.title || '').toLowerCase(); + const tags = (card.tags || []).join(' ').toLowerCase(); + const key = t + ' ' + tags; + + if (key.includes('kpi') || key.includes('대시보드') && key.includes('통계')) return `...`; + // ... 100종 패턴 매칭 + return `기본 와이어프레임 (매칭 안 됨)`; +} +``` + +### 5.2 프리셋 100종 분포 + +| 카테고리 | 패턴 수 | 대표 패턴 | +|---------|---------|----------| +| 대시보드 | 10 | KPI, 실시간 모니터링, 게이지/미터, 히트맵, 퍼널 | +| 목록 | 10 | 데이터 테이블, 칸반 보드, 마스터-디테일, 피벗 테이블 | +| 상세/폼 | 16 | 프로필, 설정, 위지윅 에디터, 멀티스텝 폼, 태그 입력 | +| 모달/팝업 | 10 | 확인 다이얼로그, 라이트박스, 바텀시트, 컨텍스트 메뉴 | +| 네비게이션 | 10 | 사이드바, 탭, 브레드크럼, FAB, 앵커 스크롤 | +| 로그인 | 8 | 로그인 폼, 회원가입, 비밀번호 재설정, RBAC, API 키 | +| 보고서 | 9 | 인쇄용 보고서, 간트 차트, 조직도, 워터폴, 리포트 빌더 | +| 기타 | 27 | 댓글, 에러 페이지, FAQ, 캐러셀, 파일 매니저 등 | + +--- + +## 6. AI 프롬프트 복사 기능 + +### 6.1 목적 + +카드에 정리된 UI 패턴 정보를 **AI가 이해할 수 있는 구조화된 마크다운 프롬프트**로 변환하여 클립보드에 복사한다. 복사한 프롬프트를 AI(Claude, ChatGPT 등)에 붙여넣으면 해당 스타일로 코드를 생성할 수 있다. + +### 6.2 UI 위치 + +카드 상세 모달 상단, **편집** 버튼 왼쪽에 보라색 `✨ AI 프롬프트` 버튼으로 배치. + +### 6.3 프롬프트 구조 + +`copyAiPrompt(card)` 함수가 카드 데이터를 다음 구조로 변환한다: + +```markdown +## UI 패턴 구현 요청 + +아래 UI/UX 패턴 레퍼런스를 참고하여, 동일한 스타일과 구조로 코드를 작성해 주세요. + +--- + +### 패턴 정보 +- **패턴명**: {title} +- **카테고리**: {category label} +- **완성도 평점**: ★★★☆☆ ({rating}/5) +- **키워드**: {tags} + +### 레퍼런스 설명 +{memo} + +### 실제 사용처 (벤치마킹 대상) +- {usedIn[0]} +- {usedIn[1]} + +### 필수 구성 요소 + +**필수 (반드시 포함)**: +- ✅ {required component} + +**선택 (권장)**: +- ○ {optional component} + +### 디자인 가이드라인 +{guidelines} + +### 개선 제안 +{suggestion} + +### 기대 효과 +{effect} + +--- + +### 구현 요구사항 + +1. **기술 스택**: HTML + Tailwind CSS (또는 프로젝트에 맞는 프레임워크) +2. **반응형**: 모바일/태블릿/데스크톱 대응 +3. **접근성**: 시맨틱 태그, ARIA 라벨, 키보드 네비게이션 +4. **인터랙션**: hover, focus, active 상태 포함 +5. **위 구성 요소를 모두 포함**하되, 실제 서비스처럼 자연스러운 더미 데이터 사용 +6. **위 가이드라인을 충실히 반영**하여 UX 완성도를 높일 것 +``` + +### 6.4 포함 필드 매핑 + +| 카드 필드 | 프롬프트 섹션 | 조건 | +|----------|-------------|------| +| `title` | 패턴 정보 > 패턴명 | 항상 | +| `category` | 패턴 정보 > 카테고리 | 항상 (라벨로 변환) | +| `rating` | 패턴 정보 > 평점 | 항상 (별점으로 변환) | +| `tags` | 패턴 정보 > 키워드 | 태그가 있을 때만 | +| `memo` | 레퍼런스 설명 | 값이 있을 때만 | +| `usedIn` | 실제 사용처 | 배열이 비어있지 않을 때만 | +| `components` | 필수 구성 요소 | 배열이 비어있지 않을 때만 | +| `guidelines` | 디자인 가이드라인 | 값이 있을 때만 | +| `suggestion` | 개선 제안 | 값이 있을 때만 | +| `effect` | 기대 효과 | 값이 있을 때만 | +| `principles` | UX 원칙 | `type === 'analysis'`일 때만 | + +--- + +## 7. 뷰 모드 (3종) + +| 모드 | 설명 | 정렬 | +|------|------|------| +| `board` | 카테고리별 컬럼 그룹핑 | 카테고리 → 생성순 | +| `gallery` | 그리드 갤러리 (와이어프레임 강조) | 필터 순 | +| `list` | 테이블형 리스트 | 필터 순 | + +--- + +## 8. 파일 구조 + +``` +resources/views/rd/design-insight/ +└── index.blade.php # 단일 파일 SPA (~6,200줄) + ├── + + + +

제목

+

본문 내용

+ + +``` + +### 3.2 슬라이드 크기 (body width/height) + +| 용도 | body 크기 | 변환 스크립트 layout | +|------|----------|---------------------| +| **16:9 가로형** (발표용) | `width: 720pt; height: 405pt;` | `width: 10, height: 5.625` | +| **9:16 세로형** (브로셔) | `width: 405pt; height: 720pt;` | `width: 5.625, height: 10` | +| **4:3 가로형** (구형) | `width: 720pt; height: 540pt;` | `width: 10, height: 7.5` | + +> **중요**: HTML의 body 크기와 변환 스크립트의 layout 크기가 일치해야 한다. 불일치 시 에러 발생. + +### 3.3 필수 규칙 + +#### 텍스트 줄바꿈 방지 (가장 중요) + +브라우저와 PowerPoint의 폰트 렌더링 차이로, HTML에서 한 줄인 텍스트가 PPTX에서 두 줄로 넘어가는 문제가 발생한다. + +```html + +

이 텍스트는 한 줄입니다

+ + +

이 텍스트는 한 줄입니다

+ + +

+ 여러 줄로 의도된 텍스트입니다.
+ 이 경우 nowrap을 넣지 않는다. +

+``` + +#### 적용 대상 + +- 뱃지 텍스트 (01, UC-01 등) +- 카드 제목, 태그, 짧은 라벨 +- 푸터 텍스트 +- 단일행 `

` 태그 전부 + +#### 이미지 경로 + +```html + + + + + +``` + +#### 스타일 작성 + +```html + +

+ + +``` + +### 3.4 사용 가능한 CSS 속성 + +| 속성 | 지원 | 비고 | +|------|:----:|------| +| background (색상) | ✅ | 단색, rgba 모두 지원 | +| background (그라데이션) | ✅ | linear-gradient 지원 | +| border | ✅ | 색상, 두께, radius | +| border-radius | ✅ | px, pt 단위 | +| font-size, font-weight | ✅ | pt 단위 권장 | +| color | ✅ | hex, rgba | +| padding, margin | ✅ | pt 단위 권장 | +| display: flex | ✅ | gap, align-items 등 | +| white-space: nowrap | ✅ | 줄바꿈 방지 (필수) | +| opacity | ✅ | | +| box-shadow | ⚠️ | 부분 지원 | +| transform | ❌ | 미지원 | +| animation | ❌ | 미지원 | + +--- + +## 4. 변환 스크립트 작성법 + +### 4.1 기본 구조 (복사해서 사용) + +```javascript +const path = require('path'); + +// ① 패키지 경로 설정 (이 두 줄은 항상 동일) +module.paths.unshift( + path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/node_modules') +); +const PptxGenJS = require('pptxgenjs'); +const html2pptx = require( + path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/html2pptx.js') +); + +async function main() { + const pres = new PptxGenJS(); + + // ② 레이아웃 설정 (HTML body 크기와 일치해야 함) + pres.defineLayout({ name: 'CUSTOM', width: 10, height: 5.625 }); + pres.layout = 'CUSTOM'; + + // ③ HTML 파일 변환 + await html2pptx('/절대경로/slides/slide-01.html', pres); + + // ④ PPTX 출력 + await pres.writeFile({ fileName: '/절대경로/output.pptx' }); + console.log('완료!'); +} + +main().catch(console.error); +``` + +### 4.2 실전 예시: 여러 슬라이드 변환 + +```javascript +const path = require('path'); +module.paths.unshift( + path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/node_modules') +); +const PptxGenJS = require('pptxgenjs'); +const html2pptx = require( + path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/html2pptx.js') +); + +async function main() { + const pres = new PptxGenJS(); + + // 16:9 가로형 + pres.defineLayout({ name: 'CUSTOM_16x9', width: 10, height: 5.625 }); + pres.layout = 'CUSTOM_16x9'; + + const slidesDir = path.join(__dirname, 'slides'); + + // 변환할 HTML 파일 목록 + const slides = [ + 'slide-01.html', + 'slide-02.html', + 'slide-03.html', + ]; + + for (const file of slides) { + console.log(`Converting ${file} ...`); + try { + await html2pptx(path.join(slidesDir, file), pres); + } catch (err) { + console.error(`Error: ${err.message}`); + } + } + + const outputPath = path.join(__dirname, 'my-presentation.pptx'); + await pres.writeFile({ fileName: outputPath }); + console.log(`\nPPTX created: ${outputPath}`); +} + +main().catch(console.error); +``` + +### 4.3 실전 예시: 번호 기반 자동 변환 (18슬라이드) + +```javascript +// slide-01.html ~ slide-18.html 자동 변환 +const totalSlides = 18; +for (let i = 1; i <= totalSlides; i++) { + const num = String(i).padStart(2, '0'); + const htmlFile = path.join(slidesDir, `slide-${num}.html`); + await html2pptx(htmlFile, pres); +} +``` + +--- + +## 5. 실행 방법 + +### 5.1 터미널에서 직접 실행 + +```bash +# 해당 폴더로 이동 후 실행 +cd /home/aweso/sam/docs/brochure +node convert-2page.cjs + +# 또는 절대 경로로 실행 +node /home/aweso/sam/docs/brochure/convert-2page.cjs +``` + +### 5.2 실행 결과 + +``` +Converting brochure-2page-front.html ... +Converting brochure-2page-back.html ... + +PPTX created: /home/aweso/sam/docs/brochure/sam-brochure-2page.pptx +``` + +> 에러가 나면 HTML body 크기와 layout 설정 불일치가 가장 흔한 원인이다. + +--- + +## 6. 프로젝트 내 기존 사용 사례 + +| 경로 | 슬라이드 수 | 레이아웃 | 용도 | +|------|:-----------:|:--------:|------| +| `docs/usecase/` | 18장 | 16:9 가로 | 방화셔터 제안서 | +| `docs/usecase/brochure/` | 1장 / 2장 | 9:16 세로 | 방화셔터 브로셔 | +| `docs/brochure/` | 1장 / 2장 | 9:16 세로 | SAM 범용 브로셔 | +| `docs/plans/slides/` | N장 | 16:9 가로 | 배포 계획 발표 | +| `docs/rules/slides/` | N장 | 16:9 가로 | 정책 규칙 문서 | + +--- + +## 7. 폴더 구조 권장 패턴 + +새 PPTX를 만들 때 아래 구조를 따른다: + +``` +docs/my-document/ +├── slides/ ← HTML 슬라이드 파일들 +│ ├── slide-01.html +│ ├── slide-02.html +│ └── slide-03.html +├── convert.cjs ← 변환 스크립트 +└── my-document.pptx ← 생성된 PPTX (node convert.cjs 실행 후) +``` + +--- + +## 8. 문제 해결 + +| 증상 | 원인 | 해결 | +|------|------|------| +| `Error: dimensions don't match` | HTML body 크기 ≠ layout 설정 | body의 width/height와 `defineLayout` 값 맞추기 | +| 텍스트가 PPTX에서 줄바꿈됨 | `white-space: nowrap` 미적용 | 단일행 `

` 태그에 nowrap 추가 | +| 이미지 안 보임 | 상대 경로 사용 | 절대 경로(`/home/aweso/...`)로 변경 | +| `Cannot find module 'pptxgenjs'` | module.paths 설정 누락 | 스크립트 상단 2줄(module.paths.unshift) 확인 | +| `Cannot find module 'playwright'` | Playwright 미설치 | `cd ~/.claude/skills/pptx-skill/scripts && npx playwright install chromium` | +| PPTX는 생성되지만 빈 슬라이드 | HTML 내용 없음 | HTML 파일을 브라우저로 열어 확인 | + +--- + +## 9. 빠른 시작 (3분 가이드) + +### Step 1: 폴더 만들기 + +```bash +mkdir -p /home/aweso/sam/docs/my-pptx/slides +``` + +### Step 2: HTML 슬라이드 만들기 + +`slides/slide-01.html` 파일 생성: + +```html + + + + + + + +

MY COMPANY

+

발표 제목을 여기에

+

부제목 또는 설명

+ + +``` + +### Step 3: 변환 스크립트 만들기 + +`convert.cjs` 파일 생성: + +```javascript +const path = require('path'); +module.paths.unshift(path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/node_modules')); +const PptxGenJS = require('pptxgenjs'); +const html2pptx = require(path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/html2pptx.js')); + +async function main() { + const pres = new PptxGenJS(); + pres.defineLayout({ name: 'CUSTOM_16x9', width: 10, height: 5.625 }); + pres.layout = 'CUSTOM_16x9'; + + await html2pptx(path.join(__dirname, 'slides', 'slide-01.html'), pres); + + await pres.writeFile({ fileName: path.join(__dirname, 'my-presentation.pptx') }); + console.log('완료!'); +} +main().catch(console.error); +``` + +### Step 4: 실행 + +```bash +cd /home/aweso/sam/docs/my-pptx +node convert.cjs +# → my-presentation.pptx 생성됨 +``` + +--- + +**최종 업데이트**: 2026-03-01 diff --git a/guides/server-how-it-works.md b/guides/server-how-it-works.md new file mode 100644 index 0000000..974693b --- /dev/null +++ b/guides/server-how-it-works.md @@ -0,0 +1,247 @@ +# SAM 서버 동작 원리 초보자 가이드 + +> **작성일**: 2026-02-22 +> **대상**: SAM 프로젝트에 새로 합류한 개발자 + +--- + +## 1. 개요 + +### 1.1 이 문서의 목적 + +SAM 시스템에서 **웹 요청이 어떤 경로로 흐르는지**, **git push 후 서버에서 무슨 일이 일어나는지**를 설명한다. +설정값 나열이 아닌, **"왜 이런 구조인가"**에 초점을 맞춘다. + +### 1.2 SAM 전체 구조 + +``` +브라우저 → Nginx (SSL 종료, 도메인별 라우팅) + │ + ┌────┬───┴───┬─────┬─────┐ + ▼ ▼ ▼ ▼ ▼ + MNG API React Sales 5130 ← 5개 서비스 + (PHP)(PHP) (Node) (PHP) (PHP7.3) + └────┴───┬───┴─────┴─────┘ + ▼ + MySQL 8.0 ← 단일 DB 공유 +``` + +--- + +## 2. 웹 요청의 여정: URL에서 화면까지 + +### 2.1 전체 흐름 + +`https://mng.sam.kr/orders` 접속 시: + +``` +브라우저 →① Nginx →② PHP-FPM →③ Laravel →④ MySQL + │ +브라우저 ←────────────────────────────── ⑤ 응답 +``` + +### 2.2 Step 1: 브라우저 → Nginx + +Nginx는 **도메인 이름**을 보고 어떤 서비스로 보낼지 결정한다. + +- `mng.sam.kr` → MNG 컨테이너의 PHP-FPM (포트 9000) +- `api.sam.kr` → API 컨테이너의 PHP-FPM (포트 9000) +- `dev.sam.kr` → React 컨테이너의 Node.js (포트 3000) + +또한 HTTP(80) 요청을 HTTPS(443)로 리다이렉트하고, SSL 인증서를 처리한다. +이를 **SSL 종료**(SSL Termination)라 한다. 내부 통신은 암호화 없이 빠르게 진행된다. + +### 2.3 Step 2: Nginx → PHP-FPM + +Nginx는 PHP 코드를 직접 실행하지 못한다. 대신 **FastCGI 프로토콜**로 PHP-FPM에 요청을 전달한다. + +``` +Nginx: "이 PHP 파일을 실행해줘" → fastcgi_pass mng:9000 +PHP-FPM: "결과 HTML이야" → Nginx → 브라우저 +``` + +PHP-FPM은 여러 **워커 프로세스**를 미리 만들어 두고, 요청이 오면 빈 워커에 할당한다. +MNG의 경우 최대 20개 워커(`pm.max_children = 20`)가 동시에 요청을 처리할 수 있다. + +### 2.4 Step 3: PHP-FPM → Laravel + +PHP-FPM이 실행하는 진입점은 `public/index.php`다. 여기서 Laravel 프레임워크가 시작된다. + +``` +public/index.php + → Bootstrap (설정 로드, 서비스 등록) + → 미들웨어 (인증, 권한, 로깅) + → 라우터 (URL → 컨트롤러 매핑) + → 컨트롤러 (비즈니스 로직) + → 뷰 렌더링 (Blade 템플릿 → HTML) +``` + +### 2.5 Step 4: Laravel → MySQL + +컨트롤러에서 Eloquent ORM으로 DB를 조회한다. 예를 들어: + +```php +// 코드: Order::where('status', 'active')->get(); +// 실제 SQL: SELECT * FROM orders WHERE status = 'active' AND tenant_id = 1; +``` + +`tenant_id`는 글로벌 스코프로 자동 추가되어, 다른 테넌트의 데이터가 섞이지 않는다. + +### 2.6 Step 5: 응답이 돌아오는 길 + +MySQL → Laravel(HTML 생성) → PHP-FPM → Nginx → 브라우저 순으로 돌아온다. +MNG는 HTMX를 사용하므로, 이후 상호작용은 **HTML 조각**(partial)만 주고받아 페이지 전체를 새로고침하지 않는다. + +--- + +## 3. 각 구성 요소의 역할 + +| 구성 요소 | 역할 | 비유 | +|-----------|------|------| +| **Nginx** | 리버스 프록시, SSL, 정적 파일 | 안내 데스크 | +| **PHP-FPM** | PHP 워커 풀 관리 | 창구 직원 팀 | +| **Laravel** | MVC, 라우팅, 비즈니스 로직 | 업무 매뉴얼 | +| **MySQL** | 데이터 저장/조회 | 서류 보관실 | +| **Supervisor** | 프로세스 감시, 자동 재시작 | 관리 감독관 | + +### 3.1 Supervisor가 관리하는 프로세스 + +각 컨테이너 안에서 Supervisor가 여러 프로세스를 관리한다. + +**API 컨테이너** (`sam-api-1`): +- `php-fpm` — PHP 요청 처리 +- `nginx` — 컨테이너 내부 웹서버 +- `queue-worker` — 백그라운드 작업 (이메일, 알림 등) +- `scheduler` — 60초마다 예약 작업 실행 (`schedule:run`) + +**MNG 컨테이너** (`sam-mng-1`): +- `php-fpm`, `nginx` — 위와 동일 +- `queue-worker` x2 — 2개 워커가 병렬 처리 + +--- + +## 4. 로컬 환경 vs 서버 환경 + +### 4.1 비교 + +``` +[로컬 - Docker] [서버 - Bare-metal] +┌───────────────┐ ┌───────────────┐ +│ sam-nginx-1 │ │ Nginx │ +├───────────────┤ ├───────────────┤ +│ sam-mng-1 │ │ MNG (직접) │ +│ sam-api-1 │ │ API (직접) │ +├───────────────┤ ├───────────────┤ +│ sam-mysql-1 │ │ MySQL (직접) │ +└───────────────┘ └───────────────┘ + 네트워크: samnet 네트워크: localhost +``` + +### 4.2 핵심 차이 + +| 항목 | 로컬 (Docker) | 서버 (Bare-metal) | +|------|--------------|-------------------| +| **DB 접속** | `DB_HOST=sam-mysql-1` | `DB_HOST=127.0.0.1` | +| **코드 반영** | 볼륨 마운트 (실시간) | `git pull` 필요 | +| **명령 실행** | `docker exec sam-api-1 php artisan ...` | `php artisan ...` | + +--- + +## 5. "git push하면 무슨 일이 일어나는가?" + +### 5.1 배포 흐름 다이어그램 + +``` +개발자 PC (WSL) Gitea 서버 운영 서버 +┌──────────┐ push ┌──────────┐ pull ┌──────────┐ +│ 코드 수정 │ ──────────→ │ 원격 │ ←───────── │ 서버에서 │ +│ git add │ │ 저장소 │ │ 수동 pull │ +│ git commit│ └──────────┘ └──────────┘ +└──────────┘ +``` + +> **주의**: 자동 배포(CI/CD)가 없다. 서버에서 **수동으로 `git pull`** 해야 반영된다. + +### 5.2 PHP 앱 배포 (MNG, API) + +```bash +# 서버에서 실행하는 명령 (개발팀장이 수행) +cd /home/webservice/api +git pull # ① 최신 코드 받기 +composer install # ② 패키지 의존성 동기화 +php artisan migrate # ③ DB 구조 변경 적용 +php artisan config:clear # ④ 설정 캐시 초기화 +``` + +**각 명령이 필요한 이유**: + +| 명령 | 왜 필요한가 | +|------|------------| +| `git pull` | 코드를 최신 상태로 동기화 | +| `composer install` | 새로 추가된 PHP 패키지 설치 (`composer.json` 변경 시) | +| `php artisan migrate` | 새 테이블/컬럼 생성 등 DB 스키마 적용 (API만) | +| `php artisan config:clear` | `.env` 또는 `config/` 변경 시 캐시된 설정 갱신 | + +### 5.3 React 앱 배포 (Next.js) + +서버 스펙(2코어, 3.8GB RAM)으로는 Next.js 빌드가 메모리 부족으로 실패한다. +따라서 **로컬에서 빌드 → 결과물을 서버에 업로드**하는 방식을 사용한다. + +```bash +# deploy.sh가 수행하는 5단계 +① 로컬에서 npm run build # standalone 빌드 +② tar.gz로 압축 # .next/standalone + static + public +③ scp로 서버 업로드 # 압축 파일 전송 +④ 서버에서 압축 해제 + 시작 # node server.js (포트 3001) +⑤ 로컬 정리 # 임시 파일 삭제 +``` + +--- + +## 6. SAM 도메인별 요청 경로 + +### 6.1 도메인 → 서비스 매핑 + +| 도메인 | 서비스 | 기술 스택 | 응답 형태 | +|--------|--------|-----------|-----------| +| `mng.sam.kr` | MNG | Laravel + Blade + HTMX | HTML (서버 렌더링) | +| `api.sam.kr` | API | Laravel | JSON | +| `dev.sam.kr` | React | Next.js | HTML (SSR/CSR) | +| `sales.sam.kr` | Sales | Laravel | HTML | +| `5130.sam.kr` | 5130 | PHP 7.3 (레거시) | HTML | + +### 6.2 서비스별 요청 흐름 + +**MNG** (관리자 화면 — Blade + HTMX): +``` +브라우저 → Nginx(:443) → MNG PHP-FPM(:9000) → Laravel → Blade HTML +이후 HTMX가 HTML 조각을 Ajax로 교체 (전체 새로고침 없음) +``` + +**API** (REST API — JSON 응답): +``` +React/외부 → Nginx(:443) → API PHP-FPM(:9000) → Laravel → JSON +인증: Bearer 토큰 (Authorization 헤더) +``` + +**React** (Next.js — SSR + CSR): +``` +브라우저 → Nginx(:443) → Node.js(:3000) → SSR HTML +이후 React 하이드레이션 → CSR (클라이언트 렌더링) +API 호출 시 → Next.js API Route 프록시 → api.sam.kr +``` + +--- + +## 관련 문서 + +| 문서 | 설명 | +|------|------| +| [docker-setup.md](../specs/docker-setup.md) | Docker 환경 설정값 상세 | +| [system-overview.md](../architecture/system-overview.md) | 시스템 아키텍처 레퍼런스 | +| [production-deployment-plan.md](../plans/production-deployment-plan.md) | 운영 배포 계획 | +| [dev-commands.md](../quickstart/dev-commands.md) | 개발 명령어 모음 | + +--- + +**최종 업데이트**: 2026-02-22 diff --git a/guides/table-design-guide.md b/guides/table-design-guide.md new file mode 100644 index 0000000..a920f08 --- /dev/null +++ b/guides/table-design-guide.md @@ -0,0 +1,486 @@ +# SAM 테이블 설계 가이드 — 비전문가용 + +> **작성일**: 2026-03-02 +> **대상 독자**: 개발자, 기획자, 관리자 — 데이터베이스를 잘 모르는 분도 읽을 수 있습니다 +> **관련 정책**: `standards/options-column-policy.md` (개발자 전용 상세 규칙) + +--- + +## 1. 이 문서는 왜 필요한가? + +SAM은 **여러 회사가 하나의 시스템을 공유**하는 구조입니다. +A회사, B회사, C회사가 모두 같은 프로그램을 쓰지만, 각 회사가 필요한 정보는 다릅니다. + +이 문서는 SAM에서 **데이터를 어떻게 저장하는지**, 그 설계 철학을 누구나 이해할 수 있도록 설명합니다. + +--- + +## 2. 기본 개념: 데이터베이스 테이블이란? + +데이터베이스 테이블은 **엑셀 시트**와 같습니다. + +``` +"주문" 테이블 (= 엑셀 시트) + + 열(컬럼) → 주문번호 │ 고객명 │ 금액 │ 상태 + ───────────────────────────────────────────────────────── + 행(레코드) 1 → ORD-001 │ 김철수 │ 500,000 │ 완료 + 행(레코드) 2 → ORD-002 │ 이영희 │ 300,000 │ 진행중 + 행(레코드) 3 → ORD-003 │ 박민수 │ 800,000 │ 대기 +``` + +- **열(컬럼)** = 정보의 종류 (주문번호, 고객명, 금액...) +- **행(레코드)** = 실제 데이터 한 건 (주문 1건) + +--- + +## 3. 문제: 회사마다 필요한 정보가 다르다 + +SAM은 여러 회사가 같은 테이블을 공유합니다. + +``` +같은 "주문" 테이블을 쓰는데... + + 🏭 A회사 (블라인드 제조) + → "절곡 각도", "날개 수" 정보가 필요해요 + + 🏭 B회사 (스크린 제조) + → "메시 밀도", "소재 종류" 정보가 필요해요 + + 🏭 C회사 (셔터 제조) + → "날개 간격", "색상 코드" 정보가 필요해요 +``` + +--- + +### 3.1 전통적인 해결 방법 (SAM은 이렇게 안 합니다) + +필요할 때마다 엑셀에 열을 추가하는 것처럼, 테이블에 컬럼을 추가합니다. + +``` +"주문" 테이블 — 전통적 방식 + + 주문번호 │ 고객명 │ 금액 │ 절곡각도 │ 날개수 │ 메시밀도 │ 소재 │ 날개간격 │ 색상코드 + ───────────────────────────────────────────────────────────────────────────────── + ORD-001 │ 김철수 │ 50만 │ 45도 │ 12개 │ (빈칸) │ (빈칸) │ (빈칸) │ (빈칸) ← A회사 + ORD-002 │ 이영희 │ 30만 │ (빈칸) │ (빈칸)│ 18 │ 폴리 │ (빈칸) │ (빈칸) ← B회사 + ORD-003 │ 박민수 │ 80만 │ (빈칸) │ (빈칸)│ (빈칸) │ (빈칸) │ 25mm │ #FF0000 ← C회사 +``` + +**문제점:** + +- 회사가 100개면? 열이 수백 개로 늘어남 +- 각 회사는 자기 것 빼고 전부 빈칸 +- 새 회사가 들어올 때마다 시스템 전체를 수정해야 함 +- 열 추가 = 시스템 중단 위험이 있는 작업 + +--- + +### 3.2 SAM의 해결 방법: "메모칸(options)" 하나로 통합 + +**핵심 열만 남기고**, 나머지는 **메모칸 하나**에 자유롭게 적습니다. + +``` +"주문" 테이블 — SAM 방식 + + 주문번호 │ 고객명 │ 금액 │ 상태 │ options (메모칸) + ──────────────────────────────────────────────────────────────────────── + ORD-001 │ 김철수 │ 50만 │ 완료 │ {"절곡각도": 45, "날개수": 12} ← A회사 + ORD-002 │ 이영희 │ 30만 │ 진행 │ {"메시밀도": 18, "소재": "폴리에스터"} ← B회사 + ORD-003 │ 박민수 │ 80만 │ 대기 │ {"날개간격": 25, "색상코드": "#FF0000"} ← C회사 + ORD-004 │ 최지은 │ 40만 │ 대기 │ null ← 메모 없음 +``` + +**`options`** = JSON이라는 형식의 메모칸. `{ }` 안에 자유롭게 정보를 넣을 수 있습니다. + +--- + +## 4. 어떤 정보를 열(컬럼)로 만들고, 어떤 정보를 메모칸(options)에 넣나? + +이것이 SAM 테이블 설계의 **가장 중요한 판단 기준**입니다. + +### 4.1 판단 흐름 (5가지 질문) + +새로운 정보를 저장해야 할 때, 아래 질문에 답합니다. + +``` +질문 1. 이 정보로 다른 테이블의 데이터를 연결(참조)하나? + 예: 고객ID로 고객 테이블을 찾는다 + → YES: 일반 컬럼 + +질문 2. 이 정보로 자주 검색(필터)하나? + 예: "완료" 상태인 주문만 보여줘 + → YES: 일반 컬럼 + +질문 3. 이 정보로 정렬하나? + 예: 최신 주문부터 보여줘 + → YES: 일반 컬럼 + +질문 4. 이 정보가 절대 중복되면 안 되나? + 예: 주문번호는 세상에 하나뿐이어야 한다 + → YES: 일반 컬럼 + +질문 5. 이 정보로 합계/평균을 계산하나? + 예: 이번 달 매출 합계 + → YES: 일반 컬럼 + +질문 1~5 전부 NO → options 메모칸에 저장 +``` + +### 4.2 실생활 예시로 비교 + +#### 예시 1: "주문" 테이블 + +| 정보 | 어디에 저장? | 이유 | +|------|:-----------:|------| +| 주문번호 | **일반 컬럼** | 중복 불가 + 검색 필수 | +| 고객 ID | **일반 컬럼** | 고객 테이블과 연결 | +| 금액 | **일반 컬럼** | 합계 계산 필요 | +| 상태 (진행/완료) | **일반 컬럼** | 필터(검색) 필수 | +| 생성일 | **일반 컬럼** | 정렬 필요 | +| 배송지 주소 | **options** | 부가 정보, 검색 안 함 | +| 수신자 이름 | **options** | 부가 정보 | +| 수신자 연락처 | **options** | 부가 정보 | +| 특이사항 메모 | **options** | 있어도 되고 없어도 됨 | + +**실제 SAM 코드에서 주문(Order) 테이블:** + +``` +일반 컬럼: id, tenant_id, order_number, client_id, total_amount, status, created_at +options: {"shipping_cost_code":"착불", "receiver":"홍길동", + "receiver_contact":"010-1234-5678", + "shipping_address":"서울 강남구 역삼동 123"} +``` + +#### 예시 2: "입고검사" 테이블 + +| 정보 | 어디에 저장? | 이유 | +|------|:-----------:|------| +| 품목 ID | **일반 컬럼** | 품목 테이블과 연결 | +| 수량 | **일반 컬럼** | 합계 계산 | +| 입고일 | **일반 컬럼** | 정렬 + 검색 | +| 제조사 | **options** | 모든 입고에 있지는 않음 | +| 검사 결과 (합격/불합격) | **options** | 검사를 안 하는 회사도 있음 | +| 검사일 | **options** | 선택적 정보 | + +**실제 SAM 코드에서 입고(Receiving) 테이블:** + +``` +일반 컬럼: id, tenant_id, item_id, quantity, received_at, status +options: {"manufacturer":"삼성전자", + "inspection_status":"합격", + "inspection_date":"2026-03-01"} +``` + +> 검사 결과가 options에 있는 이유: **모든 회사가 입고검사를 하는 것은 아닙니다.** +> A회사는 검사를 하고, B회사는 안 합니다. 이걸 일반 컬럼으로 만들면 B회사에겐 항상 빈칸입니다. + +#### 예시 3: "공정" 테이블 + +| 정보 | 어디에 저장? | 이유 | +|------|:-----------:|------| +| 공정 코드 | **일반 컬럼** | 중복 불가 + 검색 | +| 공정명 | **일반 컬럼** | 검색 + 표시 | +| 담당 부서 | **일반 컬럼** | 필터 | +| 작업일지 필요 여부 | **options** | 회사별로 다름 | +| 검사 필요 여부 | **options** | 회사별로 다름 | + +``` +일반 컬럼: id, tenant_id, process_code, process_name, department +options: {"needs_work_log": true, "needs_inspection": false} +``` + +--- + +## 5. 메모칸(options)의 실제 모습: JSON이란? + +`options`에 저장되는 데이터 형식은 **JSON**입니다. +JSON은 프로그래밍 세계의 "구조화된 메모장"이라고 생각하면 됩니다. + +### 5.1 JSON 기본 문법 + +``` +{ ← 시작 + "키": "값", ← 문자(텍스트) + "이름": "홍길동", + "나이": 30, ← 숫자 (따옴표 없음) + "합격": true, ← 참/거짓 (따옴표 없음) + "메모": null ← 값 없음 +} ← 끝 +``` + +### 5.2 중첩(nested) — 메모 안의 메모 + +``` +{ + "배송": { ← 배송 관련 정보를 묶음 + "주소": "서울 강남구 역삼동", + "수신자": "홍길동", + "연락처": "010-1234-5678" + }, + "검사": { ← 검사 관련 정보를 묶음 + "결과": "합격", + "검사일": "2026-03-01", + "검사자ID": 5 + } +} +``` + +### 5.3 목록(배열) — 여러 개를 나열 + +``` +{ + "선택지": [ ← 대괄호 [ ] = 목록 + {"label": "블라인드", "value": "blind"}, + {"label": "스크린", "value": "screen"}, + {"label": "셔터", "value": "shutter"} + ] +} +``` + +> 이 형태는 드롭다운 메뉴의 선택지 목록을 저장할 때 사용합니다. + +--- + +## 6. 멀티테넌시란? — 여러 회사가 하나의 시스템을 쓰는 구조 + +### 6.1 개념 + +``` +┌──────────────────────────────────────────────┐ +│ SAM 시스템 (하나의 프로그램) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ A회사 │ │ B회사 │ │ C회사 │ │ +│ │ tenant=1 │ │ tenant=2 │ │ tenant=3 │ │ +│ │ │ │ │ │ │ │ +│ │ 블라인드 │ │ 스크린 │ │ 셔터 │ │ +│ │ 제조 │ │ 제조 │ │ 제조 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ 같은 테이블을 쓰지만, │ +│ tenant_id로 데이터가 완전히 분리됨 │ +└──────────────────────────────────────────────┘ +``` + +### 6.2 tenant_id = 회사 식별 번호 + +모든 테이블의 모든 행에 `tenant_id`(회사 번호)가 붙어 있습니다. + +``` +"주문" 테이블 + + id │ tenant_id │ 주문번호 │ 금액 │ options + ─────────────────────────────────────────────────────────── + 1 │ 1 │ ORD-001 │ 50만 │ {"절곡각도": 45} ← A회사 데이터 + 2 │ 1 │ ORD-002 │ 30만 │ {"절곡각도": 90} ← A회사 데이터 + 3 │ 2 │ ORD-001 │ 80만 │ {"메시밀도": 18} ← B회사 데이터 + 4 │ 3 │ ORD-001 │ 40만 │ {"날개간격": 25} ← C회사 데이터 +``` + +**A회사가 로그인하면** → 시스템이 자동으로 `tenant_id = 1`인 데이터만 보여줌 +**B회사가 로그인하면** → 시스템이 자동으로 `tenant_id = 2`인 데이터만 보여줌 + +> A회사는 B회사의 데이터를 절대 볼 수 없습니다. 시스템이 자동으로 차단합니다. + +### 6.3 options + tenant_id = 강력한 조합 + +이 두 가지가 합쳐지면: + +``` +같은 테이블, 같은 컬럼 구조인데 + ✅ 회사마다 다른 데이터 (tenant_id로 분리) + ✅ 회사마다 다른 속성 (options로 유연하게) + ✅ 시스템 수정 없이 확장 가능 +``` + +--- + +## 7. SAM 테이블의 표준 구조 + +SAM에서 새 테이블을 만들면 항상 이 구조를 따릅니다. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SAM 표준 테이블 구조 │ +│ │ +│ ① 식별자 │ +│ id — 자동 생성 번호 (1, 2, 3...) │ +│ tenant_id — 어느 회사의 데이터인지 │ +│ │ +│ ② 핵심 정보 (검색/정렬/연결에 쓰는 것만) │ +│ code — 코드 (중복 불가) │ +│ status — 상태 (검색용) │ +│ is_active — 사용 여부 │ +│ sort_order — 표시 순서 │ +│ (+ FK 컬럼들) — 다른 테이블 연결 │ +│ │ +│ ③ 메모칸 │ +│ options — 나머지 전부 (JSON) │ +│ │ +│ ④ 감사 기록 (자동) │ +│ created_by — 누가 만들었나 │ +│ updated_by — 누가 수정했나 │ +│ deleted_by — 누가 삭제했나 │ +│ created_at — 언제 만들었나 │ +│ updated_at — 언제 수정했나 │ +│ deleted_at — 언제 삭제했나 (휴지통 개념) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 영역별 설명 + +| 영역 | 역할 | 비유 | +|------|------|------| +| ① 식별자 | "이 데이터가 누구 것인지" 구분 | 우편물의 받는 사람 + 주소 | +| ② 핵심 정보 | 검색, 정렬, 집계에 꼭 필요한 정보 | 엑셀의 고정 열 | +| ③ options | 회사마다 다른 부가 정보 | 엑셀의 "비고" 칸 (자유 서식) | +| ④ 감사 기록 | 언제 누가 뭘 했는지 자동 추적 | CCTV 기록 | + +--- + +## 8. 실제 SAM에서 options를 쓰는 테이블들 (22개) + +현재 SAM에서 options 메모칸을 사용하는 주요 테이블입니다. + +| 테이블 | 한글명 | options에 저장하는 정보 예시 | +|--------|--------|--------------------------| +| `orders` | 주문 | 배송지, 수신자, 연락처, 담당자 | +| `quotes` | 견적 | 견적 요약, 비용 항목, 가격 조정 | +| `receivings` | 입고 | 제조사, 검사 결과, 검사일 | +| `work_orders` | 작업지시 | 절곡 정보 (bending_info) | +| `work_order_items` | 작업지시 항목 | 작업 결과, 양품/불량 수량, LOT번호 | +| `processes` | 공정 | 작업일지 필요 여부, 검사 필요 여부 | +| `order_nodes` | 주문 노드 | 위치, 구역, 층, 실 (트리 구조) | +| `products` | 제품 | 동적 옵션 (라벨, 값, 단위) | +| `items` | 품목 | 품목별 동적 속성 | +| `materials` | 자재 | 자재 추가 속성 | +| `menus` | 메뉴 | 섹션, 메뉴 타입, 필요 권한 | +| `users` | 사용자 | 개인 설정/환경설정 | +| `tenants` | 회사(테넌트) | 회사 규모, 업종 | +| `document_template_section_fields` | 문서 양식 필드 | 선택지 목록, API 경로 | +| `item_fields` | 품목 필드 정의 | 필드별 세부 설정 | + +--- + +## 9. 자주 묻는 질문 (FAQ) + +### Q1. options에 넣으면 검색이 안 되나요? + +**아닙니다.** MySQL 8.0은 JSON 내부도 검색할 수 있습니다. + +``` +일반 컬럼 검색: "상태가 '완료'인 주문 찾아줘" → 매우 빠름 +options 검색: "제조사가 '삼성'인 입고 찾아줘" → 가능하지만 조금 느림 +``` + +다만, **매일 수천 번 검색하는 정보**라면 일반 컬럼으로 승격하는 것이 맞습니다. +가끔 검색하는 정보라면 options로 충분합니다. + +### Q2. options에 아무 정보나 마음대로 넣을 수 있나요? + +기술적으로는 가능하지만, 개발팀 내부에서 **어떤 키를 쓸지 미리 약속**합니다. + +``` +✅ 약속된 키: {"manufacturer": "삼성", "inspection_status": "합격"} +❌ 멋대로: {"asdf": 123, "temp_data": "???"} +``` + +코드에서 상수로 정의하여 일관성을 유지합니다. + +### Q3. 전통적 방식보다 뭐가 좋은 건가요? + +| 비교 항목 | 전통적 방식 (열 추가) | SAM 방식 (options JSON) | +|----------|:------------------:|:---------------------:| +| 새 정보 추가 시 | 시스템 수정 필요 | 코드만 변경 | +| 다른 회사에 영향 | 있음 (전체 구조 변경) | 없음 | +| 빈칸(null) 낭비 | 많음 | 없음 | +| 검색 속도 | 빠름 | 조금 느림 (충분히 실용적) | +| 유연성 | 낮음 | 높음 | +| 시스템 중단 위험 | 있음 (대형 테이블 수정 시) | 없음 | + +### Q4. 그럼 모든 정보를 options에 넣으면 되지 않나요? + +**아닙니다.** 핵심 정보는 반드시 일반 컬럼으로 만들어야 합니다. + +``` +❌ 나쁜 예: 모든 것을 options에 + + id │ tenant_id │ options + ────────────────────────────────────────────────────────────── + 1 │ 1 │ {"주문번호":"ORD-001", "금액":500000, "상태":"완료", ...} + + → 주문번호 검색 느림, 금액 합계 계산 불가, 중복 방지 불가 +``` + +``` +✅ 좋은 예: 핵심은 컬럼, 부가는 options + + id │ tenant_id │ order_number │ amount │ status │ options + ────────────────────────────────────────────────────────────── + 1 │ 1 │ ORD-001 │ 500000 │ 완료 │ {"배송지":"서울..."} + + → 검색 빠름, 합계 가능, 중복 방지 가능, 부가 정보도 유연 +``` + +### Q5. options 데이터는 화면에서 어떻게 보이나요? + +사용자 화면에서는 options 안에 있는지, 일반 컬럼인지 **구분할 수 없습니다**. +프로그램이 자동으로 꺼내서 보여줍니다. + +``` +화면에 보이는 모습: + + ┌─────────────────────────────────┐ + │ 입고 상세 정보 │ + │ │ + │ 품목: SUS304 스틸 │ ← 일반 컬럼 + │ 수량: 100개 │ ← 일반 컬럼 + │ 입고일: 2026-03-01 │ ← 일반 컬럼 + │ 제조사: 삼성전자 │ ← options에서 꺼냄 + │ 검사결과: 합격 │ ← options에서 꺼냄 + │ 검사일: 2026-03-01 │ ← options에서 꺼냄 + └─────────────────────────────────┘ + + 사용자는 어디에 저장되어 있는지 알 필요 없음! +``` + +--- + +## 10. 한 장 요약 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ SAM 테이블 설계 = "핵심만 컬럼, 나머진 메모칸(options)" │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ tenant_id → 어느 회사 것인지 (자동 격리) │ │ +│ │ 핵심 컬럼들 → 검색/정렬/연결/집계에 쓰는 필수 정보 │ │ +│ │ options → 나머지 전부 (회사마다 다른 부가 정보) │ │ +│ │ 감사 컬럼들 → 누가/언제 만들고/수정하고/삭제했는지 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ 이렇게 하면: │ +│ ✅ 회사 추가해도 테이블 구조 안 바꿈 │ +│ ✅ 새 정보 추가해도 시스템 수정 최소화 │ +│ ✅ 회사마다 다른 정보를 유연하게 저장 │ +│ ✅ 데이터 보안 (회사 간 완전 분리) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 관련 문서 + +| 문서 | 설명 | 대상 | +|------|------|------| +| [options-column-policy.md](../standards/options-column-policy.md) | 개발자용 상세 정책 (코드 규칙, 마이그레이션 패턴) | 개발자 | +| [database/README.md](../system/database/README.md) | DB 스키마 전체 현황 (220개 모델) | 개발자 | +| [PROJECT_DEVELOPMENT_POLICY.md](PROJECT_DEVELOPMENT_POLICY.md) | 개발 공통 정책 (테이블 생성 절차) | 개발자 | +| [system/overview.md](../system/overview.md) | SAM 시스템 전체 아키텍처 | 전체 | + +--- + +**최종 업데이트**: 2026-03-02 diff --git a/plans/SAM_General_Rule_Storyboard_D1.0.md b/plans/SAM_General_Rule_Storyboard_D1.0.md new file mode 100644 index 0000000..ae23c7b --- /dev/null +++ b/plans/SAM_General_Rule_Storyboard_D1.0.md @@ -0,0 +1,737 @@ +# SAM General Rule Storyboard D1.0 + +> **작성일**: 2026-01-16 +> **버전**: D1.0 +> **원본**: `SAM_General_Rule_Storyboard_D1.0_260116.pdf` (43페이지) +> **상태**: PC 섹션 정리 완료 + +--- + +## 1. 문서 이력 + +| 날짜 | 버전 | 주요 내용 | 세부 내용 | +|------|------|----------|----------| +| 2026-01-15 | D0.9 | 초안 | General Rule - PC, 태블릿, 모바일 UIUX 공통 작성 | +| 2026-01-16 | D1.0 | 작성 | PC 섹션 정리 33p | + +--- + +## 2. 인터랙션 (Interaction) + +> **페이지**: 4 + +사용자 입력 제스처 및 적용 여부를 정의한다. + +| Type | 제스처/마크 | 설명 | 적용 | +|------|-----------|------|------| +| Tap | 탭 | 일정영역을 사용자가 터치한다. | Yes | +| Touch & Hold | 터치 앤 홀드 | 화면을 터치한 후 계속 누르고 있는 상태. 해당영역 혹은 개체가 홀드된다. | No | +| Double Tap | 더블 탭 | 일정영역을 두 번 터치한다. 두 번 터치 시 액션이 실행된다. | No | +| Drag & Drop | 드래그 앤 드롭 | 터치 혹은 홀드 상태에서 오브젝트를 이동하여 원하는 위치에 배치시킨다. | Yes | +| Scroll Up | 스크롤 업 | 아래에서 위로 누르는 동작을 유지하면서 이동하였다가 뗀다. | Yes | +| Scroll Down | 스크롤 다운 | 위에서 아래로 누르는 동작을 유지하면서 이동하였다가 뗀다. | Yes | +| Swipe Left | 스와이프 레프트 | 오른쪽에서 왼쪽으로 누르는 동작을 유지하면서 이동하였다가 뗀다. | Yes | +| Swipe Right | 스와이프 라이트 | 왼쪽에서 오른쪽으로 누르는 동작을 유지하면서 이동하였다가 뗀다. | Yes | +| Pinch Zoom out | 핀치 줌 아웃 | 오브젝트 또는 화면을 축소한다. | Yes | +| Pinch Zoom in | 핀치 줌 인 | 오브젝트 혹은 화면을 확대한다. | Yes | + +--- + +## 3. 반응형 웹 (Responsive Web) + +> **페이지**: 5 + +### 3.1 브레이크 포인트 + +| 디바이스 | 브레이크 포인트 | Tailwind 접두사 | +|---------|---------------|----------------| +| 모바일 | < 640px | 기본 | +| 태블릿 | 768px ~ 1280px | `md` | +| 데스크탑 | 1280px+ | `lg` | +| 대형 모니터 | 1920px+ | `xl` | + +### 3.2 레이아웃 구성 + +**PC Web 레이아웃**: +1. Contents 영역 +2. Footer 영역 + +**Mobile Web 레이아웃**: +1. Contents 영역 +2. Footer 영역 + +--- + +## 4. 화면 템플릿 (Screen Template) + +> **페이지**: 6 + +모바일 웹 화면 구조를 정의한다. + +| 영역 | 코드 | 설명 | +|------|------|------| +| Status bar | A | 안테나, 통화, 배터리 등 시스템 OS 관리 영역. 모든 페이지 상단에 존재 | +| Browser 영역 | B | 브라우저 기능 영역 | +| Title 영역 | C | 텍스트 또는 기능 버튼으로 구현됨. 텍스트는 기본 가운데 정렬 | +| Content 영역 | D | 컨텐츠 내용 표시. 컨텐츠 길이가 길어질 경우 스크롤 제공 | +| Browser bar 영역 | E | 브라우저 유틸 바 영역 | +| Keypad 영역 | F | 키보드 입력할 때 활성화. 모든 페이지 위에 덮어쓰기 구현 | + +--- + +## 5. 메시지 (Notifications) + +> **페이지**: 7 + +| 유형 | 설명 | +|------|------| +| 알림 Alert | 사용자에게 상황을 알려주기 위한 팝업. `[확인]` 버튼 제공 | +| 확인 Alert | 사용자에게 확인이 필요할 경우 제공되는 팝업. `[취소]` `[확인]` 버튼 제공 | +| 토스트 메시지 | 단순 Notify. 2~3초 후 페이지 내에서 Fade out | + +--- + +## 6. GNB, LNB, 푸터 + +> **페이지**: 8 + +PC 화면의 전체 레이아웃 구조를 정의한다. + +### 6.1 구성 요소 + +| 번호 | 영역 | 설명 | +|------|------|------| +| 01 | 메뉴 버튼 | 클릭: 메뉴 영역(06) 축소/확장 토글. 디폴트: 메뉴 영역 확장 상태 | +| 02 | SAM 로고 버튼 | 클릭: 대시보드 화면으로 이동 | +| 03 | 알림 버튼 | 클릭: 알림 팝업 표시 | +| 04 | 개인 정보 버튼 | 항목: 디폴트 이미지, 이름, 직급. 클릭: 마이페이지 팝업 표시 | +| 05 | 회사 로고 | 회사정보 화면에서 등록한 로고 표시. 회사 변경 선택 시 해당 로고 변경 | +| 06 | 메뉴 영역 | 메뉴 클릭: 하위 메뉴 있을 경우 하단에 표시, 없을 경우 해당 메뉴 화면으로 이동. 목록 길 경우 해당 영역 내 스크롤 처리 | +| 07 | MES 메뉴 영역 | 영업관리, 판매관리, 구매관리 등 해당하는 MES 메뉴 영역 표시 | +| 08 | 푸터 영역 | 모든 화면 하단 공통 표시 | +| 09 | SAM AI 채팅 버튼 | 클릭: SAM AI 채팅 팝업 표시 | + +### 6.2 메뉴 목록 + +- 대시보드 +- MES 메뉴 +- 인사관리 +- 전자결재 +- 게시판 +- 회계관리 +- 기준정보 +- 보고서 및 분석 +- 계정정보 +- 회사정보 +- 구독관리 +- 결제내역 +- 고객센터 + +### 6.3 푸터 내용 + +``` +(C) 2025 SAM. All right reserved. +Codebridge X +상호: 코드 브릿지 엑스 대표: 이경호 사업자등록번호: 123-45-12345 +주소: 서울특별시 강서구 양천로 583 우림블루나인 B동 1602호 (우: 07547) +팩스: 02-123-1234 통신판매업신고번호: 제 2019-서울강서-0001호 +서비스이용문의: 02-1234-1234 이메일: cs@a.com +서비스 이용약관 | 개인정보 취급방침 +``` + +--- + +## 7. 메뉴, 페이지, 섹션, 항목 영역 + +> **페이지**: 9 + +### 7.1 영역 구분 + +| 번호 | 영역 | 설명 | +|------|------|------| +| 01 | 메뉴 영역 | 축소 상태 | +| 02 | 페이지 영역 | - | +| 03 | 섹션 영역 | - | +| 04 | 항목 영역 | - | + +### 7.2 텍스트 오버플로우 처리 + +텍스트가 영역보다 길 경우 "텍스트+..." 형태로 표시한다. + +--- + +## 8. 메뉴 목록 (3Depth) + +> **페이지**: 10 + +### 8.1 메뉴 계층 구조 + +| 번호 | 레벨 | 설명 | +|------|------|------| +| 01 | 대메뉴 | 1Depth 메뉴 | +| 02 | 중메뉴 | 2Depth 메뉴 (대메뉴 클릭 시 하단에 표시) | +| 03 | 소메뉴 | 3Depth 메뉴 (중메뉴 클릭 시 하단에 표시) | + +**메뉴 확장 예시**: +``` +대시보드 +MES 메뉴 +인사관리 +전자결재 + - 중메뉴명 + - 중메뉴명 + · 소메뉴명 + · 소메뉴명 + - 중메뉴명 + - 중메뉴명 + - 중메뉴명 +게시판 +... +``` + +--- + +## 9. 알림 팝업 + +> **페이지**: 11 +> **경로**: 메인 > 알림 팝업 + +### 9.1 구성 요소 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 알림 목록 | 각 디폴트 썸네일, 종류(공지사항, 안내), 제목/내용, 전송일시 표시. 클릭: 해당 상세 화면으로 이동. 최신순 10개까지 표시 | +| 02 | New 아이콘 | 새 알림일 경우 New 아이콘 표시. 해당 알림 클릭 시 사라짐 | +| 02-1 | 붉은 점 아이콘 | 새 알림이 있을 경우 표시. 해당 알림 모두 클릭 시 사라짐 | + +--- + +## 10. 마이페이지 팝업 + +> **페이지**: 12 +> **경로**: 메인 > 마이페이지 팝업 + +### 10.1 구성 요소 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 계정 아이디 (이메일) | 이메일 주소 표시 (예: `name@company.com`) | +| 02 | 회사 셀렉트 박스 | 종류: 회사명 목록 (해당 계정이 생성한 회사(테넌트) 목록 표시). 정렬: 등록순. 한 회사만 소유중일 경우에는 해당 영역 숨김 | +| 03 | 로그아웃 버튼 | 클릭: "정말 로그아웃하시겠습니까?" 로그아웃 확인 Alert 표시. 확인 버튼 클릭시 로그아웃 처리 | + +--- + +## 11. 셀렉트 박스 + +> **페이지**: 13 + +### 11.1 기본 셀렉트 박스 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 셀렉트 박스 | 클릭: 하단에 종류 목록 표시 | +| 02 | 종류 목록 | 목록 중 하나만 선택 가능 | + +### 11.2 다중 선택 셀렉트 박스 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 03 | 다중 선택 셀렉트 박스 | 선택된 첫번째 항목명 + 추가 수 표시. 텍스트 영역 부족할 경우 `항목..+3` 형태로 표시 | +| 04 | 다중 선택 종류 목록 | 목록 중 복수 선택 가능. 전체 선택 시 전체 선택/해제 토글 | + +### 11.3 검색 셀렉트 박스 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 05 | 검색 영역 | 검색어 입력 후 엔터 또는 검색 아이콘 클릭 시 검색 상태로 전환되며 검색 결과 표시 | +| 05-3 | 삭제 버튼 | 클릭: 검색어 삭제 처리, 전체 종류 목록 표시 | + +### 11.4 셀렉트 박스 유형 정리 + +| 유형 | 단일 선택 | 다중 선택 | 검색 | 검색+다중 선택 | +|------|----------|----------|------|--------------| +| 선택 방식 | 하나만 | 복수 | 하나만 | 복수 | +| 검색 기능 | X | X | O | O | +| 전체 선택 | X | O | X | O | + +--- + +## 12. 가이드 메시지 + +> **페이지**: 14 + +상황에 따라 입력 필드 하단 또는 Alert에 가이드 메시지를 표시한다. + +### 12.1 표시 규칙 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 가이드 메시지 표시 위치 | 입력 필드 하단에 표시 | +| - | 긍정 메시지 | 녹색으로 표시 | +| 01-1 | 부정 메시지 | 붉은색으로 표시 | + +--- + +## 13. 태블릿/모바일 헤더 + +> **페이지**: 15 + +### 13.1 동작 규칙 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 태블릿/모바일 헤더 | 하단으로 스크롤 시 숨김. 역스크롤 시 표시 | + +### 13.2 적용 화면 + +- TABLET 가로 목록 +- TABLET 세로 목록 +- MOBILE 가로 목록 +- MOBILE 세로 목록 + +--- + +## 14. 태블릿/모바일 바텀 버튼 영역 + +> **페이지**: 16 + +### 14.1 동작 규칙 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 태블릿/모바일 바텀 버튼 영역 | 최하단 바텀에 플로팅 표시. 하단으로 스크롤 시 숨김. 역스크롤 시 표시 | + +### 14.2 버튼 예시 + +- `[수정]` `[삭제]` + +--- + +## 15. 공지 팝업 + +> **페이지**: 17 + +### 15.1 구성 + +| 항목 | 설명 | +|------|------| +| 대상 | 전체, 설정 부서 | +| 내용 | 설정 기간동안 대상에게 팝업 표시 | + +### 15.2 구성 요소 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 팝업 내용 영역 | 이미지, 텍스트 | +| 02 | 1일간 이 창을 열지 않음 체크박스 | 클릭: 체크 설정/해제 토글. 디폴트: 체크 해제 상태. 체크 설정 시 1일 동안 팝업 미표시 (자정 기준) | + +--- + +## 16. 목록 화면 - 4단계 반응형 + +> **페이지**: 18~25 + +PC, TABLET, MOBILE 환경에서 목록 화면의 4단계 반응형 표시를 정의한다. + +### 16.1 반응형 단계 개요 + +| 단계 | 디바이스 | 화면명 | +|------|---------|--------| +| 1단계 | PC | PC_목록 | +| 2단계 | TABLET 가로 | TABLET_가로_목록 | +| 3단계 | TABLET 세로 / MOBILE 가로 | TABLET_세로_목록, MOBILE_가로_목록 | +| 4단계 | MOBILE 세로 | MOBILE_세로_목록 | + +### 16.2 PC_목록 (1단계) + +> **페이지**: 19 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 헤더 영역 | 항목 클릭: 값이 국문/영문/숫자일 경우 오름/내림차순으로 토글 | +| 02 | 정렬 아이콘 | 현재 칼럼으로 정렬 상태일 경우에만 표시 | + +**목록 테이블 예시 칼럼**: + +| 칼럼 | 설명 | +|------|------| +| 시공번호 | 고유 식별 번호 | +| 거래처 | 회사명 | +| 현장명 | 현장 이름 | +| 공사PM | 담당 PM | +| 작업반장 | 작업반장 이름 | +| 작업자 | 작업자 수 | +| 시공투입일 | 시공 투입 날짜 | +| 시공완료일 | 시공 완료 날짜 | +| 상태 | 시공대기, 시공진행, 시공완료 | + +### 16.3 TABLET_가로_목록 (2단계) + +> **페이지**: 20 + +- PC와 동일한 테이블 구조 +- 사이드 메뉴가 아이콘 축소 상태로 변경 + +### 16.4 TABLET_세로_목록 (3단계) + +> **페이지**: 21~22 + +- 테이블 대신 카드형 목록으로 전환 +- 각 카드에 시공번호와 상태 표시 +- 카드 클릭 시 확장되어 상세 정보 표시 + +**확장 시 표시 항목**: + +| 필드 | 예시 값 | +|------|---------| +| 거래처 | 회사명 | +| 현장명 | 현장명 | +| 공사PM | 홍길동 | +| 작업반장 | 홍길동 | +| 작업자 | 3 | +| 시공투입일 | 2026-01-01 | +| 시공완료일 | 2026-01-01 | + +### 16.5 MOBILE_가로_목록 (3단계) + +> **페이지**: 23~24 + +- 카드형 목록 +- 시공번호와 상태 표시 +- 클릭 시 확장하여 상세 항목 표시 + +### 16.6 MOBILE_세로_목록 (4단계) + +> **페이지**: 25 + +- 카드형 목록 (세로 스크롤) +- 클릭 시 확장하여 상세 항목 표시 +- 확장 시 거래처, 현장명, 공사PM, 작업반장, 작업자, 시공투입일, 시공완료일 표시 + +--- + +## 17. 상세 화면 - 4단계 반응형 + +> **페이지**: 26~31 + +PC, TABLET, MOBILE 환경에서 상세 화면의 4단계 반응형 표시를 정의한다. + +### 17.1 반응형 단계 개요 + +| 단계 | 디바이스 | 화면명 | +|------|---------|--------| +| 1단계 | PC | PC_상세 | +| 2단계 | TABLET 가로 | TABLET_가로_상세 | +| 3단계 | TABLET 세로 / MOBILE 가로 | TABLET_세로_상세, MOBILE_가로_상세 | +| 4단계 | MOBILE 세로 | MOBILE_세로_상세 | + +### 17.2 PC_상세 (1단계) + +> **페이지**: 27 + +- 페이지 제목: "메뉴 상세" + 설명: "메뉴 상세를 관리합니다" +- 섹션명: "시공 정보" +- 버튼: `[수정]` `[삭제]` + +**표시 항목 예시**: + +| 필드 | 예시 값 | +|------|---------| +| 시공번호 | 123123 | +| 상태 | 시공진행 | +| 현장 | 현장명 | +| 작업반장 | 홍길동 (셀렉트 박스) | +| 시공투입일 | 2025-12-15 | +| 시공완료일 | 2025-12-15 | +| 항목명 | 항목 (다수) | + +### 17.3 TABLET_가로_상세 (2단계) + +> **페이지**: 28 + +- PC와 동일한 상세 정보 표시 +- 사이드 메뉴 아이콘 축소 상태 + +### 17.4 TABLET_세로_상세 (3단계) + +> **페이지**: 29 + +- 항목 수가 줄어들며 스크롤로 나머지 확인 + +### 17.5 MOBILE_가로_상세 (3단계) + +> **페이지**: 30 + +- 상세 항목을 세로 배치 +- 바텀에 `[수정]` `[삭제]` 버튼 플로팅 + +### 17.6 MOBILE_세로_상세 (4단계) + +> **페이지**: 31 + +- 모든 항목 세로 배치 +- 바텀에 `[수정]` `[삭제]` 버튼 플로팅 + +--- + +## 18. PC 섹션 정리 + +> **페이지**: 32~33 + +PC 화면의 섹션 레이아웃 및 필터/정렬 구성을 정의한다. + +### 18.1 필터 규칙 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 라디오 버튼형 필터 | 선택 값이 2개일 경우 사용 (예: 수취/발행) | +| 02 | 필터 셀렉트 박스 | 최소로만 활용 | +| 03 | 표 헤더 정렬 | 표 헤더 정렬로 정렬 셀렉트 박스는 삭제 | + +### 18.2 PC 섹션 구성 요소 + +**상단 영역**: +- 페이지 제목 + 설명 +- 집계 카드 (예: `수취어음 55건`, `발행어음 1건`, `만기임박 5건`, `결제완료 15건`) +- 기간 선택 (날짜 범위 + 단축 버튼: 전전월, 어제, 오늘, 전월, 당월, 당해년도) +- 버튼: `[버튼명]` `[버튼명]` `[버튼명]` +- 탭: 탭1, 탭2, 탭3 + +**필터 영역**: +- 셀렉트 박스 필터 (전체) +- 라디오 버튼형 필터 (수취/발행) +- 상태 셀렉트 박스 (보관중) +- `[저장]` 버튼 + +**목록 테이블 예시**: + +| No. | 어음번호 | 구분 | 거래처 | 금액 | 발행일 | 만기일 | 차수 | 상태 | +|-----|---------|------|--------|------|--------|--------|------|------| +| 7 | 123123 | 수취 | 회사명 | 1,000,000 | 2025-12-12 | 2025-12-12 | 1 | 보관중 | +| 6 | 123123 | 수취 | 회사명 | 1,000,000 | 2025-12-12 | 2025-12-12 | 2 | 만기임박 | + +**하단 정보**: `총 7건` / `1건 선택` + +--- + +## 19. TBD (미정) + +> **페이지**: 34 + +추후 결정 예정 영역이다. + +--- + +## 20. 나의 메뉴 + +> **페이지**: 35~38 + +### 20.1 나의 메뉴 - 없음 + +> **페이지**: 35 + +- 나의 메뉴가 설정되지 않은 상태 +- 콘텐츠 상단에 `[...]` 아이콘만 표시 + +### 20.2 나의 메뉴 - 있음 + +> **페이지**: 36 + +- 나의 메뉴가 1개 설정된 상태 +- 콘텐츠 상단에 나의 메뉴명 탭 표시 (예: `메뉴관리`) + +### 20.3 나의 메뉴 - 여러 줄 + +> **페이지**: 37 + +- 나의 메뉴가 여러 개 설정된 상태 +- 콘텐츠 상단에 여러 메뉴명이 나열됨 +- 줄바꿈되어 여러 줄로 표시 가능 (예: `메뉴관리 메뉴명 메뉴명 메뉴명 ...`) + +### 20.4 나의 메뉴 - 메뉴 영역에 통합 + +> **페이지**: 38 + +- 좌측 메뉴 영역에 "메뉴" / "나의 메뉴" 탭으로 통합 +- 메뉴 탭: 일반 메뉴 목록 표시 +- 나의 메뉴 탭: 사용자 즐겨찾기 메뉴 표시 + +--- + +## 21. 검색, 필터, 정렬 모음 + +> **페이지**: 39 + +### 21.1 구성 요소 + +| 영역 | 구성 | +|------|------| +| 기간 선택 | 날짜 범위 (`2025-09-01 ~ 2025-09-03`) + 단축 버튼 (전전월, 어제, 오늘, 전월, 당월, 당해년도) | +| 검색바 | 검색 입력 필드 | +| 필터 셀렉트 박스 | 복수의 전체 셀렉트 박스 | +| 정렬 | 최신순 셀렉트 박스 | +| 항목 필터 | 항목명 태그 형태로 나열 | + +--- + +## 22. 페이지 설정 버튼 + +> **페이지**: 40 + +### 22.1 기능 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 섹션 표시 및 순서 변경 | 페이지 내 섹션 ON/OFF 토글 및 순서 변경 | +| 02 | 일반 설정 | 일반 설정 > 페이지/섹션 설정 > 공통 요소 모두 제어 | + +### 22.2 설정 패널 구성 + +- 버전기록 +- 가져오기 +- 내보내기 +- 섹션 목록: 각 섹션별 ON/OFF 토글 + +**예시**: +``` +섹션명 [ON] +섹션명 [ON] +섹션명 [ON] +섹션명 [ON] +섹션명 [ON] +``` + +--- + +## 23. 섹션 설정 버튼 + +> **페이지**: 41 + +### 23.1 기능 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 항목 표시 및 순서 변경 | 섹션 내 항목 ON/OFF 토글 및 순서 변경 | +| 02 | 일반 설정 | 일반 설정 > 페이지/섹션 설정 > 공통 요소 모두 제어 | + +### 23.2 설정 패널 구성 + +- 가져오기 +- 내보내기 +- 항목 목록: 각 항목별 ON/OFF 토글 + +**예시**: +``` +기간 [ON] +기간단축버튼 [ON] +검색바 [ON] +필터명 [ON] +필터명 [ON] +필터명 [ON] +필터명 [ON] +정렬 [ON] +``` + +--- + +## 24. 태스크 알림 아이콘 + +> **페이지**: 42~43 + +### 24.1 동작 규칙 + +| 번호 | 항목 | 설명 | +|------|------|------| +| 01 | 태스크 알림 아이콘 | 태스크가 추가될 경우 카운트하여 표시 | +| - | 메뉴 확장 시 표시 | 대/중/소메뉴로 확장될 경우 해당 메뉴에 아이콘 표시 | +| - | 카운트 범위 | 최소 1 ~ 최대 99 | + +### 24.2 표시 예시 + +**축소 상태**: 대메뉴 옆에 카운트 배지 표시 (예: `전자결재 [3]`) + +**확장 상태 (2Depth)**: +``` +전자결재 [3] + - 중메뉴명 [2] + - 중메뉴명 [1] + - 중메뉴명 + - 중메뉴명 + - 중메뉴명 +``` + +**확장 상태 (3Depth)**: +``` +전자결재 [3] + - 중메뉴명 + - 중메뉴명 [1] + · 소메뉴명 [1] + · 소메뉴명 + - 중메뉴명 + - 중메뉴명 + - 중메뉴명 +``` + +### 24.3 페이지 내 표시 + +- 메뉴 축소 상태에서도 대메뉴 아이콘 옆에 카운트 배지 표시 + +--- + +## 부록: 페이지 맵 + +| 페이지 | 섹션 | 화면명 | +|--------|------|--------| +| 1 | 표지 | SAM_General Rule | +| 2 | 문서 이력 | Document History | +| 3 | 공통 | - | +| 4 | 인터랙션 | Interaction | +| 5 | 반응형 웹 | Responsive Web | +| 6 | 화면 템플릿 | Screen Template | +| 7 | 메시지 | Notifications | +| 8 | GNB, LNB, 푸터 | GNB, LNB, 푸터 | +| 9 | 영역 구분 | 메뉴, 페이지, 섹션, 항목 영역 | +| 10 | 메뉴 목록 | 메뉴 목록 3Depth | +| 11 | 알림 팝업 | 알림 팝업 | +| 12 | 마이페이지 | 마이페이지 팝업 | +| 13 | 셀렉트 박스 | 셀렉트 박스 (기본/다중/검색) | +| 14 | 가이드 메시지 | 가이드 메시지 | +| 15 | 태블릿/모바일 헤더 | 태블릿/모바일 헤더 | +| 16 | 태블릿/모바일 바텀 버튼 | 태블릿/모바일 바텀 버튼 영역 | +| 17 | 공지 팝업 | 공지 팝업 | +| 18 | (구분) | PC, TABLET, MOBILE - 목록 4단계 | +| 19 | 목록 1단계 | PC_목록 | +| 20 | 목록 2단계 | TABLET_가로_목록 | +| 21 | 목록 3단계 | TABLET_세로_목록 | +| 22 | 목록 3단계 확장 | TABLET_세로_목록_확장 | +| 23 | 목록 3단계 | MOBILE_가로_목록 | +| 24 | 목록 3단계 확장 | MOBILE_가로_목록_확장 | +| 25 | 목록 4단계 | MOBILE_세로_목록, MOBILE_세로_목록_확장 | +| 26 | (구분) | PC, TABLET, MOBILE - 상세 4단계 | +| 27 | 상세 1단계 | PC_상세 | +| 28 | 상세 2단계 | TABLET_가로_상세 | +| 29 | 상세 3단계 | TABLET_세로_상세 | +| 30 | 상세 3단계 | MOBILE_가로_상세 | +| 31 | 상세 4단계 | MOBILE_세로_상세 | +| 32 | (구분) | 섹션 정리 | +| 33 | 섹션 정리 | PC 섹션 정리 | +| 34 | TBD | 미정 | +| 35 | 나의 메뉴 | 나의 메뉴_없음 | +| 36 | 나의 메뉴 | 나의 메뉴_있음 | +| 37 | 나의 메뉴 | 나의 메뉴_여러 줄 | +| 38 | 나의 메뉴 | 나의 메뉴_메뉴 영역에 통합 | +| 39 | 검색/필터/정렬 | 검색, 필터, 정렬 모음 | +| 40 | 페이지 설정 | 페이지 설정 버튼 | +| 41 | 섹션 설정 | 섹션 설정 버튼 | +| 42~43 | 태스크 알림 | 태스크 알림 아이콘 | + +--- + +## 관련 문서 + +- [SAM ERP 회계관리 스토리보드 D1.6](SAM_ERP_회계관리_Storyboard_D1.6.md) +- 원본 PDF: `SAM_General_Rule_Storyboard_D1.0_260116.pdf` + +--- + +**최종 업데이트**: 2026-02-23 diff --git a/plans/ai-quotation-engine-plan.md b/plans/ai-quotation-engine-plan.md new file mode 100644 index 0000000..c1b5098 --- /dev/null +++ b/plans/ai-quotation-engine-plan.md @@ -0,0 +1,928 @@ +# AI 견적서 자동생성 엔진 개발 계획 + +> **작성일**: 2026-03-02 +> **상태**: 기획 초안 +> **프로젝트**: SAM API + MNG +> **우선순위**: 🔴 필수 +> **참조**: `docs/features/ai/README.md`, `docs/features/quotes/README.md`, `docs/rules/customer-pricing.md` + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM 계약 완료 후 매니저가 고객사 직원과 인터뷰를 진행할 때, **인터뷰 내용을 AI가 분석하여 SAM 표준 견적서 형태로 자동 변환**하는 엔진을 구축한다. + +현재 매니저가 수동으로 수행하는 프로세스: + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ SAM 계약 │ → │ 현장 인터뷰 │ → │ 업무 파악 │ → │ 견적서 작성 │ +│ 완료 │ │ (매니저+직원)│ │ (수동 정리) │ │ (수동 작성) │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +AI 엔진 도입 후: + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐ +│ SAM 계약 │ → │ 현장 인터뷰 │ → │ AI 엔진 │ → │ 견적서 초안 │ +│ 완료 │ │ (매니저+직원)│ │ (음성/텍스트 분석) │ │ (자동 생성) │ +│ │ │ │ │ (업무 매핑) │ │ (매니저 확인)│ +└──────────────┘ └──────────────┘ └──────────────────────┘ └──────────────┘ +``` + +### 1.2 핵심 원칙 + +| 원칙 | 설명 | +|------|------| +| **자체 AI 엔진** | Claude API 기반으로 SAM 전용 AI 서비스 구축 | +| **기존 인프라 활용** | `ai_configs`, `ai_token_usages`, `ai_pricing_configs` 테이블 재사용 | +| **견적 시스템 연동** | 기존 `quotes`, `quote_items` 테이블과 직접 연동 | +| **Multi-tenant** | 모든 데이터에 `tenant_id` 격리 적용 | +| **점진적 확장** | Phase 1 텍스트 → Phase 2 음성 (STT 구현 완료, 통합만 필요) → Phase 3 학습 고도화 | + +### 1.3 기존 인프라 현황 + +#### AI 인프라 (구축 완료) + +| 구성요소 | 상태 | 비고 | +|---------|------|------| +| `ai_configs` 테이블 | ✅ 완료 | Claude provider 설정 가능 | +| `ai_token_usages` 테이블 | ✅ 완료 | 토큰/비용 자동 추적 | +| `ai_pricing_configs` 테이블 | ✅ 완료 | Claude 모델 단가 등록 | +| `AiTokenHelper` | ✅ 완료 | `saveClaudeUsage()` 메서드 존재 | +| MNG AI 설정 UI | ✅ 완료 | `/system/ai-config` | + +#### 견적 시스템 (구축 완료) + +| 구성요소 | 상태 | 비고 | +|---------|------|------| +| `quotes` 테이블 | ✅ 완료 | 견적 마스터 | +| `quote_items` 테이블 | ✅ 완료 | 견적 품목 상세 | +| `QuoteService` | ✅ 완료 | 견적 CRUD, 상태 관리 | +| `QuoteCalculationService` | ✅ 완료 | BOM 10단계 계산 | +| 견적 API 엔드포인트 | ✅ 완료 | REST API 전체 | + +#### 음성 녹음/STT (구축 완료) + +| 구성요소 | 상태 | 비고 | +|---------|------|------| +| `ai_voice_recordings` 테이블 | ✅ 완료 | DB 스키마 + CRUD | +| GCS 업로드 | ✅ 완료 | `GoogleCloudService` — GCS 저장/조회/삭제 | +| Google Cloud STT 변환 | ✅ 완료 | `GoogleCloudService::speechToText()` — LongRunningRecognize, ko-KR | +| Web Speech API (브라우저 STT) | ✅ 완료 | `voice-recorder.blade.php` — 실시간 음성→텍스트 (무료) | +| STT + Gemini AI 분석 | ✅ 완료 | `AiVoiceRecordingService` — 음성→STT→AI 분석 파이프라인 | +| 화자 분리 (Diarization) | ✅ 완료 | `MeetingMinuteService` — Speaker Diarization | +| 영업 상담 음성 녹음 | ✅ 완료 | `ConsultationController` — MediaRecorder + STT + GCS 백업 | + +> **참조 구현 파일:** +> - `mng/app/Services/GoogleCloudService.php` — Google Cloud STT/GCS 통합 서비스 +> - `mng/app/Services/AiVoiceRecordingService.php` — STT + Gemini 분석 +> - `mng/app/Services/MeetingMinuteService.php` — 회의록 STT + 화자분리 +> - `mng/app/Http/Controllers/Sales/ConsultationController.php` — 영업 상담 음성 +> - `mng/resources/views/sales/modals/voice-recorder.blade.php` — 브라우저 음성 녹음 UI +> - `docs/features/voice-input-stt-guide.md` — STT 기술 가이드 + +--- + +## 📍 현재 진행 상태 + +| 항목 | 내용 | +|------|------| +| **마지막 완료 작업** | 기획 초안 작성 | +| **다음 작업** | Phase 1 상세 설계 → 구현 | +| **진행률** | 0/4 Phase (0%) | +| **마지막 업데이트** | 2026-03-02 | + +--- + +## 2. 시스템 아키텍처 + +### 2.1 전체 구조 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SAM AI 견적 엔진 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────┐ ┌───────────────────────┐ │ +│ │ 입력 채널 │ │ AI 분석 파이프라인 │ │ +│ │ │ │ │ │ +│ │ 📝 텍스트 │───→│ 1. 인터뷰 전처리 │ │ +│ │ 🎤 음성(P2) │ │ 2. 업무 도메인 분류 │ │ +│ │ 📄 문서(P3) │ │ 3. SAM 모듈 매핑 │ │ +│ └───────────────┘ │ 4. 견적 항목 추출 │ │ +│ │ 5. 금액 산출 │ │ +│ └──────────┬────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ 견적서 생성기 │ │ +│ │ │ │ +│ │ SAM 표준 모듈 카탈로그 ←──→ Claude API │ │ +│ │ 고객 요금 정책 ←──→ 프롬프트 엔진 │ │ +│ │ 기존 견적 템플릿 ←──→ 결과 파서 │ │ +│ └──────────────────────┬────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ 출력 │ │ +│ │ │ │ +│ │ 📊 견적서 초안 (quotes 테이블) │ │ +│ │ 📋 업무 분석 리포트 │ │ +│ │ 💡 추천 모듈 목록 │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Claude API 연동 구조 + +``` +┌──────────────────┐ ┌──────────────────┐ +│ SAM API Server │ │ Claude API │ +│ (Laravel) │ │ (Anthropic) │ +│ │ │ │ +│ AiQuotation │ ──HTTP──→ Messages API │ +│ Service │ ←─JSON── (claude-sonnet) │ +│ │ │ │ +│ ┌────────────┐ │ │ System Prompt: │ +│ │ Prompt │ │ │ - SAM 모듈 목록 │ +│ │ Engine │ │ │ - 요금 정책 │ +│ │ │ │ │ - 견적 구조 │ +│ │ - 모듈목록 │ │ │ - 출력 형식 │ +│ │ - 요금표 │ │ │ │ +│ │ - 템플릿 │ │ │ │ +│ └────────────┘ │ │ │ +└──────────────────┘ └──────────────────┘ +``` + +### 2.3 데이터 흐름 + +``` +매니저 인터뷰 입력 + │ + ▼ +┌──────────────────────────────────────────────────┐ +│ Step 1: 인터뷰 전처리 │ +│ - 텍스트 정규화 (불필요한 표현 제거) │ +│ - 핵심 키워드 추출 │ +│ - 업무 도메인 태깅 │ +└──────────────────────┬───────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────┐ +│ Step 2: Claude API 1차 호출 — 업무 분석 │ +│ - 고객사 업종/규모 파악 │ +│ - 현재 업무 프로세스 분석 │ +│ - 디지털화 필요 영역 식별 │ +│ - Pain Point 도출 │ +│ 출력: 구조화된 업무 분석 JSON │ +└──────────────────────┬───────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────┐ +│ Step 3: SAM 모듈 매핑 │ +│ - 업무 분석 결과 ↔ SAM 모듈 카탈로그 대조 │ +│ - 필수/선택 모듈 분류 │ +│ - 사용자 수, 데이터량 추정 │ +│ 출력: 추천 모듈 목록 + 근거 │ +└──────────────────────┬───────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────┐ +│ Step 4: Claude API 2차 호출 — 견적 생성 │ +│ - SAM 요금 정책 적용 │ +│ - 모듈별 개발비 + 월 구독료 계산 │ +│ - 추가 옵션 (AI 토큰, 저장공간) 산출 │ +│ - 할인 정책 적용 (통합 패키지 등) │ +│ 출력: 견적서 JSON (SAM 표준 형식) │ +└──────────────────────┬───────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────┐ +│ Step 5: 견적서 초안 저장 │ +│ - quotes 테이블에 저장 (status: draft) │ +│ - quote_items에 모듈별 항목 저장 │ +│ - 업무 분석 리포트 첨부 │ +│ - 매니저에게 알림 → 검토/수정 → 확정 │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 3. SAM 모듈 카탈로그 (AI 프롬프트용) + +> **참조**: `docs/rules/customer-pricing.md` + +AI가 인터뷰 내용을 SAM 견적으로 변환하려면, SAM이 제공하는 모듈과 요금을 정확히 알아야 한다. 이 데이터는 **DB 테이블로 관리**하여 프롬프트에 동적 주입한다. + +### 3.1 모듈 카탈로그 테이블 설계 + +```sql +CREATE TABLE ai_quotation_modules ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + module_code VARCHAR(50) NOT NULL, -- 'HR', 'SALES', 'FINANCE' 등 + module_name VARCHAR(100) NOT NULL, -- '인사관리', '영업관리', '재무관리' + category ENUM('basic', 'individual', 'addon') NOT NULL, + description TEXT, -- 모듈 기능 설명 + keywords JSON, -- AI 매핑용 키워드 목록 + dev_cost DECIMAL(12,0) DEFAULT 0, -- 개발비 (원) + monthly_fee DECIMAL(10,0) DEFAULT 0, -- 월 구독료 (원) + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_tenant (tenant_id), + INDEX idx_category (category), + UNIQUE KEY uk_tenant_module (tenant_id, module_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 3.2 초기 데이터 (customer-pricing.md 기준) + +| module_code | module_name | category | dev_cost | monthly_fee | +|-------------|-------------|----------|----------|-------------| +| `BASIC_PKG` | 기본 패키지 (인사+근태+급여+게시판) | basic | 5,000,000 | 200,000 | +| `HR` | 인사관리 | individual | 2,000,000 | 80,000 | +| `ATTENDANCE` | 근태관리 | individual | 1,500,000 | 60,000 | +| `PAYROLL` | 급여관리 | individual | 2,500,000 | 100,000 | +| `BOARD` | 게시판/공지사항 | individual | 500,000 | 20,000 | +| `SALES` | 영업관리 (CRM+견적+수주) | individual | 5,000,000 | 150,000 | +| `PURCHASE` | 구매/자재관리 | individual | 3,000,000 | 100,000 | +| `PRODUCTION` | 생산관리 (MES) | individual | 8,000,000 | 250,000 | +| `QUALITY` | 품질관리 | individual | 4,000,000 | 120,000 | +| `FINANCE` | 재무/회계관리 | individual | 5,000,000 | 150,000 | +| `LOGISTICS` | 물류/출하관리 | individual | 3,000,000 | 100,000 | +| `APPROVAL` | 전자결재 | individual | 3,000,000 | 80,000 | +| `DOCUMENT` | 문서관리 (전자서명) | individual | 2,000,000 | 60,000 | +| `EQUIPMENT` | 설비관리 | individual | 3,000,000 | 100,000 | +| `INTEGRATED` | 통합 패키지 | basic | 30,000,000 | 800,000 | +| `AI_TOKEN` | AI 토큰 추가 | addon | 0 | 별도 | +| `STORAGE` | 파일 저장공간 추가 | addon | 0 | 별도 | +| `CUSTOM_DEV` | 커스텀 개발 | addon | 별도 협의 | 0 | + +### 3.3 keywords 필드 예시 + +```json +// HR 모듈 +{ + "keywords": ["직원", "사원", "인사", "조직도", "부서", "입퇴사", "인력"], + "pain_points": ["엑셀로 직원 관리", "입퇴사 관리가 번거로움", "조직도 없음"], + "business_needs": ["직원 정보 통합", "조직 구조 관리", "인력 현황 파악"] +} + +// PRODUCTION 모듈 +{ + "keywords": ["생산", "제조", "작업지시", "공정", "LOT", "불량", "MES"], + "pain_points": ["생산 현황을 수기로 기록", "불량 추적 불가", "납기 관리 어려움"], + "business_needs": ["실시간 생산현황", "불량률 관리", "작업지시 자동화"] +} +``` + +--- + +## 4. AI 프롬프트 엔진 + +### 4.1 시스템 프롬프트 구조 + +``` +┌─────────────────────────────────────────────────────┐ +│ System Prompt │ +├─────────────────────────────────────────────────────┤ +│ │ +│ [역할 정의] │ +│ 너는 SAM ERP/MES 솔루션의 컨설팅 AI이다. │ +│ 고객 인터뷰를 분석하여 맞춤형 견적서를 작성한다. │ +│ │ +│ [SAM 모듈 카탈로그] ← DB에서 동적 로드 │ +│ 각 모듈의 기능, 키워드, 가격 정보 │ +│ │ +│ [요금 정책] ← customer-pricing 기반 │ +│ 기본패키지, 개별모듈, 추가옵션 요금 체계 │ +│ 할인 정책 (통합 패키지 할인 등) │ +│ │ +│ [출력 형식] │ +│ JSON Schema 명시 (견적서 구조) │ +│ │ +│ [분석 지침] │ +│ - 고객 업종/규모별 권장 모듈 기준 │ +│ - 우선순위 결정 기준 (필수 vs 선택) │ +│ - 비용 최적화 원칙 (패키지 vs 개별) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### 4.2 프롬프트 템플릿 (1차: 업무 분석) + +``` +당신은 SAM(Smart Automation Management) ERP/MES 솔루션의 전문 컨설턴트입니다. + +아래는 고객사 직원과의 인터뷰 내용입니다. 이를 분석하여 구조화된 업무 분석 보고서를 작성하세요. + +## 고객 정보 +- 회사명: {company_name} +- 업종: {industry} +- 직원 수: {employee_count} +- 인터뷰 대상: {interviewee_role} + +## 인터뷰 내용 +{interview_content} + +## 분석 기준 +다음 SAM 모듈 영역에 맞춰 분석하세요: +{module_catalog_json} + +## 출력 형식 (JSON) +{ + "company_analysis": { + "industry": "업종 분류", + "scale": "소규모/중소/중견", + "current_systems": ["현재 사용 중인 시스템"], + "digitalization_level": "상/중/하" + }, + "business_domains": [ + { + "domain": "인사/급여", + "current_process": "현재 처리 방식 설명", + "pain_points": ["문제점 1", "문제점 2"], + "improvement_needs": ["개선 필요사항"], + "priority": "필수/높음/보통/낮음", + "matched_modules": ["HR", "PAYROLL"] + } + ], + "recommendations": { + "essential_modules": ["반드시 필요한 모듈 코드"], + "recommended_modules": ["권장 모듈 코드"], + "optional_modules": ["선택 모듈 코드"], + "package_suggestion": "BASIC_PKG 또는 INTEGRATED 또는 individual", + "reasoning": "패키지 추천 근거" + } +} +``` + +### 4.3 프롬프트 템플릿 (2차: 견적 생성) + +``` +아래 업무 분석 결과를 바탕으로 SAM 견적서를 생성하세요. + +## 업무 분석 결과 +{analysis_result_json} + +## SAM 요금 정책 +{pricing_policy_json} + +## 견적 생성 규칙 +1. 필수 모듈은 반드시 포함 +2. 통합 패키지가 개별 합산보다 저렴하면 패키지 추천 +3. 직원 수 기반 사용자 라이선스 산출 +4. AI 토큰은 월 기본 100만 토큰 포함, 초과분 별도 +5. 파일 저장공간은 기본 10GB, 초과분 별도 + +## 출력 형식 (JSON) +{ + "quotation": { + "title": "견적서 제목", + "client_name": "고객사명", + "valid_until": "견적 유효기간", + "items": [ + { + "category": "기본서비스/추가모듈/추가옵션", + "module_code": "모듈코드", + "module_name": "모듈명", + "description": "포함 기능 설명", + "dev_cost": 0, + "monthly_fee": 0, + "quantity": 1, + "note": "비고" + } + ], + "summary": { + "total_dev_cost": 0, + "total_monthly_fee": 0, + "discount_type": "패키지할인/볼륨할인/없음", + "discount_rate": 0, + "final_dev_cost": 0, + "final_monthly_fee": 0 + }, + "implementation_plan": { + "estimated_months": 0, + "phases": [ + { + "phase": 1, + "name": "단계명", + "modules": ["모듈코드"], + "duration_weeks": 0 + } + ] + }, + "analysis_summary": "업무 분석 요약 (고객 설명용)" + } +} +``` + +--- + +## 5. API 설계 + +### 5.1 엔드포인트 + +| Method | Path | 설명 | +|--------|------|------| +| `POST` | `/api/v1/ai/quotation/analyze` | 인터뷰 분석 (1차) | +| `POST` | `/api/v1/ai/quotation/generate` | 견적서 생성 (2차) | +| `POST` | `/api/v1/ai/quotation/generate-full` | 분석+생성 통합 (원스텝) | +| `GET` | `/api/v1/ai/quotation/{id}` | AI 견적 상세 조회 | +| `GET` | `/api/v1/ai/quotation` | AI 견적 목록 | +| `PUT` | `/api/v1/ai/quotation/{id}` | AI 견적 수정 (매니저) | +| `POST` | `/api/v1/ai/quotation/{id}/confirm` | 정식 견적으로 전환 | +| `DELETE` | `/api/v1/ai/quotation/{id}` | AI 견적 삭제 | + +### 5.2 요청/응답 예시 + +#### POST `/api/v1/ai/quotation/analyze` + +**Request:** +```json +{ + "client_id": 15, + "client_name": "(주)대한기계", + "industry": "기계제조업", + "employee_count": 45, + "interviewee_role": "관리부 팀장", + "interview_content": "현재 직원 관리는 엑셀로 하고 있어요. 출퇴근도 수기로 기록하고... 영업팀에서는 견적서를 한글 프로그램으로 만들어서 이메일로 보내는데, 이력 관리가 안 돼요. 생산 현장에서는 작업일보를 종이에 쓰고 있고, 불량이 나면 어디서 발생했는지 추적이 안 됩니다. 재고도 실사를 해봐야 알 수 있어요...", + "interview_type": "text" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": 1, + "analysis": { + "company_analysis": { + "industry": "기계제조업", + "scale": "중소기업 (45명)", + "current_systems": ["엑셀", "한글 프로그램", "종이 문서"], + "digitalization_level": "하" + }, + "business_domains": [ + { + "domain": "인사/급여", + "current_process": "엑셀로 직원 관리, 수기 출퇴근 기록", + "pain_points": ["인사정보 분산", "출퇴근 수기 기록"], + "priority": "필수", + "matched_modules": ["HR", "ATTENDANCE", "PAYROLL"] + }, + { + "domain": "영업관리", + "current_process": "한글 프로그램 견적서, 이메일 발송", + "pain_points": ["견적 이력 관리 불가", "영업 현황 파악 어려움"], + "priority": "높음", + "matched_modules": ["SALES"] + }, + { + "domain": "생산관리", + "current_process": "종이 작업일보, 수동 불량 관리", + "pain_points": ["불량 추적 불가", "생산현황 실시간 파악 불가"], + "priority": "필수", + "matched_modules": ["PRODUCTION", "QUALITY"] + }, + { + "domain": "재고/물류", + "current_process": "실사로만 재고 파악", + "pain_points": ["실시간 재고 파악 불가"], + "priority": "높음", + "matched_modules": ["PURCHASE", "LOGISTICS"] + } + ], + "recommendations": { + "essential_modules": ["HR", "ATTENDANCE", "PAYROLL", "PRODUCTION", "QUALITY"], + "recommended_modules": ["SALES", "PURCHASE", "LOGISTICS"], + "optional_modules": ["APPROVAL", "DOCUMENT"], + "package_suggestion": "INTEGRATED", + "reasoning": "8개 이상 모듈이 필요하므로 통합 패키지(30,000,000원)가 개별 합산(34,500,000원)보다 경제적" + } + }, + "token_usage": { + "prompt_tokens": 1250, + "completion_tokens": 890, + "cost_krw": 45 + } + } +} +``` + +### 5.3 컨트롤러 / 서비스 구조 + +``` +app/Http/Controllers/Api/V1/ +└── AiQuotationController.php + ├── analyze() ← 인터뷰 분석 + ├── generate() ← 견적 생성 + ├── generateFull() ← 통합 (분석+생성) + ├── index() ← 목록 + ├── show() ← 상세 + ├── update() ← 수정 + ├── confirm() ← 정식 견적 전환 + └── destroy() ← 삭제 + +app/Services/ +└── AiQuotationService.php + ├── analyzeInterview() ← 1차: 인터뷰 분석 + ├── generateQuotation() ← 2차: 견적 생성 + ├── generateFull() ← 통합 처리 + ├── confirmToQuote() ← quotes 테이블로 전환 + │ + ├── buildAnalysisPrompt() ← 분석 프롬프트 조립 + ├── buildQuotationPrompt() ← 견적 프롬프트 조립 + ├── loadModuleCatalog() ← DB에서 모듈 카탈로그 로드 + ├── loadPricingPolicy() ← DB에서 요금 정책 로드 + ├── callClaudeApi() ← Claude API 호출 + ├── parseResponse() ← 응답 JSON 파싱 + └── saveTokenUsage() ← 토큰 사용량 기록 + +app/Http/Requests/V1/AiQuotation/ +├── AiQuotationAnalyzeRequest.php +├── AiQuotationGenerateRequest.php +├── AiQuotationUpdateRequest.php +└── AiQuotationConfirmRequest.php +``` + +--- + +## 6. 데이터베이스 설계 + +### 6.1 신규 테이블 + +#### ai_quotations (AI 견적 마스터) + +```sql +CREATE TABLE ai_quotations ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + + -- 고객 정보 + client_id BIGINT UNSIGNED NULL, + client_name VARCHAR(200) NOT NULL, + industry VARCHAR(100) NULL, + employee_count INT NULL, + interviewee_role VARCHAR(100) NULL, + + -- 인터뷰 데이터 + interview_content TEXT NOT NULL, + interview_type ENUM('text', 'voice', 'document') DEFAULT 'text', + voice_recording_id BIGINT UNSIGNED NULL, -- ai_voice_recordings 참조 + + -- AI 분석 결과 + analysis_result JSON NULL, -- 1차 분석 결과 + quotation_result JSON NULL, -- 2차 견적 결과 + + -- 상태 관리 + status ENUM('analyzing', 'analyzed', 'generating', 'generated', + 'confirmed', 'failed') DEFAULT 'analyzing', + error_message TEXT NULL, + + -- 연결 + quote_id BIGINT UNSIGNED NULL, -- 정식 견적 전환 시 quotes.id + + -- 금액 요약 + total_dev_cost DECIMAL(12,0) DEFAULT 0, + total_monthly_fee DECIMAL(10,0) DEFAULT 0, + discount_rate DECIMAL(5,2) DEFAULT 0, + final_dev_cost DECIMAL(12,0) DEFAULT 0, + final_monthly_fee DECIMAL(10,0) DEFAULT 0, + + -- 토큰 사용 + total_tokens INT DEFAULT 0, + total_cost_krw DECIMAL(12,2) DEFAULT 0, + + -- 감사 + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_status (tenant_id, status), + INDEX idx_tenant_client (tenant_id, client_id), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### ai_quotation_items (AI 견적 항목) + +```sql +CREATE TABLE ai_quotation_items ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ai_quotation_id BIGINT UNSIGNED NOT NULL, + + category ENUM('basic', 'module', 'addon') NOT NULL, + module_code VARCHAR(50) NOT NULL, + module_name VARCHAR(100) NOT NULL, + description TEXT NULL, + + dev_cost DECIMAL(12,0) DEFAULT 0, + monthly_fee DECIMAL(10,0) DEFAULT 0, + quantity INT DEFAULT 1, + priority ENUM('essential', 'recommended', 'optional') DEFAULT 'recommended', + + ai_reasoning TEXT NULL, -- AI가 이 모듈을 추천한 근거 + matched_pain_points JSON NULL, -- 매칭된 고객 Pain Point + + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_quotation (ai_quotation_id), + FOREIGN KEY (ai_quotation_id) REFERENCES ai_quotations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 6.2 기존 테이블 활용 + +| 테이블 | 용도 | 연동 방식 | +|--------|------|----------| +| `ai_configs` | Claude API 키/모델 설정 | provider='claude' 조회 | +| `ai_token_usages` | 토큰 비용 추적 | menu_name='AI견적' | +| `ai_pricing_configs` | Claude 모델 단가 | provider='claude' | +| `quotes` | 정식 견적 전환 대상 | `confirm` 시 생성 | +| `clients` | 고객사 정보 | client_id 참조 | + +--- + +## 7. Phase별 개발 계획 + +### Phase 1: 텍스트 인터뷰 → AI 견적 (MVP) + +> **목표**: 텍스트 인터뷰 입력 → Claude 분석 → 견적서 자동생성 +> **기간**: 2~3주 +> **우선순위**: 🔴 필수 + +| 단계 | 작업 | 프로젝트 | 상태 | +|------|------|---------|------| +| 1-1 | `ai_quotation_modules` 마이그레이션 + 시더 | API | ⏳ | +| 1-2 | `ai_quotations`, `ai_quotation_items` 마이그레이션 | API | ⏳ | +| 1-3 | 모델 생성 (`AiQuotation`, `AiQuotationItem`, `AiQuotationModule`) | API | ⏳ | +| 1-4 | `AiQuotationService` — Claude API 연동 | API | ⏳ | +| 1-5 | 프롬프트 엔진 구현 (분석 + 견적 템플릿) | API | ⏳ | +| 1-6 | `AiQuotationController` + FormRequest | API | ⏳ | +| 1-7 | API 라우트 등록 (`routes/api/v1/common.php`) | API | ⏳ | +| 1-8 | 정식 견적 전환 로직 (`confirmToQuote`) | API | ⏳ | +| 1-9 | MNG AI 견적 관리 화면 (목록/상세/수정) | MNG | ⏳ | +| 1-10 | 테스트 (인터뷰 샘플 3건 이상) | API | ⏳ | + +### Phase 2: 음성 인터뷰 연동 + +> **목표**: 현장에서 녹음한 음성 → STT 변환 → AI 분석 → 견적 생성 +> **기간**: 1주 (기존 STT 인프라 재사용으로 단축) +> **선행 조건**: Phase 1 완료 + +| 단계 | 작업 | 프로젝트 | 상태 | 비고 | +|------|------|---------|------|------| +| 2-1 | Google STT 서비스 구현 | MNG | ✅ 완료 | `GoogleCloudService::speechToText()` 재사용 | +| 2-2 | 음성 업로드 API (GCS 저장) | MNG | ✅ 완료 | `AiVoiceRecordingService`, `ConsultationController` 재사용 | +| 2-3 | STT → 텍스트 변환 파이프라인 | MNG | ✅ 완료 | LongRunningRecognize + 폴링 패턴 구현됨 | +| 2-4 | 음성 인터뷰 → AI 견적 통합 플로우 | API | ⏳ | 기존 STT 결과를 Claude 분석에 연결 | +| 2-5 | MNG 음성 녹음 업로드 UI | MNG | ✅ 완료 | `voice-recorder.blade.php` 재사용 가능 | + +> **핵심**: Google Cloud STT, GCS, 브라우저 음성 녹음 UI가 모두 구현 완료 상태. +> Phase 2에서는 기존 STT 결과를 AI 견적 파이프라인(Claude API)에 연결하는 **통합 플로우(2-4)만 신규 개발**하면 된다. + +### Phase 3: 학습 데이터 고도화 + +> **목표**: 과거 견적 데이터를 활용하여 AI 정확도 향상 +> **기간**: 2주 +> **선행 조건**: Phase 1 + 실제 사용 데이터 축적 + +| 단계 | 작업 | 프로젝트 | 상태 | +|------|------|---------|------| +| 3-1 | 과거 견적 데이터 → 프롬프트 Few-shot 예시 구성 | API | ⏳ | +| 3-2 | 확정된 AI 견적 → 학습 데이터 피드백 루프 | API | ⏳ | +| 3-3 | 업종별 견적 패턴 분석 → 추천 정확도 향상 | API | ⏳ | +| 3-4 | 프롬프트 A/B 테스트 프레임워크 | API | ⏳ | + +### Phase 4: 고객 셀프서비스 (확장) + +> **목표**: 고객이 직접 간단한 질문에 답변하면 견적 자동 생성 +> **기간**: 3주 +> **선행 조건**: Phase 1~3 안정화 + +| 단계 | 작업 | 프로젝트 | 상태 | +|------|------|---------|------| +| 4-1 | 고객용 인터뷰 설문 폼 설계 | React | ⏳ | +| 4-2 | 단계별 질문 → AI 분석 통합 | API + React | ⏳ | +| 4-3 | 견적서 미리보기 + PDF 다운로드 | React | ⏳ | +| 4-4 | 매니저 알림 → 후속 상담 연결 | API + MNG | ⏳ | + +--- + +## 8. Claude API 연동 상세 + +### 8.1 SDK 설치 + +```bash +# API 프로젝트에 Anthropic SDK 설치 +docker exec sam-api-1 composer require anthropic-ai/laravel +``` + +> **참고**: `anthropic-ai/laravel` 패키지는 Laravel용 공식 래퍼로, HTTP Client 기반으로 Claude API를 호출한다. 미출시/미지원 시 `GuzzleHttp`로 직접 HTTP 호출한다. + +### 8.2 Claude API 호출 패턴 + +```php +// app/Services/AiQuotationService.php + +class AiQuotationService +{ + private function callClaudeApi(string $systemPrompt, string $userMessage): array + { + // 1. ai_configs에서 Claude 설정 로드 + $config = AiConfig::where('provider', 'claude') + ->where('is_active', true) + ->first(); + + // 2. HTTP 호출 + $response = Http::withHeaders([ + 'x-api-key' => $config->api_key, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ])->post($config->base_url . '/messages', [ + 'model' => $config->model, // claude-sonnet-4-20250514 + 'max_tokens' => 4096, + 'temperature' => 0.3, // 견적은 일관성 중요 + 'system' => $systemPrompt, + 'messages' => [ + ['role' => 'user', 'content' => $userMessage] + ], + ]); + + // 3. 토큰 사용량 기록 + $usage = $response->json('usage'); + AiTokenHelper::saveClaudeUsage( + tenantId: auth()->user()->tenant_id, + menuName: 'AI견적', + promptTokens: $usage['input_tokens'], + completionTokens: $usage['output_tokens'], + model: $config->model, + ); + + // 4. 응답 파싱 + $content = $response->json('content.0.text'); + return json_decode($content, true); + } +} +``` + +### 8.3 비용 예측 + +| 항목 | 토큰 | 비용 (USD) | 비용 (KRW) | +|------|------|-----------|------------| +| 1차 분석 프롬프트 (시스템+사용자) | ~2,000 입력 | $0.0005 | ~1원 | +| 1차 분석 응답 | ~1,500 출력 | $0.0019 | ~3원 | +| 2차 견적 프롬프트 | ~3,000 입력 | $0.0008 | ~1원 | +| 2차 견적 응답 | ~2,000 출력 | $0.0025 | ~4원 | +| **견적 1건 합계** | **~8,500** | **~$0.006** | **~9원** | + +> Claude Sonnet 기준. 1건당 약 **9원**으로 매우 경제적이다. + +--- + +## 9. MNG 관리 화면 + +### 9.1 화면 목록 + +| 화면 | URL | 설명 | +|------|-----|------| +| AI 견적 목록 | `/ai-quotation` | 생성된 AI 견적 목록 | +| AI 견적 생성 | `/ai-quotation/create` | 인터뷰 입력 폼 | +| AI 견적 상세 | `/ai-quotation/{id}` | 분석 결과 + 견적서 조회 | +| AI 견적 수정 | `/ai-quotation/{id}/edit` | 매니저가 수정 | +| 모듈 카탈로그 관리 | `/ai-quotation/modules` | SAM 모듈 목록 관리 | + +### 9.2 AI 견적 생성 화면 구성 + +``` +┌─────────────────────────────────────────────────────┐ +│ AI 견적서 생성 │ +├─────────────────────────────────────────────────────┤ +│ │ +│ [고객 정보] │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ 고객사 선택 ▼│ │ 업종 │ │ +│ └─────────────┘ └─────────────┘ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ 직원 수 │ │ 인터뷰 대상 │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +│ [인터뷰 내용] │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ (텍스트 입력 또는 음성 녹음 업로드) │ │ +│ │ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [🔄 분석 시작] [📊 분석+견적 한번에] │ +│ │ +├─────────────────────────────────────────────────────┤ +│ [분석 결과] (Ajax 응답) │ +│ │ +│ 📊 업무 도메인 분석 │ +│ ┌──────────┬──────────┬──────────┬──────────┐ │ +│ │ 도메인 │ 현재상태 │ 문제점 │ 추천모듈 │ │ +│ ├──────────┼──────────┼──────────┼──────────┤ │ +│ │ 인사/급여 │ 엑셀관리 │ 분산관리 │ HR,PAYROLL│ │ +│ │ 생산관리 │ 종이기록 │ 추적불가 │PRODUCTION │ │ +│ └──────────┴──────────┴──────────┴──────────┘ │ +│ │ +│ 💰 견적서 초안 │ +│ ┌──────────┬──────────┬──────────┬──────────┐ │ +│ │ 모듈 │ 개발비 │ 월구독료 │ 우선순위 │ │ +│ ├──────────┼──────────┼──────────┼──────────┤ │ +│ │ 통합패키지 │30,000,000│ 800,000 │ 필수 │ │ +│ │ AI토큰 │ 0│ 50,000 │ 선택 │ │ +│ └──────────┴──────────┴──────────┴──────────┘ │ +│ 합계: 개발비 30,000,000원 / 월 850,000원 │ +│ │ +│ [✅ 정식 견적으로 전환] [✏️ 수정] [🗑️ 삭제] │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 10. 보안 및 운영 + +### 10.1 보안 고려사항 + +| 항목 | 대책 | +|------|------| +| API 키 노출 | `ai_configs` 테이블에 암호화 저장, .env 폴백 | +| 인터뷰 데이터 보호 | tenant_id 격리, 접근 권한 제어 | +| Claude API 비용 제어 | 일일/월별 토큰 한도 설정 (ai_configs.options) | +| 프롬프트 인젝션 | 사용자 입력 sanitize, 시스템 프롬프트 분리 | + +### 10.2 모니터링 + +| 항목 | 방법 | +|------|------| +| API 호출 성공/실패 | `ai_quotations.status` + `error_message` | +| 토큰 사용량 | `ai_token_usages` 테이블 (기존 인프라) | +| 비용 추적 | MNG `/system/ai-token-usage` (기존 UI) | +| 견적 전환율 | `ai_quotations` → `quotes` 전환 비율 통계 | + +### 10.3 에러 처리 + +```php +try { + $result = $this->callClaudeApi($systemPrompt, $userMessage); +} catch (ConnectionException $e) { + // Claude API 연결 실패 + $aiQuotation->update(['status' => 'failed', 'error_message' => 'Claude API 연결 실패']); +} catch (JsonException $e) { + // 응답 JSON 파싱 실패 + $aiQuotation->update(['status' => 'failed', 'error_message' => 'AI 응답 파싱 실패']); +} +``` + +--- + +## 11. 기대 효과 + +| 항목 | Before (현재) | After (AI 엔진) | +|------|--------------|-----------------| +| 견적 작성 시간 | 2~4시간 (수동) | 5~10분 (AI 초안 + 검토) | +| 모듈 누락 위험 | 매니저 경험 의존 | AI가 체계적으로 분석 | +| 고객 맞춤화 | 표준 템플릿 복사 | 인터뷰 기반 맞춤 견적 | +| 비용 최적화 | 수동 비교 | AI가 패키지 vs 개별 자동 비교 | +| 견적 1건 AI 비용 | — | ~9원 (Claude Sonnet) | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-02 | 기획 초안 작성 | + +--- + +## 관련 문서 + +| 문서 | 경로 | +|------|------| +| SAM 프로젝트 개요 | `docs/SAM_PROJECT_OVERVIEW_FOR_AI.md` | +| 견적 기능 상세 | `docs/features/quotes/README.md` | +| 견적 시스템 분석 | `docs/data/견적/견적시스템_분석문서.md` | +| AI 기능 현황 | `docs/features/ai/README.md` | +| AI 설정 가이드 | `docs/guides/ai-config-settings.md` | +| 고객 요금 안내 | `docs/rules/customer-pricing.md` | +| 내부 과금 정책 | `docs/rules/billing-policy.md` | +| 단가 정책 | `docs/rules/pricing-policy.md` | +| Plans 가이드 | `docs/plans/GUIDE.md` | + +--- + +**최종 업데이트**: 2026-03-02 diff --git a/plans/attendance-management-plan.md b/plans/attendance-management-plan.md new file mode 100644 index 0000000..c5f8889 --- /dev/null +++ b/plans/attendance-management-plan.md @@ -0,0 +1,284 @@ +# MNG 근태현황 개발 계획서 + +> **작성일**: 2026-02-26 +> **상태**: 계획 수립 + +--- + +## 1. 개요 + +### 1.1 목적 + +MNG 인사관리 > 근태현황 기능을 완성한다. 현재 기본 CRUD가 구현되어 있으나, 미완성 기능과 알려진 버그를 해결하고 실무에 필요한 추가 기능을 구현한다. + +### 1.2 현재 상태 분석 + +#### 구현 완료 + +| 항목 | 상태 | 파일 | +|------|------|------| +| 근태 목록 조회 (HTMX 테이블) | ✅ | `index.blade.php`, `table.blade.php` | +| 월간 통계 카드 (5종) | ✅ | `index.blade.php` | +| 필터 (이름, 부서, 상태, 날짜) | ✅ | `index.blade.php` | +| 등록/수정 모달 | ✅ | `index.blade.php` | +| CRUD API (목록/등록/수정/삭제) | ✅ | `AttendanceController.php` (API) | +| AttendanceService | ✅ | `AttendanceService.php` | +| Attendance 모델 (8개 상태) | ✅ | `Attendance.php` | +| Soft Delete | ✅ | 모델 + 서비스 | + +#### 알려진 문제 (E2E 테스트 결과) + +| 문제 | 심각도 | 설명 | +|------|--------|------| +| 엑셀 다운로드 미구현 | 🟡 중요 | 버튼 없음, API 미연결 | +| 근태 등록 서버 에러 | 🔴 필수 | 모달 submit 시 500 에러 발생 가능 | + +#### 미구현 기능 (API 대비) + +| 기능 | API 지원 | MNG 상태 | +|------|---------|---------| +| 엑셀 내보내기 | ✅ `/v1/attendances/export` | ❌ 미구현 | +| 일괄 삭제 | ✅ `/v1/attendances/bulk-delete` | ❌ 미구현 | +| 개인별 근태 상세 | ✅ `/v1/attendances/{id}` | ❌ 미구현 | +| 월간 요약 통계 | ✅ `/v1/attendances/monthly-stats` | ⚠️ 기본만 구현 | +| 출퇴근 설정 관리 | ✅ `attendance_settings` 테이블 | ❌ 미구현 | + +--- + +## 2. 구현 범위 + +### 2.1 Phase 1: 버그 수정 + 핵심 기능 (우선) + +| # | 작업 | 난이도 | 설명 | +|---|------|--------|------| +| 1-1 | 근태 등록/수정 버그 수정 | 🟢 낮음 | store/update API 요청 오류 점검 및 수정 | +| 1-2 | 엑셀 다운로드 | 🟢 낮음 | API `/v1/attendances/export` 연동, 다운로드 버튼 추가 | +| 1-3 | 일괄 삭제 | 🟡 보통 | 체크박스 선택 → 일괄 삭제 버튼 | +| 1-4 | 월간 통계 기간 선택 | 🟢 낮음 | 현재 당월 고정 → 연/월 선택 가능하게 | + +### 2.2 Phase 2: 확장 기능 + +| # | 작업 | 난이도 | 설명 | +|---|------|--------|------| +| 2-1 | 개인별 근태 상세 페이지 | 🟡 보통 | 사원 클릭 → 월간 달력 + 출퇴근 이력 | +| 2-2 | 출퇴근 설정 관리 | 🟡 보통 | 표준 출근시간, GPS 사용여부, 허용반경 설정 | +| 2-3 | 월간/주간 요약 뷰 | 🟡 보통 | 부서별/사원별 근태 요약 테이블 | +| 2-4 | 근태 일괄 등록 | 🔴 높음 | 날짜 범위 + 대상 사원 → 일괄 근태 등록 | + +--- + +## 3. 상세 설계 + +### 3.1 Phase 1-1: 근태 등록/수정 버그 수정 + +**점검 항목**: +- MNG `AttendanceController::store()` validation 규칙과 실제 폼 데이터 일치 여부 +- `check_in`, `check_out` 포맷 (HH:MM vs HH:MM:SS) 불일치 가능성 +- `user_id` 전달 누락 여부 +- HTMX `hx-headers` CSRF 토큰 전달 확인 + +**수정 대상 파일**: +- `mng/app/Http/Controllers/Api/Admin/HR/AttendanceController.php` +- `mng/resources/views/hr/attendances/index.blade.php` (JS `submitAttendance()`) + +--- + +### 3.2 Phase 1-2: 엑셀 다운로드 + +**방식**: MNG에서 직접 엑셀 생성 (API 서버 미경유) + +**구현**: +1. `AttendanceService::getExportData()` 메서드 추가 +2. `AttendanceController::export()` 메서드 추가 +3. 라우트: `GET /api/admin/hr/attendances/export` +4. 인덱스 페이지에 다운로드 버튼 추가 + +**엑셀 컬럼**: + +| 컬럼 | 값 | +|------|-----| +| 날짜 | `base_date` | +| 사원명 | `user.name` | +| 부서 | `department.name` | +| 상태 | `status_label` | +| 출근 | `check_in` | +| 퇴근 | `check_out` | +| 비고 | `remarks` | + +**수정 대상 파일**: +- `mng/app/Services/HR/AttendanceService.php` +- `mng/app/Http/Controllers/Api/Admin/HR/AttendanceController.php` +- `mng/routes/api.php` +- `mng/resources/views/hr/attendances/index.blade.php` + +--- + +### 3.3 Phase 1-3: 일괄 삭제 + +**UI**: 테이블 각 행에 체크박스 추가, 헤더에 전체선택, 상단에 "선택 삭제" 버튼 + +**구현**: +1. `table.blade.php`에 체크박스 컬럼 추가 +2. Alpine.js 컴포넌트로 선택 상태 관리 +3. `AttendanceController::bulkDestroy()` 메서드 추가 +4. 라우트: `POST /api/admin/hr/attendances/bulk-delete` + +**수정 대상 파일**: +- `mng/resources/views/hr/attendances/partials/table.blade.php` +- `mng/resources/views/hr/attendances/index.blade.php` +- `mng/app/Http/Controllers/Api/Admin/HR/AttendanceController.php` +- `mng/app/Services/HR/AttendanceService.php` +- `mng/routes/api.php` + +--- + +### 3.4 Phase 1-4: 월간 통계 기간 선택 + +**현재**: 당월 통계만 표시 (하드코딩) +**변경**: 연/월 드롭다운 추가 → 선택 시 통계 카드 HTMX 갱신 + +**구현**: +1. 통계 카드 영역을 별도 partial로 분리 (`partials/stats.blade.php`) +2. 연/월 선택 UI 추가 +3. `hx-get` + `hx-vals`로 선택된 연/월 전달 +4. `stats()` API가 `year`, `month` 파라미터 이미 지원 + +**수정 대상 파일**: +- `mng/resources/views/hr/attendances/index.blade.php` +- `mng/resources/views/hr/attendances/partials/stats.blade.php` (신규) + +--- + +### 3.5 Phase 2-1: 개인별 근태 상세 페이지 + +**URL**: `/hr/attendances/{userId}` + +**페이지 구성**: +1. **사원 프로필 카드**: 이름, 부서, 직급, 재직상태 +2. **월간 달력**: 날짜별 근태 상태를 색상 도트로 표시 +3. **월간 통계**: 정시출근 N일, 지각 N일, 결근 N일 등 +4. **출퇴근 이력 테이블**: 해당 월의 상세 출퇴근 기록 + +**수정 대상 파일**: +- `mng/routes/web.php` (라우트 추가) +- `mng/app/Http/Controllers/HR/AttendanceController.php` (`show()` 추가) +- `mng/app/Services/HR/AttendanceService.php` (`getUserMonthlyAttendances()` 추가) +- `mng/resources/views/hr/attendances/show.blade.php` (신규) + +--- + +### 3.6 Phase 2-2: 출퇴근 설정 관리 + +**URL**: `/hr/attendance-settings` + +**설정 항목** (`attendance_settings` 테이블 기반): + +| 항목 | 필드 | 설명 | +|------|------|------| +| GPS 출퇴근 사용 | `use_gps` | on/off 토글 | +| 자동 출퇴근 | `use_auto` | on/off 토글 | +| 허용 반경 | `allowed_radius` | 미터 단위 입력 | +| 본사 주소 | `hq_address` | 주소 입력 | +| 본사 위도/경도 | `hq_latitude`, `hq_longitude` | 좌표 입력 | + +**수정 대상 파일**: +- `mng/routes/web.php`, `mng/routes/api.php` +- `mng/app/Http/Controllers/HR/AttendanceSettingController.php` (신규) +- `mng/app/Models/HR/AttendanceSetting.php` (신규 — API 모델 미러링) +- `mng/resources/views/hr/attendance-settings/index.blade.php` (신규) + +--- + +## 4. 데이터 흐름 + +### 4.1 MNG 자체 CRUD 패턴 (현재) + +``` +브라우저 ──HTMX──→ MNG API Controller ──→ MNG Service ──→ DB (직접) + (api/admin/hr/attendances) +``` + +> MNG는 API 서버를 경유하지 않고 DB에 직접 접근한다. + +### 4.2 엑셀 다운로드 흐름 + +``` +브라우저 ──GET──→ MNG AttendanceController::export() + → AttendanceService::getExportData() + → ExportService::download() (라라벨 엑셀) + ← BinaryFileResponse (.xlsx) +``` + +--- + +## 5. 구현 순서 및 의존 관계 + +``` +Phase 1 (버그 수정 + 핵심) + 1-1 버그 수정 ─────────────────────────────┐ + 1-2 엑셀 다운로드 ─────────────────────────┤ 독립적, 병렬 가능 + 1-3 일괄 삭제 ─────────────────────────────┤ + 1-4 통계 기간 선택 ────────────────────────┘ + +Phase 2 (확장) + 2-1 개인별 상세 ───→ 2-3 월간/주간 요약 (데이터 재사용) + 2-2 출퇴근 설정 ─── 독립적 + 2-4 일괄 등록 ───── 독립적 +``` + +--- + +## 6. 관련 파일 목록 + +### MNG 프로젝트 (`/home/aweso/sam/mng`) + +| 파일 | 역할 | +|------|------| +| `app/Models/HR/Attendance.php` | 모델 (8개 상태, json_details) | +| `app/Services/HR/AttendanceService.php` | 비즈니스 로직 | +| `app/Http/Controllers/HR/AttendanceController.php` | 뷰 컨트롤러 | +| `app/Http/Controllers/Api/Admin/HR/AttendanceController.php` | API 컨트롤러 | +| `resources/views/hr/attendances/index.blade.php` | 메인 페이지 | +| `resources/views/hr/attendances/partials/table.blade.php` | 테이블 partial | +| `routes/web.php` | 웹 라우트 | +| `routes/api.php` | API 라우트 | + +### API 프로젝트 (`/home/aweso/sam/api`) + +| 파일 | 역할 | +|------|------| +| `database/migrations/2025_12_09_*_attendances*` | 마이그레이션 (2개) | +| `database/migrations/2025_12_17_*_attendance_settings*` | 설정 테이블 | +| `app/Models/Tenants/Attendance.php` | API 모델 (참조용) | +| `app/Models/Tenants/AttendanceSetting.php` | 설정 모델 (참조용) | + +### 참조 문서 + +| 문서 | 경로 | +|------|------| +| 근태 API 규칙 | `docs/rules/attendance-api.md` | +| GPS 출퇴근 스펙 | `docs/specs/erp-analysis/03-gps-attendance.md` | + +--- + +## 7. 검증 방법 + +### Phase 1 체크리스트 + +- [ ] 근태 등록 모달 → 사원 선택 + 날짜 + 상태 입력 → 저장 성공 +- [ ] 근태 수정 모달 → 기존 데이터 로드 → 수정 → 저장 성공 +- [ ] 동일 사원/날짜 중복 등록 시 기존 데이터 업데이트 (Upsert) +- [ ] 엑셀 다운로드 버튼 클릭 → .xlsx 파일 다운로드 +- [ ] 체크박스 선택 → 일괄 삭제 → 테이블 갱신 +- [ ] 연/월 선택 → 통계 카드 갱신 + +### Phase 2 체크리스트 + +- [ ] 사원 이름 클릭 → 개인별 상세 페이지 이동 +- [ ] 달력에 근태 상태 색상 표시 +- [ ] 출퇴근 설정 페이지 → GPS/자동 토글 → 저장 +- [ ] 허용 반경 변경 → 저장 → DB 반영 + +--- + +**최종 업데이트**: 2026-02-26 diff --git a/plans/block-builder-evolution-plan.md b/plans/block-builder-evolution-plan.md new file mode 100644 index 0000000..57385f3 --- /dev/null +++ b/plans/block-builder-evolution-plan.md @@ -0,0 +1,706 @@ +# 양식 디자이너(Block Builder) 고도화 계획 + +> **작성일**: 2026-03-06 +> **상태**: 계획 수립 +> **담당**: Claude Code + 개발팀 +> **관련**: [문서양식관리](../features/documents/mng-document-template.md) | [문서관리](../features/documents/mng-document-system.md) + +--- + +## 1. 현재 상태 진단 + +### 1.1 구현 완료 (Phase 1 — 2026-02-28) + +- 13개 블록 타입 (기본 6 + 폼 7) +- 3패널 UI (팔레트 / 캔버스 / 속성) +- SortableJS 드래그-앤-드롭 정렬 +- Undo/Redo (최대 50단계) +- JSON 스키마 저장 (`document_templates.schema`) +- 페이지 설정 (A4/A3/B5, 세로/가로, 여백) + +### 1.2 핵심 미구현 사항 + +| 기능 | 상태 | 영향도 | +|------|------|--------| +| 문서 생성 시 블록 렌더링 | 미구현 | 블록 서식으로 문서 작성 불가 | +| 결재선 블록 | 미구현 | 결재 워크플로우 연동 불가 | +| 데이터 바인딩 (EAV 연동) | 미구현 | 입력값 저장/로드 불가 | +| 동적 행 추가 | 미구현 | 검사 데이터 행 추가 불가 | +| 변수/매크로 시스템 | 미구현 | 자동 값 주입 불가 | +| 인쇄/PDF 출력 | 미구현 | 블록 문서 인쇄 불가 | +| Columns 내부 블록 | 미구현 | 다단 레이아웃 활용 불가 | + +> **결론**: 양식 디자이너는 **레이아웃 편집기**로만 동작. 실제 문서 생성/결재/인쇄에서는 Legacy Builder만 사용 가능. + +--- + +## 2. 목표 + +Legacy Builder의 모든 기능을 양식 디자이너에서 지원하면서, 더 유연하고 확장 가능한 문서 시스템 구축. + +**최종 목표:** +``` +양식 디자이너로 서식 설계 + ↓ +블록 스키마 기반 문서 생성 (데이터 입력) + ↓ +결재 워크플로우 (작성 → 검토 → 승인) + ↓ +인쇄/PDF 출력 + ↓ +Legacy Builder 완전 대체 +``` + +--- + +## 3. 개발 로드맵 (6단계) + +### Phase 2: 블록 런타임 렌더러 (기반 인프라) + +> **목표**: 저장된 블록 스키마를 문서 생성/조회/인쇄에서 렌더링 + +#### 2-1. 블록 렌더러 엔진 + +**위치**: `mng/resources/views/documents/partials/block-renderer.blade.php` + +``` +schema JSON 입력 + ↓ +블록 타입별 Blade 컴포넌트 렌더링 + ↓ +모드별 출력: + - view 모드: 읽기 전용 HTML + - edit 모드: 입력 폼 HTML + - print 모드: 인쇄 최적화 HTML +``` + +**핵심 구현:** + +```php +// BlockRendererService +class BlockRendererService +{ + public function render(array $schema, string $mode, array $data = []): string + { + $html = ''; + foreach ($schema['blocks'] as $block) { + $html .= $this->renderBlock($block, $mode, $data); + } + return $html; + } + + private function renderBlock(array $block, string $mode, array $data): string + { + return match($block['type']) { + 'heading' => $this->renderHeading($block, $mode), + 'paragraph' => $this->renderParagraph($block, $mode), + 'table' => $this->renderTable($block, $mode, $data), + 'text_field' => $this->renderTextField($block, $mode, $data), + 'number_field' => $this->renderNumberField($block, $mode, $data), + 'date_field' => $this->renderDateField($block, $mode, $data), + 'select_field' => $this->renderSelectField($block, $mode, $data), + 'checkbox_field' => $this->renderCheckboxField($block, $mode, $data), + 'textarea_field' => $this->renderTextareaField($block, $mode, $data), + 'signature_field'=> $this->renderSignatureField($block, $mode, $data), + 'divider' => $this->renderDivider($block), + 'spacer' => $this->renderSpacer($block), + 'columns' => $this->renderColumns($block, $mode, $data), + 'approval_line' => $this->renderApprovalLine($block, $mode, $data), + 'dynamic_table' => $this->renderDynamicTable($block, $mode, $data), + default => '', + }; + } +} +``` + +#### 2-2. 문서 편집 화면 통합 + +**수정 대상**: `mng/resources/views/documents/edit.blade.php` + +``` +Template 로드 + ↓ +isBlockBuilder() 체크 + ├── true → BlockRendererService::render(schema, 'edit', data) + └── false → 기존 Legacy 렌더링 (변경 없음) +``` + +#### 2-3. 데이터 바인딩 (EAV 매핑) + +블록의 `binding` 속성으로 EAV 데이터와 연결: + +```javascript +// 블록 스키마 예시 +{ + "type": "text_field", + "props": { + "label": "제품명", + "binding": "bf_product_name", // ← EAV field_key + "required": true + } +} +``` + +``` +저장 시: + block.binding → document_data.field_key + input.value → document_data.field_value + block.id → document_data.section_id (블록 ID를 섹션으로 활용) + +로드 시: + document_data 조회 → field_key로 블록 매칭 → 값 주입 +``` + +**산출물:** + +| 파일 | 작업 | +|------|------| +| `mng/app/Services/BlockRendererService.php` | 신규 생성 | +| `mng/resources/views/documents/partials/block-renderer.blade.php` | 신규 생성 | +| `mng/resources/views/documents/edit.blade.php` | 블록 빌더 분기 추가 | +| `mng/resources/views/documents/show.blade.php` | 블록 빌더 분기 추가 | +| `api/app/Services/DocumentService.php` | 블록 데이터 저장/로드 로직 | + +--- + +### Phase 3: 결재선 블록 + +> **목표**: 블록 스키마 내에서 결재 워크플로우 정의 + +#### 3-1. approval_line 블록 타입 추가 + +**스키마:** + +```json +{ + "type": "approval_line", + "props": { + "steps": [ + { "role": "작성", "department": "", "name": "" }, + { "role": "검토", "department": "", "name": "" }, + { "role": "승인", "department": "", "name": "" } + ], + "style": "horizontal", + "showStamp": true + } +} +``` + +#### 3-2. 팔레트에 결재선 블록 추가 + +```javascript +// 블록 팔레트 추가 +{ type: 'approval_line', icon: '✓', label: '결재선', category: '워크플로우' } +``` + +#### 3-3. 속성 패널 결재선 편집기 + +``` +┌─────────────────────────────┐ +│ 결재선 설정 │ +│ │ +│ [+ 단계 추가] │ +│ │ +│ 1. 역할: [작성 ▼] │ +│ 부서: [___________] │ +│ 이름: [___________] │ +│ │ +│ 2. 역할: [검토 ▼] │ +│ 부서: [___________] │ +│ 이름: [___________] │ +│ │ +│ 3. 역할: [승인 ▼] │ +│ 부서: [___________] │ +│ 이름: [___________] │ +│ │ +│ ☐ 직인 표시 │ +│ 스타일: [가로형 ▼] │ +└─────────────────────────────┘ +``` + +#### 3-4. 문서 생성 시 결재 연동 + +``` +블록 스키마 → approval_line 블록 추출 + ↓ +DocumentApproval 레코드 자동 생성 + ↓ +기존 결재 워크플로우 (submit → approve → reject) 그대로 활용 +``` + +**산출물:** + +| 파일 | 작업 | +|------|------| +| `block-editor.blade.php` | approval_line 블록 추가 | +| `block-canvas.blade.php` | 결재선 렌더링 | +| `BlockRendererService.php` | 결재선 view/edit/print 렌더 | +| `DocumentService.php` | 블록 결재선 → DocumentApproval 변환 | + +--- + +### Phase 4: 동적 테이블 블록 + 변수 시스템 + +> **목표**: 문서 작성 시 행 추가/삭제 가능한 테이블 + 자동 값 주입 + +#### 4-1. dynamic_table 블록 타입 + +기존 `table` 블록은 정적 (양식 설계 시 행 고정). `dynamic_table`은 문서 작성 시 행 동적 추가. + +**스키마:** + +```json +{ + "type": "dynamic_table", + "props": { + "label": "검사 데이터", + "columns": [ + { "key": "col_item", "label": "항목", "type": "text", "width": 120 }, + { "key": "col_standard", "label": "기준값", "type": "text", "width": 100 }, + { "key": "col_measured", "label": "측정값", "type": "number", "width": 100 }, + { "key": "col_result", "label": "판정", "type": "select", + "options": ["합격", "불합격", "보류"], "width": 80 } + ], + "minRows": 1, + "maxRows": 50, + "initialRows": 3, + "showRowNumber": true, + "binding": "inspection_data" + } +} +``` + +#### 4-2. EAV 데이터 매핑 + +``` +dynamic_table 블록 데이터 저장: + +document_data 레코드: + section_id = (dynamic_table 블록 ID → section 매핑) + column_id = (columns[].key → column 매핑) + row_index = 0, 1, 2, ... + field_key = "col_item", "col_standard", ... + field_value = 입력값 +``` + +#### 4-3. 변수/매크로 시스템 + +**내장 변수:** + +| 변수 | 값 | 설명 | +|------|-----|------| +| `{{today}}` | 현재 날짜 | YYYY-MM-DD | +| `{{now}}` | 현재 시각 | YYYY-MM-DD HH:mm | +| `{{user.name}}` | 로그인 사용자명 | | +| `{{user.department}}` | 로그인 사용자 부서 | | +| `{{doc.number}}` | 문서 번호 | 자동채번 | +| `{{doc.title}}` | 문서 제목 | | +| `{{template.company}}` | 서식 회사명 | | + +**연결 데이터 변수 (linked data):** + +| 변수 | 설명 | +|------|------| +| `{{item.name}}` | 연결 품목명 | +| `{{item.code}}` | 연결 품목 코드 | +| `{{order.number}}` | 연결 작업지시서 번호 | +| `{{order.quantity}}` | 연결 수량 | + +**변수 사용 예시 (블록 속성):** + +```json +{ + "type": "text_field", + "props": { + "label": "검사일자", + "binding": "bf_inspection_date", + "default": "{{today}}" + } +} +``` + +```json +{ + "type": "paragraph", + "props": { + "text": "작성자: {{user.name}} ({{user.department}})" + } +} +``` + +#### 4-4. 변수 해석 엔진 + +```php +// VariableResolver +class VariableResolver +{ + public function resolve(string $text, array $context): string + { + return preg_replace_callback('/\{\{(\w+(?:\.\w+)*)\}\}/', function ($m) use ($context) { + return data_get($context, $m[1], $m[0]); + }, $text); + } + + public function buildContext(Document $document, ?User $user = null): array + { + return [ + 'today' => now()->format('Y-m-d'), + 'now' => now()->format('Y-m-d H:i'), + 'user' => [ + 'name' => $user?->name, + 'department' => $user?->department?->name, + ], + 'doc' => [ + 'number' => $document->document_number, + 'title' => $document->title, + ], + 'item' => $this->resolveLinkedItem($document), + 'order' => $this->resolveLinkedOrder($document), + ]; + } +} +``` + +**산출물:** + +| 파일 | 작업 | +|------|------| +| `block-editor.blade.php` | dynamic_table 블록 추가 | +| `BlockRendererService.php` | 동적 테이블 렌더링 (edit: 행 추가/삭제 UI) | +| `mng/app/Services/VariableResolver.php` | 신규 생성 | +| `DocumentService.php` | 동적 테이블 EAV 저장/로드 | + +--- + +### Phase 5: 고급 블록 + 조건부 로직 + +> **목표**: 수식 계산, 조건부 표시, 이미지 블록 등 고급 기능 + +#### 5-1. 수식 블록 (formula) + +```json +{ + "type": "formula_field", + "props": { + "label": "합계", + "expression": "SUM(inspection_data.col_measured)", + "format": "number", + "decimal": 2 + } +} +``` + +**지원 함수:** + +| 함수 | 설명 | 예시 | +|------|------|------| +| `SUM()` | 합계 | `SUM(table.col_amount)` | +| `AVG()` | 평균 | `AVG(table.col_measured)` | +| `COUNT()` | 개수 | `COUNT(table.col_item)` | +| `MIN()` / `MAX()` | 최솟값/최댓값 | `MIN(table.col_value)` | +| `IF()` | 조건 | `IF(AVG(table.col_measured) > 5, "합격", "불합격")` | +| `ROUND()` | 반올림 | `ROUND(AVG(table.col_measured), 2)` | + +#### 5-2. 조건부 표시 (conditional visibility) + +모든 블록에 `visibility` 속성 추가: + +```json +{ + "type": "paragraph", + "props": { + "text": "불합격 사유를 기재해 주세요.", + "visibility": { + "condition": "field", + "field": "bf_judgement", + "operator": "equals", + "value": "불합격" + } + } +} +``` + +**연산자:** + +| 연산자 | 설명 | +|--------|------| +| `equals` | 같으면 표시 | +| `not_equals` | 다르면 표시 | +| `contains` | 포함하면 표시 | +| `greater_than` | 크면 표시 | +| `less_than` | 작으면 표시 | +| `is_empty` | 비어있으면 표시 | +| `is_not_empty` | 비어있지 않으면 표시 | + +#### 5-3. 이미지 블록 + +```json +{ + "type": "image", + "props": { + "label": "검사 사진", + "source": "upload", + "maxSize": 10, + "accept": ["jpeg", "png", "webp"], + "width": "100%", + "align": "center" + } +} +``` + +#### 5-4. Columns 내부 블록 (중첩 렌더링) + +```json +{ + "type": "columns", + "props": { + "count": 2, + "ratio": "1:1", + "children": [ + [ + { "type": "text_field", "props": { "label": "품명" } }, + { "type": "date_field", "props": { "label": "검사일" } } + ], + [ + { "type": "text_field", "props": { "label": "LOT NO" } }, + { "type": "select_field", "props": { "label": "판정", "options": ["합격","불합격"] } } + ] + ] + } +} +``` + +**산출물:** + +| 파일 | 작업 | +|------|------| +| `block-editor.blade.php` | formula, image, conditional 블록 추가 | +| `mng/app/Services/FormulaEngine.php` | 수식 해석 엔진 | +| `BlockRendererService.php` | 조건부 표시, 수식 계산 렌더링 | + +--- + +### Phase 6: 인쇄/PDF + Legacy 대체 + +> **목표**: 블록 문서 인쇄 완성, Legacy Builder 완전 대체 + +#### 6-1. 인쇄 레이아웃 + +``` +print 모드 렌더링: + - 페이지 설정 (A4/A3) 적용 + - 여백 적용 + - 폼 필드 → 값 표시 (입력란 제거) + - 서명 → 서명 이미지 표시 + - 결재선 → 직인 표시 + - 페이지 넘김 (page-break) 자동 계산 +``` + +#### 6-2. PDF 내보내기 + +``` +블록 렌더러 (print 모드 HTML) + ↓ +Puppeteer / wkhtmltopdf + ↓ +PDF 파일 생성 + ↓ +다운로드 또는 첨부 +``` + +#### 6-3. Legacy → Block 마이그레이션 도구 + +기존 Legacy 서식을 Block 스키마로 자동 변환: + +```php +// LegacyToBlockMigrator +class LegacyToBlockMigrator +{ + public function convert(DocumentTemplate $legacy): array + { + $blocks = []; + + // 1. 결재선 → approval_line 블록 + if ($legacy->approvalLines->isNotEmpty()) { + $blocks[] = $this->convertApprovalLines($legacy->approvalLines); + } + + // 2. 기본필드 → text_field / date_field 블록 + foreach ($legacy->basicFields as $field) { + $blocks[] = $this->convertBasicField($field); + } + + // 3. 섹션 → heading + image 블록 + foreach ($legacy->sections as $section) { + $blocks[] = ['type' => 'heading', 'props' => ['text' => $section->title]]; + if ($section->image_path) { + $blocks[] = ['type' => 'image', 'props' => ['source' => $section->image_path]]; + } + } + + // 4. 컬럼 → dynamic_table 블록 + if ($legacy->columns->isNotEmpty()) { + $blocks[] = $this->convertColumns($legacy->columns); + } + + return [ + 'version' => '1.0', + 'page' => ['size' => 'A4', 'orientation' => 'portrait'], + 'blocks' => $blocks, + ]; + } +} +``` + +#### 6-4. Legacy Builder 비활성화 + +``` +Phase 6 완료 후: + - 새 양식 생성: 양식 디자이너만 허용 + - 기존 Legacy 서식: 조회/편집 가능 (변환 유도) + - Legacy Builder "새 양식" 버튼: "양식 디자이너" 사용 안내 +``` + +**산출물:** + +| 파일 | 작업 | +|------|------| +| `mng/resources/views/documents/print-block.blade.php` | 인쇄 전용 뷰 | +| `mng/app/Services/LegacyToBlockMigrator.php` | 변환 도구 | +| `mng/app/Services/PdfExportService.php` | PDF 생성 | + +--- + +## 4. Phase별 우선순위 및 의존관계 + +``` +Phase 2: 블록 런타임 렌더러 ──────────────────────┐ + (렌더러 엔진, 데이터 바인딩, 문서 편집 통합) │ + │ +Phase 3: 결재선 블록 ─────────────────────┐ │ + (approval_line 블록, 결재 워크플로우) │ │ + ↓ ↓ +Phase 4: 동적 테이블 + 변수 ──────→ Phase 5: 고급 블록 + (dynamic_table, 매크로) (수식, 조건부, 이미지) + │ + ↓ + Phase 6: 인쇄/PDF + Legacy 대체 + (마이그레이션 도구) +``` + +| Phase | 의존 | 난이도 | 예상 범위 | +|-------|------|--------|----------| +| **Phase 2** | 없음 (기반) | 높음 | 렌더러 엔진 + EAV 매핑 | +| **Phase 3** | Phase 2 | 중간 | 결재선 블록 + 워크플로우 연동 | +| **Phase 4** | Phase 2 | 높음 | 동적 테이블 + 변수 해석 | +| **Phase 5** | Phase 4 | 높음 | 수식 엔진 + 조건부 로직 | +| **Phase 6** | Phase 3~5 | 중간 | 인쇄 + 마이그레이션 | + +--- + +## 5. 스키마 버전 관리 + +### 5.1 버전 규칙 + +| 버전 | Phase | 변경 내용 | +|------|-------|----------| +| `1.0` | Phase 1 (현재) | 기본 13개 블록 | +| `2.0` | Phase 2~3 | 데이터 바인딩, approval_line 추가 | +| `3.0` | Phase 4 | dynamic_table, 변수 시스템 | +| `4.0` | Phase 5 | formula, conditional, image | + +### 5.2 하위 호환 + +```json +{ + "version": "3.0", + "page": { ... }, + "blocks": [ ... ], + "variables": { ... }, + "migrations": { + "from_1.0": "auto" + } +} +``` + +- 이전 버전 스키마 자동 인식 및 업그레이드 +- 신규 블록 타입은 이전 버전에서 무시 (graceful degradation) + +--- + +## 6. 신규 블록 타입 전체 목록 + +### Phase별 블록 추가 계획 + +| Phase | 블록 타입 | 카테고리 | 설명 | +|-------|----------|---------|------| +| 1 (완료) | `heading` | 기본 | 제목 | +| 1 (완료) | `paragraph` | 기본 | 문단 | +| 1 (완료) | `table` | 기본 | 정적 테이블 | +| 1 (완료) | `columns` | 기본 | 다단 레이아웃 | +| 1 (완료) | `divider` | 기본 | 구분선 | +| 1 (완료) | `spacer` | 기본 | 여백 | +| 1 (완료) | `text_field` | 폼 | 텍스트 입력 | +| 1 (완료) | `number_field` | 폼 | 숫자 입력 | +| 1 (완료) | `date_field` | 폼 | 날짜 입력 | +| 1 (완료) | `select_field` | 폼 | 드롭다운 | +| 1 (완료) | `checkbox_field` | 폼 | 체크박스 | +| 1 (완료) | `textarea_field` | 폼 | 장문 텍스트 | +| 1 (완료) | `signature_field` | 폼 | 서명 | +| **3** | `approval_line` | 워크플로우 | 결재선 | +| **4** | `dynamic_table` | 데이터 | 동적 행 테이블 | +| **5** | `formula_field` | 데이터 | 수식 계산 | +| **5** | `image` | 미디어 | 이미지 업로드/표시 | + +--- + +## 7. 기술 스택 정리 + +| 구성 요소 | 기술 | 비고 | +|----------|------|------| +| 블록 에디터 UI | Alpine.js + Blade | 기존 유지 | +| 드래그-앤-드롭 | SortableJS | 기존 유지 | +| 블록 렌더러 | PHP (BlockRendererService) | 신규 | +| 변수 해석 | PHP (VariableResolver) | 신규 | +| 수식 엔진 | PHP (FormulaEngine) | 신규 | +| 데이터 저장 | EAV (document_data) | 기존 테이블 활용 | +| 결재 워크플로우 | DocumentApproval | 기존 로직 활용 | +| 인쇄 | CSS @media print | 신규 | +| PDF | Puppeteer 또는 wkhtmltopdf | 신규 | + +--- + +## 8. 위험 요소 및 대응 + +| 위험 | 영향 | 대응 | +|------|------|------| +| EAV 매핑 복잡도 | 블록 ID ↔ section_id 매핑 불일치 | 블록 ID를 section 대용으로 사용, 매핑 테이블 추가 검토 | +| Legacy 데이터 호환 | 기존 문서 데이터 접근 불가 | Legacy 서식 문서는 기존 방식 유지, 신규 서식만 블록 적용 | +| 수식 엔진 보안 | 임의 코드 실행 위험 | 화이트리스트 함수만 허용, eval 사용 금지 | +| 인쇄 레이아웃 | 브라우저별 차이 | CSS @page 규격 준수, PDF 변환 권장 | +| 스키마 마이그레이션 | 버전 업그레이드 시 데이터 손실 | 하위 호환 보장, 자동 업그레이드 로직 | + +--- + +## 9. 성공 기준 + +| 기준 | 측정 방법 | +|------|----------| +| 블록 서식으로 문서 생성 가능 | Phase 2 완료 후 테스트 | +| 결재 워크플로우 정상 동작 | Phase 3 완료 후 테스트 | +| 동적 행 추가/삭제 | Phase 4 완료 후 테스트 | +| 변수 자동 주입 | Phase 4 완료 후 테스트 | +| Legacy 서식 자동 변환 | Phase 6 완료 후 테스트 | +| 인쇄 품질 A4 기준 정상 | Phase 6 완료 후 테스트 | + +--- + +## 관련 문서 + +- [문서양식관리](../features/documents/mng-document-template.md) — 현재 양식관리 기술문서 +- [문서관리 시스템](../features/documents/mng-document-system.md) — 문서 생성/결재 기술문서 +- [문서관리 API](../features/documents/README.md) — API 엔드포인트 목록 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/plans/design-insight-menu-plan.md b/plans/design-insight-menu-plan.md new file mode 100644 index 0000000..28122f5 --- /dev/null +++ b/plans/design-insight-menu-plan.md @@ -0,0 +1,611 @@ +# UI/UX 디자인 인사이트 연구 메뉴 기획서 + +> **작성일**: 2026-03-08 +> **상태**: 기획 중 +> **라우트**: `/rd/design-insight` +> **모티브**: 기획디자인 스토리보드 에디터 (`/rd/planning-design`) + +--- + +## 1. 개요 + +### 1.1 배경 + +기획디자인 메뉴는 ERP 화면을 **설계(Output)**하는 도구다. +그런데 좋은 설계를 하려면 **연구(Input)**가 먼저 필요하다. + +``` +연구 (이 메뉴) 설계 (기획디자인) +┌─────────────────┐ ┌─────────────────┐ +│ 레퍼런스 수집 │ │ 스토리보드 작성 │ +│ 패턴 분석 │ ──→ │ 와이어프레임 설계 │ +│ 인사이트 정리 │ │ HTML 내보내기 │ +│ 디자인 원칙 학습 │ │ 인쇄 │ +└─────────────────┘ └─────────────────┘ +``` + +현재 SAM ERP 화면을 만들 때 참고할 디자인 패턴이나 인사이트를 체계적으로 관리하는 도구가 없다. 외부 서비스(Dribbble, Mobbin 등)를 참고하지만 **우리 ERP에 맞는 패턴**을 축적하는 곳이 없다. + +### 1.2 목적 + +SAM ERP 화면 개발에 필요한 **UI/UX 디자인 인사이트를 수집·분석·축적**하는 연구 도구 + +### 1.3 핵심 가치 + +| 가치 | 설명 | +|------|------| +| **패턴 축적** | "이 화면은 왜 좋은가?" — 반복 사용할 패턴을 라이브러리화 | +| **Before/After** | 개선 전후를 비교하여 디자인 결정의 근거를 기록 | +| **팀 학습** | 디자인 인사이트를 팀원과 공유, 일관된 UI 품질 유지 | +| **빠른 참조** | 새 화면 설계 시 기존 패턴을 즉시 찾아 재사용 | + +--- + +## 2. 기술 아키텍처 + +### 2.1 기획디자인과 동일한 패턴 + +기획디자인 메뉴의 성공 패턴을 그대로 적용한다. + +| 항목 | 선택 | 이유 | +|------|------|------| +| 프레임워크 | Alpine.js 단일 파일 SPA | 서버 API 없이 즉시 사용, MNG 기존 스택 | +| 저장 | localStorage | 서버 의존성 제거, 즉시 사용 가능 | +| 뷰 파일 | `resources/views/rd/design-insight/index.blade.php` | 단일 파일 구조 | +| 컨트롤러 | `RdController@designInsight()` | 기존 R&D 컨트롤러 확장 | +| 이미지 | Base64 Data URL (localStorage) | 서버 업로드 불필요 | + +### 2.2 라우트 + +```php +// routes/web.php — R&D 그룹 내 추가 +Route::get('/rd/design-insight', [RdController::class, 'designInsight']) + ->name('rd.design-insight'); +``` + +### 2.3 localStorage 키 + +| 키 | 용도 | +|----|------| +| `di_projects` | 연구 프로젝트 목록 (메인 저장소) | +| `di_current` | 현재 프로젝트 ID | +| `di_patterns` | 디자인 패턴 라이브러리 (프로젝트 간 공유) | + +--- + +## 3. 화면 구조 + +### 3.1 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 툴바: [프로젝트명] [저장] [내보내기] [뷰: 보드│리스트│갤러리] │ +├──────────────────────────────────────────────────────────────┤ +│ 카테고리 탭: 전체 │ 레퍼런스 │ 분석 │ 패턴 │ Before/After │ +├────────┬─────────────────────────────────────────────────────┤ +│ │ │ +│ 사이드 │ 메인 콘텐츠 영역 │ +│ 바 │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ ◆ 프로 │ │ 인사이트 │ │ 인사이트 │ │ 인사이트 │ │ +│ 젝트 │ │ 카드 1 │ │ 카드 2 │ │ 카드 3 │ │ +│ 목록 │ │ │ │ │ │ │ │ +│ │ │ 🏷️태그 │ │ 🏷️태그 │ │ 🏷️태그 │ │ +│ ◆ 태그 │ └─────────┘ └─────────┘ └─────────┘ │ +│ 필터 │ │ +│ │ ┌─────────┐ ┌─────────┐ │ +│ ◆ 검색 │ │ 인사이트 │ │ + 새 카드 │ │ +│ │ │ 카드 4 │ │ 추가 │ │ +│ │ └─────────┘ └─────────┘ │ +│ │ │ +├────────┴─────────────────────────────────────────────────────┤ +│ 상태바: 카드 12개 │ 패턴 5개 │ 태그 8개 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 3.2 뷰 모드 (3종) + +| 뷰 | 설명 | 용도 | +|----|------|------| +| **보드 (Board)** | 칸반 스타일 카드 격자 배열 | 전체 현황 파악, 기본 뷰 | +| **리스트 (List)** | 테이블형 목록 (정렬/필터) | 대량 데이터 관리, 검색 | +| **갤러리 (Gallery)** | 이미지 중심 큰 썸네일 격자 | 시각적 비교, 레퍼런스 브라우징 | + +--- + +## 4. 인사이트 카드 (핵심 데이터 단위) + +### 4.1 카드 유형 (4종) + +#### A. 레퍼런스 카드 (Reference) + +외부/내부 화면 스크린샷을 수집하고 메모를 남긴다. + +``` +┌──────────────────────────────┐ +│ 📷 [스크린샷 이미지] │ +│ │ +├──────────────────────────────┤ +│ 📌 Notion 대시보드 │ +│ "카드형 레이아웃이 정보 밀도를 │ +│ 유지하면서도 시각적으로 깔끔" │ +├──────────────────────────────┤ +│ 출처: notion.so │ +│ 🏷️ 대시보드 카드 레이아웃 │ +│ ⭐⭐⭐⭐☆ │ +└──────────────────────────────┘ +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `image` | string (Base64) | 스크린샷 이미지 | +| `title` | string | 제목 | +| `memo` | string | 인사이트 메모 (왜 좋은가/나쁜가) | +| `source` | string | 출처 (URL, 앱 이름 등) | +| `tags` | string[] | 태그 배열 | +| `rating` | number (1-5) | 평점 | +| `category` | string | 화면 카테고리 | + +#### B. 분석 카드 (Analysis) + +화면을 분석하고 디자인 원칙을 체크한다. + +``` +┌──────────────────────────────┐ +│ 🔍 SAM 수주 목록 화면 분석 │ +├──────────────────────────────┤ +│ [스크린샷 + 어노테이션 오버레이]│ +│ ①→ 검색 영역 너무 넓음 │ +│ ②→ 버튼 정렬 불일치 │ +│ ③→ 여백 불균형 │ +├──────────────────────────────┤ +│ ✅ 정렬 (Alignment) │ +│ ❌ 대비 (Contrast) │ +│ ✅ 반복 (Repetition) │ +│ ⚠️ 근접성 (Proximity) │ +├──────────────────────────────┤ +│ 개선 제안: │ +│ "검색 영역을 접을 수 있게 하고 │ +│ 버튼 그룹을 우측 정렬" │ +│ 🏷️ 목록화면 개선필요 │ +└──────────────────────────────┘ +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `image` | string (Base64) | 분석 대상 스크린샷 | +| `annotations` | Annotation[] | 어노테이션 배열 (마커 번호, 좌표, 텍스트) | +| `principles` | object | CRAP 원칙 체크 (contrast, repetition, alignment, proximity) | +| `suggestion` | string | 개선 제안 | +| `severity` | string | 심각도 (info, warning, critical) | + +#### C. 패턴 카드 (Pattern) + +반복 사용할 UI 패턴을 템플릿으로 등록한다. + +``` +┌──────────────────────────────┐ +│ 📐 검색 + 필터 + 목록 패턴 │ +├──────────────────────────────┤ +│ [패턴 와이어프레임 이미지] │ +├──────────────────────────────┤ +│ 사용처: │ +│ • 수주 목록 │ +│ • 거래처 목록 │ +│ • 품목 목록 │ +├──────────────────────────────┤ +│ 구성 요소: │ +│ ☑ 검색바 (상단 고정) │ +│ ☑ 필터 칩 (접기/펼치기) │ +│ ☑ 테이블 (정렬 가능) │ +│ ☑ 페이지네이션 (하단) │ +│ ☑ 액션 버튼 (우상단) │ +├──────────────────────────────┤ +│ 🏷️ 목록 CRUD 테이블 │ +│ 📊 사용빈도: ★★★★★ (12회) │ +└──────────────────────────────┘ +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `image` | string (Base64) | 패턴 와이어프레임 | +| `usedIn` | string[] | 사용처 목록 | +| `components` | Component[] | 구성 요소 체크리스트 | +| `guidelines` | string | 사용 가이드라인 | +| `frequency` | number | 사용 빈도 | + +#### D. Before/After 카드 (Comparison) + +디자인 개선 전후를 비교한다. + +``` +┌──────────────────────────────────────────┐ +│ 🔄 거래처 상세 화면 리뉴얼 │ +├───────────────────┬──────────────────────┤ +│ ❌ Before │ ✅ After │ +│ [이전 스크린샷] │ [개선 스크린샷] │ +│ │ │ +├───────────────────┴──────────────────────┤ +│ 변경 포인트: │ +│ 1. 탭 구조 → 섹션 접기/펼치기 변경 │ +│ 2. 좌우 2컬럼 → 단일 컬럼 (모바일 대응) │ +│ 3. 저장 버튼 하단 고정 → 상단 sticky │ +├──────────────────────────────────────────┤ +│ 효과: 스크롤 40% 감소, 작업 완료 시간 단축 │ +│ 🏷️ 상세화면 폼 리뉴얼 │ +└──────────────────────────────────────────┘ +``` + +| 필드 | 타입 | 설명 | +|------|------|------| +| `beforeImage` | string (Base64) | 개선 전 스크린샷 | +| `afterImage` | string (Base64) | 개선 후 스크린샷 | +| `changes` | string[] | 변경 포인트 목록 | +| `effect` | string | 개선 효과 | + +### 4.2 공통 필드 + +모든 카드 유형이 공유하는 기본 필드: + +```json +{ + "id": "di_1709856000000_abc", + "type": "reference", + "title": "카드 제목", + "createdAt": "2026-03-08T10:00:00", + "updatedAt": "2026-03-08T15:30:00", + "tags": ["대시보드", "카드", "레이아웃"], + "category": "dashboard", + "pinned": false, + "archived": false +} +``` + +### 4.3 카테고리 (화면 유형별) + +| 카테고리 | 코드 | 설명 | +|---------|------|------| +| 대시보드 | `dashboard` | 통계, KPI, 차트 화면 | +| 목록 | `list` | 테이블, 검색, 필터 화면 | +| 상세/폼 | `form` | 입력, 편집, 상세 보기 | +| 모달/팝업 | `modal` | 모달 다이얼로그, 확인창 | +| 네비게이션 | `navigation` | 사이드바, 탭, 메뉴 | +| 로그인/온보딩 | `auth` | 인증, 초기 설정 | +| 보고서/인쇄 | `report` | 인쇄용, PDF 출력 화면 | +| 기타 | `etc` | 분류 불가 | + +--- + +## 5. 기능 상세 + +### 5.1 이미지 수집 + +| 기능 | 설명 | +|------|------| +| 파일 업로드 | 이미지 파일 선택 (PNG, JPG, GIF) | +| 클립보드 붙여넣기 | `Ctrl+V`로 스크린샷 즉시 붙여넣기 | +| 드래그 앤 드롭 | 이미지 파일을 카드 영역에 드롭 | + +> **Ctrl+V 붙여넣기가 핵심** — 스크린샷 캡처 후 즉시 카드 생성이 워크플로우의 핵심 + +### 5.2 어노테이션 (분석 카드) + +분석 카드에서 이미지 위에 마커를 추가하여 문제점이나 인사이트를 표시한다. + +| 어노테이션 유형 | 설명 | +|---------------|------| +| 번호 마커 (①②③) | 이미지 위 클릭 → 번호 자동 증가, 하단 설명과 연동 | +| 영역 하이라이트 | 드래그로 사각형 영역 표시 (반투명 컬러 오버레이) | +| 텍스트 메모 | 이미지 위 임의 위치에 짧은 메모 | + +> 기획디자인의 번호 마커(marker 블록) + Description 패널 패턴을 재활용 + +### 5.3 태그 시스템 + +| 기능 | 설명 | +|------|------| +| 자유 태그 | 카드에 자유 태그 추가 (콤마 구분 입력) | +| 태그 자동 완성 | 기존 태그 목록에서 자동 완성 | +| 태그 필터 | 사이드바에서 태그 클릭 → 해당 태그 카드만 표시 | +| 태그 색상 | 카테고리별 자동 색상 배정 | + +### 5.4 CRAP 디자인 원칙 체크리스트 + +분석 카드에서 사용하는 디자인 원칙 평가: + +| 원칙 | 체크 항목 | +|------|----------| +| **C**ontrast (대비) | 중요 요소가 시각적으로 구분되는가? | +| **R**epetition (반복) | 일관된 스타일이 반복 적용되는가? | +| **A**lignment (정렬) | 요소들이 논리적으로 정렬되어 있는가? | +| **P**roximity (근접성) | 관련 요소가 가까이 그룹핑되어 있는가? | + +추가 체크: + +| 원칙 | 체크 항목 | +|------|----------| +| 여백 (Whitespace) | 적절한 여백이 확보되어 있는가? | +| 계층 (Hierarchy) | 정보의 우선순위가 시각적으로 드러나는가? | +| 일관성 (Consistency) | 다른 화면과 일관된 패턴을 따르는가? | +| 접근성 (Accessibility) | 색상 대비, 폰트 크기가 충분한가? | + +### 5.5 검색 & 필터 + +| 기능 | 설명 | +|------|------| +| 텍스트 검색 | 제목, 메모, 태그에서 전문 검색 | +| 카테고리 필터 | 화면 유형별 필터 (탭) | +| 카드 유형 필터 | 레퍼런스 / 분석 / 패턴 / Before/After | +| 평점 필터 | ⭐ 3점 이상만 표시 등 | +| 정렬 | 최신순, 평점순, 이름순 | + +### 5.6 내보내기 + +| 형식 | 설명 | +|------|------| +| JSON | 전체 프로젝트 데이터 백업/복원 | +| HTML | 인사이트 카드를 HTML 보고서로 출력 (인쇄 가능) | +| 패턴 → 기획디자인 | 패턴 카드의 와이어프레임을 기획디자인 템플릿으로 전송 | + +### 5.7 키보드 단축키 + +| 단축키 | 기능 | +|--------|------| +| `Ctrl+V` | 클립보드 이미지로 새 카드 생성 | +| `Ctrl+S` | 프로젝트 저장 | +| `Ctrl+F` | 검색 포커스 | +| `Ctrl+N` | 새 카드 추가 | +| `Delete` | 선택 카드 삭제 | +| `Ctrl+Z` | 실행 취소 | +| `Ctrl+Y` | 다시 실행 | + +--- + +## 6. 데이터 구조 + +### 6.1 프로젝트 (localStorage: `di_projects`) + +```json +[ + { + "id": "diproj_1709856000000", + "title": "SAM ERP v2 디자인 연구", + "description": "SAM ERP 화면 개선을 위한 UI/UX 인사이트 수집", + "cards": [], + "createdAt": "2026-03-08T10:00:00", + "updatedAt": "2026-03-08T15:30:00" + } +] +``` + +### 6.2 인사이트 카드 (cards 배열 내) + +```json +{ + "id": "di_1709856000000_abc", + "type": "reference", + "title": "Notion 대시보드 카드 레이아웃", + "image": "data:image/png;base64,...", + "memo": "카드형 레이아웃이 정보 밀도를 유지하면서도 시각적으로 깔끔", + "source": "notion.so", + "tags": ["대시보드", "카드", "레이아웃"], + "category": "dashboard", + "rating": 4, + "pinned": false, + "archived": false, + "createdAt": "2026-03-08T10:00:00", + "updatedAt": "2026-03-08T15:30:00" +} +``` + +### 6.3 분석 어노테이션 + +```json +{ + "annotations": [ + { + "id": "ann_001", + "type": "marker", + "num": 1, + "x": 150, + "y": 80, + "text": "검색 영역 너무 넓음 — 접기 기능 필요" + }, + { + "id": "ann_002", + "type": "highlight", + "x": 200, + "y": 300, + "w": 150, + "h": 40, + "color": "rgba(239,68,68,0.3)", + "text": "버튼 정렬 불일치" + } + ] +} +``` + +### 6.4 디자인 패턴 라이브러리 (localStorage: `di_patterns`) + +프로젝트 간 공유되는 패턴 라이브러리: + +```json +[ + { + "id": "pat_001", + "name": "검색 + 필터 + 목록", + "image": "data:image/png;base64,...", + "components": [ + { "name": "검색바", "required": true }, + { "name": "필터 칩", "required": false }, + { "name": "데이터 테이블", "required": true }, + { "name": "페이지네이션", "required": true }, + { "name": "액션 버튼", "required": true } + ], + "guidelines": "검색바는 상단 고정, 필터는 접기/펼치기 지원", + "usedIn": ["수주 목록", "거래처 목록", "품목 목록"], + "tags": ["목록", "CRUD", "테이블"], + "frequency": 12, + "createdAt": "2026-03-08T10:00:00" + } +] +``` + +--- + +## 7. 프리셋 데이터 + +### 7.1 기본 카테고리 (하드코딩) + +```javascript +categories: [ + { code: 'dashboard', label: '대시보드', icon: '📊', color: '#6366f1' }, + { code: 'list', label: '목록', icon: '📋', color: '#3b82f6' }, + { code: 'form', label: '상세/폼', icon: '📝', color: '#10b981' }, + { code: 'modal', label: '모달/팝업', icon: '💬', color: '#f59e0b' }, + { code: 'navigation',label: '네비게이션',icon: '🧭', color: '#8b5cf6' }, + { code: 'auth', label: '로그인', icon: '🔐', color: '#ec4899' }, + { code: 'report', label: '보고서', icon: '📄', color: '#0ea5e9' }, + { code: 'etc', label: '기타', icon: '📎', color: '#64748b' }, +] +``` + +### 7.2 CRAP 원칙 체크리스트 (하드코딩) + +```javascript +designPrinciples: [ + { key: 'contrast', label: '대비 (Contrast)', icon: '🔲', desc: '중요 요소가 시각적으로 구분' }, + { key: 'repetition', label: '반복 (Repetition)', icon: '🔁', desc: '일관된 스타일 반복 적용' }, + { key: 'alignment', label: '정렬 (Alignment)', icon: '📏', desc: '논리적 정렬' }, + { key: 'proximity', label: '근접성 (Proximity)', icon: '🧲', desc: '관련 요소 그룹핑' }, + { key: 'whitespace', label: '여백 (Whitespace)', icon: '⬜', desc: '적절한 여백 확보' }, + { key: 'hierarchy', label: '계층 (Hierarchy)', icon: '🔺', desc: '정보 우선순위 시각화' }, + { key: 'consistency', label: '일관성 (Consistency)',icon: '🔗', desc: '다른 화면과의 일관성' }, + { key: 'a11y', label: '접근성 (A11y)', icon: '♿', desc: '색상 대비, 폰트 크기' }, +] +``` + +### 7.3 샘플 패턴 템플릿 (프리셋) + +| 패턴명 | 구성 요소 | SAM 내 사용처 | +|--------|----------|--------------| +| 검색 + 목록 | 검색바, 필터, 테이블, 페이지네이션, 액션버튼 | 수주/거래처/품목 목록 | +| 상세 폼 | 섹션 헤더, 라벨+입력, 저장/취소 버튼 | 수주 상세, 거래처 상세 | +| 대시보드 | 통계 카드 4개, 차트 2개, 요약 테이블 | 메인 대시보드 | +| 탭 레이아웃 | 탭 메뉴, 탭 콘텐츠, 액션 버튼 | 설정, 품목기준관리 | +| 트리 + 상세 | 좌측 트리, 우측 상세 패널 | 메뉴 관리, 조직도 | +| 모달 폼 | 모달 헤더, 입력 필드, 확인/취소 | 등록/수정 팝업 | +| 칸반 보드 | 컬럼 헤더, 드래그 카드, 필터 | 업무 관리 | +| 캘린더 | 월/주/일 뷰, 이벤트 카드, 필터 | 일정 관리, 근태 | + +--- + +## 8. 워크플로우 + +### 8.1 일반 사용 흐름 + +``` +1. 새 연구 프로젝트 생성 ("SAM ERP v2 디자인 연구") + ↓ +2. 레퍼런스 수집 + • 외부 서비스 스크린샷 → Ctrl+V 붙여넣기 + • SAM 기존 화면 스크린샷 → 파일 업로드 + • 태그 + 카테고리 분류 + ↓ +3. 화면 분석 + • 분석 카드 생성 → 어노테이션 추가 + • CRAP 원칙 체크 + • 개선 제안 작성 + ↓ +4. 패턴 추출 + • 반복되는 좋은 패턴 → 패턴 카드로 등록 + • 구성 요소 정리, 사용 가이드라인 작성 + ↓ +5. Before/After 기록 + • 개선 전후 비교 카드 생성 + • 변경 포인트 + 효과 기록 + ↓ +6. 기획디자인 연계 + • 패턴 라이브러리에서 참고하며 스토리보드 작성 +``` + +### 8.2 기획디자인 연계 + +``` +디자인 인사이트 기획디자인 +┌──────────────┐ ┌──────────────┐ +│ 패턴 카드: │ │ 스토리보드: │ +│ "검색+목록" │──참조──→│ 새 페이지에 │ +│ 구성요소 체크 │ │ 패턴 적용 │ +│ 가이드라인 │ │ │ +└──────────────┘ └──────────────┘ +``` + +> 향후 패턴 카드의 구성 요소를 기획디자인 블록 템플릿으로 자동 변환하는 연계 기능을 검토한다. + +--- + +## 9. 개발 로드맵 + +### Phase 1 — 기본 구조 (MVP) + +| 항목 | 내용 | +|------|------| +| 라우트 + 컨트롤러 | `GET /rd/design-insight` → 뷰 반환 | +| 프로젝트 CRUD | 생성/저장/로드/삭제 (localStorage) | +| 레퍼런스 카드 | 이미지 업로드 + 메모 + 태그 + 카테고리 | +| 보드 뷰 | 카드 격자 배열 기본 화면 | +| 검색/필터 | 텍스트 검색, 카테고리 탭 필터 | +| Ctrl+V 붙여넣기 | 클립보드 이미지 → 새 카드 자동 생성 | + +### Phase 2 — 분석 도구 + +| 항목 | 내용 | +|------|------| +| 분석 카드 | 어노테이션 시스템 (마커, 하이라이트) | +| CRAP 체크리스트 | 디자인 원칙 체크 UI | +| Before/After 카드 | 전후 비교 카드 유형 | +| 갤러리 뷰 | 이미지 중심 큰 썸네일 | +| 리스트 뷰 | 테이블형 정렬/필터 | + +### Phase 3 — 패턴 라이브러리 + +| 항목 | 내용 | +|------|------| +| 패턴 카드 | 구성 요소 체크리스트, 가이드라인 | +| 패턴 프리셋 | SAM ERP 기본 패턴 8종 | +| 패턴 공유 | 프로젝트 간 패턴 공유 (di_patterns) | +| 내보내기 | JSON 백업, HTML 보고서 | + +### Phase 4 — 연계 & 고도화 + +| 항목 | 내용 | +|------|------| +| 기획디자인 연계 | 패턴 → 블록 템플릿 변환 | +| DB 저장 전환 | localStorage → DB (협업 지원) | +| 팀 공유 | 다른 사용자와 인사이트 공유 | + +--- + +## 10. 파일 구조 (예상) + +``` +mng/ +├── app/Http/Controllers/ +│ └── RdController.php ← designInsight() 메서드 추가 +├── resources/views/rd/design-insight/ +│ └── index.blade.php ← 전체 CSS + HTML + Alpine.js +└── routes/web.php ← Route 추가 +``` + +--- + +## 11. 관련 문서 + +- [기획디자인 기술 스펙](../features/rd/planning-design.md) — 모티브가 된 스토리보드 에디터 +- [기획디자인 프로젝트](../projects/planning-design/README.md) — 프로젝트 이력 +- [R&D 메뉴 개요](../features/rd/README.md) — R&D 전체 메뉴 구조 + +--- + +**최종 업데이트**: 2026-03-08 diff --git a/plans/fire-shutter-drawing-generator-plan.md b/plans/fire-shutter-drawing-generator-plan.md new file mode 100644 index 0000000..1b9a680 --- /dev/null +++ b/plans/fire-shutter-drawing-generator-plan.md @@ -0,0 +1,753 @@ +# 방화셔터 도면생성 기능 기획서 + +> **작성일**: 2026-03-08 +> **상태**: 기획 초안 +> **위치**: MNG > R&D > 방화셔터 도면생성 +> **라우트**: `GET /rd/fire-shutter-drawing` +> **참고**: 기존 `자동도면 생성` (`/rd/auto-drawing`) 구조를 확장 + +--- + +## 1. 개요 + +### 1.1 목적 + +방화셔터의 **가이드레일 단면**과 **셔터박스(케이스) 형태**를 파라미터로 입력하면, **2D 단면도(SVG)**와 **3D 렌더링(Three.js)**을 실시간으로 생성하는 도구를 제공한다. + +### 1.2 핵심 가치 + +| 기존 (수동) | 개선 (SAM 도면생성) | +|-------------|-------------------| +| CAD 프로그램에서 수동 작도 | 파라미터 입력 → 자동 도면 생성 | +| 도면 수정 시 전체 재작업 | 치수 변경 → 실시간 미리보기 | +| 제품별 도면 관리 어려움 | 프리셋 저장/불러오기로 재활용 | +| 영업/설치팀 도면 요청 대기 | 현장에서 즉시 단면도 확인 가능 | + +### 1.3 대상 사용자 + +- 설계팀: 방화셔터 단면 설계 및 검토 +- 영업팀: 고객 제안 시 단면도/3D 이미지 첨부 +- 설치팀: 현장 설치 전 가이드레일/케이스 형태 확인 +- 생산팀: 절곡/제작 사양 확인 + +--- + +## 2. 방화셔터 핵심 구조 + +### 2.1 전체 구성도 + +``` +┌─────────────────── 천장 슬래브 ───────────────────┐ +│ │ +│ ┌──────────── 셔터박스 (HEAD BOX / CASE) ──────┐ │ +│ │ ┌─────┐ ┌─────┐ │ │ +│ │ │브래킷│ [샤프트+슬랫 감김] │브래킷│ │ │ +│ │ └──┬──┘ [모터+감속기+브레이크] └──┬──┘ │ │ +│ │ │ [밸런스 스프링] │ │ │ +│ └─────┼─────────────────────────────────┼──────┘ │ +│ │ │ │ +│ ┌─────┴─────┐ ┌──────┴─────┐ │ +│ │ 가이드레일 │ ← 슬랫 커튼 → │ 가이드레일 │ │ +│ │ (좌) │ (강판/스크린) │ (우) │ │ +│ │ │ │ │ │ +│ │ 연기차단재│ │연기차단재 │ │ +│ │ │ │ │ │ +│ └─────┬─────┘ └──────┬─────┘ │ +│ │ │ │ +│ ══════╧═══ 하장바 (BOTTOM BAR) ═════════╧═══════ │ +│ [고무 실링] │ +└────────────────── 바닥 ──────────────────────────┘ +``` + +### 2.2 주요 구성요소 상세 + +#### A. 가이드레일 (Guide Rail) + +- **형태**: C-채널 단면 (ㄷ자 형태) +- **재질**: 강판 2.3mm 이상 +- **기능**: 슬랫 커튼의 좌우 안내 + 연기 차단 +- **표준 길이**: 2,438mm / 3,305mm / 4,430mm (조합 사용) +- **수량**: 항상 **2개** (좌우 1쌍) +- **부속**: 연기차단재(Smoke Seal Packing), 앵커볼트 + +``` +가이드레일 단면 (상단에서 본 모습) + + ┌────────────┐ + │ │ ← 가이드레일 본체 (C-채널) + │ ┌──────┐ │ + │ │ 연기 │ │ + │ │ 차단 │ │ + │ │ 재 │ │ + │ │ │ │ + │ │슬랫 │ │ + │ │엣지→ ● │ + │ │ │ │ + │ │ 연기 │ │ + │ │ 차단 │ │ + │ │ 재 │ │ + │ └──────┘ │ + │ │ + └────────────┘ + ■■■■■■■■■■■■■ ← 방화벽 +``` + +**파라미터**: + +| 파라미터 | 설명 | 단위 | 기본값 | +|---------|------|------|--------| +| `rail_width` | 레일 전체 폭 | mm | 65 | +| `rail_depth` | 레일 깊이 (채널 깊이) | mm | 50 | +| `rail_thickness` | 강판 두께 | mm | 2.3 | +| `rail_lip` | 립(입구) 높이 | mm | 15 | +| `seal_thickness` | 연기차단재 두께 | mm | 5 | +| `seal_depth` | 연기차단재 깊이 | mm | 40 | +| `slat_thickness` | 슬랫 두께 (끼워지는 부분) | mm | 1.6 | +| `rail_height` | 레일 전체 높이 | mm | 3305 | +| `anchor_spacing` | 앵커볼트 간격 | mm | 500 | + +#### B. 셔터박스 / 케이스 (Head Box / Case) + +- **형태**: 직사각형 박스 (상부 천장 부착) +- **재질**: 강판 1.6mm 이상 +- **기능**: 샤프트/모터/슬랫 감김 수납 +- **표준 규격**: 1500×380mm / 500×380mm (개구부 크기에 따라) + +``` +셔터박스 단면 (정면에서 본 모습) + + ┌─────────────────────────────────────────┐ ← 상판 + │ │ + │ [브래킷] ┌──── 샤프트 ────┐ [브래킷] │ + │ │ │ (슬랫 감김) │ │ │ + │ ├──────┤ ├──────┤ │ + │ │ │ ◎ 중심축 │ │ │ + │ │ └───────────────┘ │ │ + │ │ │ │ + │ │ [모터+감속기] [브레이크] │ │ + │ │ [밸런스 스프링] │ │ + │ │ + └───┬─────────────────────────────────┬───┘ ← 하판 (슬랫 출구) + │ ↓ 슬랫 하강 ↓ │ + └─────────────────────────────────┘ +``` + +**파라미터**: + +| 파라미터 | 설명 | 단위 | 기본값 | +|---------|------|------|--------| +| `box_width` | 케이스 전체 폭 (= 개구부 폭 + 마진) | mm | 1500 | +| `box_height` | 케이스 높이 | mm | 380 | +| `box_depth` | 케이스 깊이 (전후) | mm | 380 | +| `box_thickness` | 케이스 강판 두께 | mm | 1.6 | +| `shaft_diameter` | 샤프트 직경 | mm | 120 | +| `shaft_offset_x` | 샤프트 중심 수평 오프셋 | mm | 0 | +| `shaft_offset_y` | 샤프트 중심 수직 오프셋 | mm | 0 | +| `motor_side` | 모터 위치 (좌/우) | - | 우 | +| `slat_exit_width` | 슬랫 출구 폭 | mm | 1400 | +| `bracket_width` | 브래킷 폭 | mm | 80 | + +#### C. 슬랫 (Steel Slat / Screen) + +- **강판형**: EGI 강판 1.6mm, C/S형 인터록킹 프로파일 +- **스크린형**: 실리카/와이어 원단, 가이드레일 11mm 홈 +- **피치**: 75~100mm + +**파라미터**: + +| 파라미터 | 설명 | 단위 | 기본값 | +|---------|------|------|--------| +| `slat_type` | 슬랫 유형 (강판/스크린) | - | 강판 | +| `slat_pitch` | 슬랫 피치 | mm | 80 | +| `slat_thickness` | 슬랫 두께 | mm | 1.6 | +| `slat_profile` | 단면 형태 (C형/S형) | - | C형 | + +#### D. 하장바 (Bottom Bar) + +- **기능**: 슬랫 커튼 하단 마감 + 바닥 밀착 +- **부속**: 고무 실링 + +**파라미터**: + +| 파라미터 | 설명 | 단위 | 기본값 | +|---------|------|------|--------| +| `bar_width` | 하장바 폭 | mm | 60 | +| `bar_height` | 하장바 높이 | mm | 40 | +| `bar_seal_height` | 고무 실링 높이 | mm | 15 | + +--- + +## 3. 기능 설계 + +### 3.1 탭 구성 + +기존 자동도면 생성의 탭 구조를 참고하여 4개 탭으로 구성한다. + +``` +┌──────────┬──────────┬──────────┬──────────┐ +│ 설정 │ 가이드 │ 셔터박스 │ 3D │ +│ Settings │ 레일 │ (케이스) │ 렌더링 │ +└──────────┴──────────┴──────────┴──────────┘ +``` + +| 탭 | ID | 기능 | +|----|----|------| +| **설정** | `Settings` | 제품 유형 선택, 개구부 크기, 전역 설정 | +| **가이드레일** | `GuideRail` | 가이드레일 단면 파라미터 입력 + SVG 단면도 실시간 미리보기 | +| **셔터박스** | `ShutterBox` | 셔터박스 단면 파라미터 입력 + SVG 단면도 실시간 미리보기 | +| **3D 렌더링** | `3D` | 전체 방화셔터 조립체 3D 렌더링 (Three.js) | + +### 3.2 설정 탭 (Settings) + +#### 입력 항목 + +| 항목 | 타입 | 설명 | +|------|------|------| +| 제품 유형 | 드롭다운 | 강판형 (KFS) / 스크린형 (KSS) | +| 제품 모델 | 드롭다운 | KSS01, KSS02, KFS01 등 (유형 선택 시 필터링) | +| 개구부 폭 (W0) | 숫자 입력 | mm | +| 개구부 높이 (H0) | 숫자 입력 | mm | +| 수량 | 숫자 입력 | 기본값 1 | + +#### 자동 계산 (표시 전용) + +| 항목 | 수식 | 설명 | +|------|------|------| +| 제작 폭 (W1) | 스크린: W0+140 / 강판: W0+110 | 마진 포함 | +| 제작 높이 (H1) | H0+350 | 마진 포함 | +| 면적 (M) | W1 × H1 / 1,000,000 | m² | +| 중량 (K) | 스크린: M×2 / 강판: M×25 | kg | +| 권장 모터 | K 기준 자동 선택 | 150K~1500K | + +#### 프리셋 관리 + +- **프리셋 저장**: 현재 파라미터를 이름 지정하여 localStorage에 저장 +- **프리셋 불러오기**: 저장된 프리셋 목록에서 선택하여 파라미터 복원 +- **기본 프리셋**: 강판형 기본, 스크린형 기본 (제품 유형 선택 시 자동 적용) + +### 3.3 가이드레일 탭 + +#### UI 구성 (2컬럼 레이아웃) + +``` +┌──────────────────────┬──────────────────────────────────┐ +│ 왼쪽: 파라미터 입력 │ 오른쪽: SVG 단면도 미리보기 │ +│ │ │ +│ ■ 레일 전체 폭: [65] │ │ +│ ■ 레일 깊이: [50] │ ┌────────┐ │ +│ ■ 강판 두께: [2.3] │ │ │ │ +│ ■ 립 높이: [15] │ │ ┌────┐ │ ← SVG 실시간 │ +│ ■ 연기차단재: [5/40] │ │ │ ● │ │ 렌더링 │ +│ ■ 슬랫 두께: [1.6] │ │ └────┘ │ │ +│ │ │ │ │ +│ [치수 표시 ON/OFF] │ └────────┘ │ +│ [연기차단재 ON/OFF] │ ← 치수 라벨 (mm) │ +│ │ │ +│ ■ 레일 높이: [3305] │ [줌 +] [줌 -] [리셋] [DXF 저장] │ +│ ■ 앵커 간격: [500] │ │ +└──────────────────────┴──────────────────────────────────┘ +``` + +#### SVG 단면도 렌더링 상세 + +**뷰 모드 3가지**: + +1. **횡단면도 (Cross-Section)**: 가이드레일을 위에서 본 단면 — 슬랫이 레일에 끼워진 형태 +2. **종단면도 (Longitudinal)**: 가이드레일을 측면에서 본 단면 — 앵커볼트 배치 +3. **정면도 (Front View)**: 가이드레일을 정면에서 본 모습 — 레일 전체 높이 + 앵커 위치 + +**렌더링 요소**: + +| 요소 | 색상 | 설명 | +|------|------|------| +| 레일 본체 | `#94a3b8` (은회색) | 강판 단면 | +| 연기차단재 | `#f97316` (주황) | 실링 재질 | +| 슬랫 엣지 | `#60a5fa` (파랑) | 레일 안의 슬랫 | +| 방화벽 | `#a1887f` (갈색 해칭) | 콘크리트 벽 | +| 앵커볼트 | `#ef4444` (빨강) | 고정 부속 | +| 치수선 | `#3b82f6` (파랑) | mm 단위 치수 | + +### 3.4 셔터박스 탭 + +#### UI 구성 (2컬럼 레이아웃) + +``` +┌──────────────────────┬──────────────────────────────────┐ +│ 왼쪽: 파라미터 입력 │ 오른쪽: SVG 단면도 미리보기 │ +│ │ │ +│ ■ 케이스 폭: [1500] │ ┌────────────────────────────┐ │ +│ ■ 케이스 높이: [380] │ │ [브래킷] ◎샤프트 [브래킷]│ │ +│ ■ 케이스 깊이: [380] │ │ 감김 슬랫 │ │ +│ ■ 강판 두께: [1.6] │ │ [모터+감속기] [브레이크] │ │ +│ │ └────────────────────────────┘ │ +│ ■ 샤프트 직경: [120] │ │ +│ ■ 샤프트 오프셋 │ ← SVG 실시간 렌더링 │ +│ X: [0] Y: [0] │ ← 치수 라벨 (mm) │ +│ ■ 모터 위치: [좌/우] │ │ +│ │ │ +│ ■ 내부 부품 표시 │ [줌 +] [줌 -] [리셋] [DXF 저장] │ +│ □ 샤프트 │ │ +│ □ 모터/감속기 │ │ +│ □ 브레이크 │ │ +│ □ 밸런스 스프링 │ │ +└──────────────────────┴──────────────────────────────────┘ +``` + +#### SVG 단면도 렌더링 상세 + +**뷰 모드 3가지**: + +1. **정면 단면도**: 케이스를 정면에서 본 내부 구조 (샤프트, 모터, 브래킷 위치) +2. **측면 단면도**: 케이스를 측면에서 본 단면 (깊이 방향, 슬랫 감김 단면) +3. **하부 상세도**: 슬랫 출구 부분 확대 + +**렌더링 요소**: + +| 요소 | 색상 | 설명 | +|------|------|------| +| 케이스 외곽 | `#94a3b8` (은회색) | 강판 박스 | +| 샤프트 | `#64748b` (짙은 회색) | 중심축 + 감김 슬랫 | +| 모터 | `#3b82f6` (파랑) | 전동 개폐기 | +| 브레이크 | `#ef4444` (빨강) | 전자 브레이크 | +| 스프링 | `#22c55e` (녹색) | 밸런스 스프링 | +| 브래킷 | `#8b5cf6` (보라) | 벽 고정 브래킷 | +| 슬랫 | `#f59e0b` (주황) | 감긴 슬랫 단면 | + +### 3.5 3D 렌더링 탭 + +#### 렌더링 대상 + +Three.js를 사용하여 방화셔터 전체 조립체를 3D로 시각화한다. + +``` +3D 렌더링 요소: +├── 셔터박스 (반투명 상자) +│ ├── 샤프트 (원통) +│ ├── 감긴 슬랫 (원통 표면) +│ ├── 모터+감속기 (박스) +│ ├── 브레이크 (디스크) +│ └── 브래킷 (L형 판) +├── 가이드레일 좌 (C-채널 압출) +├── 가이드레일 우 (C-채널 압출) +├── 슬랫 커튼 (평면 텍스처) +│ ├── 강판형: 줄무늬 텍스처 (인터록킹 표현) +│ └── 스크린형: 반투명 메쉬 +├── 하장바 (직사각형 바) +└── 방화벽 (반투명 콘크리트 텍스처) +``` + +#### 3D 인터랙션 + +| 기능 | 조작 | 설명 | +|------|------|------| +| 회전 | 마우스 드래그 | OrbitControls | +| 줌 | 마우스 휠 | 확대/축소 | +| 팬 | 우클릭 드래그 | 시점 이동 | +| 부품 하이라이트 | 마우스 호버 | 해당 부품 강조 + 이름 표시 | +| 부품 ON/OFF | 체크박스 | 개별 부품 표시/숨김 | +| 투명도 | 슬라이더 | 케이스 투명도 조절 (내부 구조 확인) | +| 셔터 개폐 | 슬라이더 | 0%(전개)~100%(전폐) 애니메이션 | +| 조명 | 프리셋 | 기본/스튜디오/야외/드라마틱 | + +#### 3D 모델링 방식 + +DB나 외부 3D 파일 없이, **파라미터 기반 절차적 모델링(Procedural Modeling)**으로 구현한다. + +```javascript +// 가이드레일 C-채널 3D 생성 예시 (Three.js ExtrudeGeometry) +function createGuideRailMesh(params) { + const shape = new THREE.Shape(); + // C-채널 프로파일 경로 정의 + shape.moveTo(0, 0); + shape.lineTo(params.rail_width, 0); + shape.lineTo(params.rail_width, params.rail_lip); + shape.lineTo(params.rail_width - params.rail_thickness, params.rail_lip); + shape.lineTo(params.rail_width - params.rail_thickness, params.rail_thickness); + shape.lineTo(params.rail_thickness, params.rail_thickness); + shape.lineTo(params.rail_thickness, params.rail_lip); + shape.lineTo(0, params.rail_lip); + shape.lineTo(0, 0); + + // 높이 방향으로 압출 + const extrudeSettings = { + depth: params.rail_height, + bevelEnabled: false + }; + + return new THREE.Mesh( + new THREE.ExtrudeGeometry(shape, extrudeSettings), + new THREE.MeshStandardMaterial({ color: 0x94a3b8 }) + ); +} +``` + +### 3.6 출력 기능 + +| 기능 | 형식 | 설명 | +|------|------|------| +| **DXF 다운로드** | `.dxf` | 가이드레일/셔터박스 단면도를 CAD 호환 파일로 저장 | +| **PNG 다운로드** | `.png` | SVG 단면도를 이미지로 저장 | +| **3D 스크린샷** | `.png` | 3D 렌더링 현재 뷰를 이미지로 저장 | +| **파라미터 JSON** | `.json` | 현재 설정값을 파일로 내보내기/가져오기 | + +--- + +## 4. 기술 설계 + +### 4.1 아키텍처 + +기존 자동도면 생성과 동일한 **순수 클라이언트 측** 아키텍처를 사용한다. + +``` +┌─────────────────────────────────────────────┐ +│ Browser (Client-Side Only) │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Blade Template │ │ +│ │ (fire-shutter-drawing/index.blade) │ │ +│ ├─────────────────────────────────────┤ │ +│ │ JavaScript State Management │ │ +│ │ (fireShutterState 객체) │ │ +│ ├──────────┬──────────────────────────┤ │ +│ │ SVG 엔진 │ Three.js 3D 엔진 │ │ +│ │ (단면도) │ (조립체 렌더링) │ │ +│ ├──────────┴──────────────────────────┤ │ +│ │ DXF 생성기 │ PNG 내보내기 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ DB 연동: 없음 (localStorage 프리셋만 사용) │ +│ API 호출: 없음 │ +└─────────────────────────────────────────────┘ +``` + +### 4.2 파일 구조 + +``` +mng/ +├── routes/web.php ← 라우트 추가 +├── app/Http/Controllers/RdController.php ← 메서드 추가 +└── resources/views/rd/fire-shutter-drawing/ + ├── index.blade.php ← 메인 레이아웃 + 탭 UI + ├── partials/ + │ ├── _settings.blade.php ← 설정 탭 HTML + │ ├── _guide-rail.blade.php ← 가이드레일 탭 HTML + │ ├── _shutter-box.blade.php ← 셔터박스 탭 HTML + │ └── _3d-viewer.blade.php ← 3D 렌더링 탭 HTML + └── js/ + (인라인 또는 @push('scripts')에 포함) +``` + +> **참고**: 기존 `auto-drawing/index.blade.php`는 단일 파일 4,884줄이다. 유지보수성을 위해 **Blade partial로 분리**하되, JavaScript는 상태 공유가 필요하므로 메인 파일의 `@push('scripts')`에 통합한다. + +### 4.3 상태 관리 객체 + +```javascript +const fireShutterState = { + // 활성 탭 + activeTab: 'Settings', + + // 설정 탭 + settings: { + productType: 'steel', // 'steel' | 'screen' + productModel: 'KFS01', // 제품 모델 코드 + openWidth: 2000, // 개구부 폭 W0 (mm) + openHeight: 3000, // 개구부 높이 H0 (mm) + quantity: 1, + // 자동 계산 + mfgWidth: 0, // 제작 폭 W1 + mfgHeight: 0, // 제작 높이 H1 + area: 0, // 면적 M (m²) + weight: 0, // 중량 K (kg) + motorSpec: '', // 권장 모터 + }, + + // 가이드레일 파라미터 + guideRail: { + width: 65, + depth: 50, + thickness: 2.3, + lip: 15, + sealThickness: 5, + sealDepth: 40, + slatThickness: 1.6, + height: 3305, + anchorSpacing: 500, + // 뷰 옵션 + showDimensions: true, + showSeal: true, + viewMode: 'cross', // 'cross' | 'longitudinal' | 'front' + }, + + // 셔터박스 파라미터 + shutterBox: { + width: 1500, + height: 380, + depth: 380, + thickness: 1.6, + shaftDiameter: 120, + shaftOffsetX: 0, + shaftOffsetY: 0, + motorSide: 'right', // 'left' | 'right' + slatExitWidth: 1400, + bracketWidth: 80, + // 내부 부품 표시 + showShaft: true, + showMotor: true, + showBrake: true, + showSpring: true, + viewMode: 'front', // 'front' | 'side' | 'bottom' + }, + + // 슬랫 파라미터 + slat: { + type: 'steel', // 'steel' | 'screen' + pitch: 80, + thickness: 1.6, + profile: 'C', // 'C' | 'S' + }, + + // 하장바 파라미터 + bottomBar: { + width: 60, + height: 40, + sealHeight: 15, + }, + + // 3D 뷰 설정 + threeD: { + caseOpacity: 0.3, // 케이스 투명도 + shutterPosition: 100, // 0=전개, 100=전폐 + showComponents: { + case: true, + shaft: true, + motor: true, + brake: true, + spring: true, + guideRailL: true, + guideRailR: true, + slats: true, + bottomBar: true, + wall: true, + }, + lightPreset: 'default', + }, + + // 프리셋 관리 + presets: [], // localStorage에서 로드 + + // 뷰 컨트롤 (줌/팬) + view: { + scale: 1, + offset: { x: 0, y: 0 }, + isDragging: false, + }, +}; +``` + +### 4.4 제품 유형별 기본값 매핑 + +```javascript +const PRODUCT_DEFAULTS = { + steel: { + label: '강판형', + marginW: 110, // W1 = W0 + 110 + marginH: 350, // H1 = H0 + 350 + weightFactor: 25, // K = M × 25 + guideRail: { width: 65, depth: 50, thickness: 2.3, lip: 15 }, + slat: { type: 'steel', pitch: 80, thickness: 1.6, profile: 'C' }, + }, + screen: { + label: '스크린형', + marginW: 140, // W1 = W0 + 140 + marginH: 350, // H1 = H0 + 350 + weightFactor: 2, // K = M × 2 + guideRail: { width: 30, depth: 25, thickness: 1.5, lip: 11 }, + slat: { type: 'screen', pitch: 100, thickness: 0.8, profile: 'flat' }, + }, +}; + +const MOTOR_TABLE = [ + { maxWeight: 150, spec: '150K', inch: 4 }, + { maxWeight: 300, spec: '300K', inch: 4 }, + { maxWeight: 500, spec: '500K', inch: 5 }, + { maxWeight: 750, spec: '750K', inch: 5 }, + { maxWeight: 1000, spec: '1000K', inch: 6 }, + { maxWeight: 1500, spec: '1500K', inch: 6 }, +]; +``` + +--- + +## 5. 개발 단계 + +### Phase 1: 기본 구조 + 가이드레일 단면도 (1단계) + +> **목표**: 라우트/컨트롤러/뷰 생성, 설정 탭, 가이드레일 SVG 단면도 + +| 작업 | 상세 | 예상 | +|------|------|------| +| 라우트 등록 | `GET /rd/fire-shutter-drawing` | 10분 | +| 컨트롤러 메서드 | `RdController@fireShutterDrawing` | 10분 | +| 레이아웃 + 탭 UI | 4탭 구조, 다크 테마 | 1시간 | +| 설정 탭 | 제품 유형/개구부 크기 입력 + 자동 계산 | 1시간 | +| 가이드레일 SVG 엔진 | C-채널 단면도 + 치수선 + 연기차단재 | 3시간 | +| 줌/팬 컨트롤 | 기존 auto-drawing 코드 재사용 | 30분 | + +**산출물**: 가이드레일 파라미터 입력 → SVG 횡단면도 실시간 렌더링 + +### Phase 2: 셔터박스 단면도 (2단계) + +> **목표**: 셔터박스(케이스) SVG 단면도 + 내부 부품 표시 + +| 작업 | 상세 | 예상 | +|------|------|------| +| 셔터박스 SVG 엔진 | 케이스 외곽 + 내부 구조 | 3시간 | +| 내부 부품 렌더링 | 샤프트, 모터, 브레이크, 스프링 | 2시간 | +| 뷰 모드 전환 | 정면/측면/하부 | 1시간 | +| 부품 ON/OFF 토글 | 체크박스 → SVG 요소 표시/숨김 | 30분 | + +**산출물**: 셔터박스 파라미터 입력 → SVG 단면도 (정면/측면/하부) + +### Phase 3: 3D 렌더링 (3단계) + +> **목표**: Three.js 기반 방화셔터 전체 조립체 3D 렌더링 + +| 작업 | 상세 | 예상 | +|------|------|------| +| Three.js 씬 구축 | 카메라, 조명, OrbitControls | 1시간 | +| 가이드레일 3D 모델 | ExtrudeGeometry (C-채널 압출) | 2시간 | +| 셔터박스 3D 모델 | BoxGeometry + 내부 부품 | 2시간 | +| 슬랫 커튼 3D 모델 | 평면 메쉬 + 텍스처 | 1시간 | +| 셔터 개폐 애니메이션 | 슬라이더 → 슬랫 위치 변경 | 1시간 | +| 조명/투명도 패널 | 기존 auto-drawing 패널 재사용 | 30분 | + +**산출물**: 파라미터 연동 3D 방화셔터 조립체 + 인터랙션 + +### Phase 4: 출력 + 프리셋 (4단계) + +> **목표**: DXF/PNG 저장, 프리셋 관리, 완성도 향상 + +| 작업 | 상세 | 예상 | +|------|------|------| +| DXF 내보내기 | 기존 DXF 생성기 확장 | 1시간 | +| PNG 내보내기 | SVG → Canvas → PNG | 30분 | +| 3D 스크린샷 | Three.js renderer.domElement.toDataURL | 15분 | +| 프리셋 저장/불러오기 | localStorage CRUD | 1시간 | +| JSON 가져오기/내보내기 | 파일 업로드/다운로드 | 30분 | +| UI 다듬기 | 반응형, 툴팁, 키보드 단축키 | 1시간 | + +**산출물**: 완성된 방화셔터 도면생성 도구 + +--- + +## 6. UI/UX 설계 + +### 6.1 디자인 시스템 + +기존 자동도면 생성의 **다크 테마 (Space Theme)**를 그대로 이어간다. + +| 요소 | 값 | +|------|------| +| 배경 | `#020617` (slate-950) | +| 패널 | `rgba(15, 23, 42, 0.7)` + backdrop-blur | +| 테두리 | `rgba(255, 255, 255, 0.1)` | +| 강조색 | `#3b82f6` (blue-500) | +| 텍스트 | `#f8fafc` (white) / `#94a3b8` (slate-400) | +| 입력 필드 | `bg-slate-950/80` + `border-slate-800` | + +### 6.2 반응형 레이아웃 + +``` +Desktop (1200px+): 2컬럼 (4:8 비율) +Tablet (768-1199px): 1컬럼 (상: 파라미터, 하: 미리보기) +Mobile: 지원 안 함 (최소 768px) +``` + +### 6.3 인터랙션 흐름 + +``` +사용자 → 제품 유형 선택 (강판/스크린) + → 기본값 자동 적용 + → 개구부 크기 입력 + → 제작 치수/중량/모터 자동 계산 + + → 가이드레일 탭 이동 + → 파라미터 조정 (폭, 깊이, 두께 등) + → SVG 실시간 업데이트 (입력 즉시) + → 뷰 모드 전환 (횡단면/종단면/정면) + → DXF 다운로드 가능 + + → 셔터박스 탭 이동 + → 파라미터 조정 + → SVG 실시간 업데이트 + → 내부 부품 ON/OFF + + → 3D 탭 이동 + → 전체 조립체 3D 뷰 + → 셔터 개폐 애니메이션 + → 스크린샷 저장 +``` + +--- + +## 7. 메뉴 등록 + +### 7.1 메뉴 위치 + +``` +R&D +├── 대시보드 +├── 조직도 관리 +├── 중대재해처벌법 점검 +├── AI 견적 +├── 기획디자인 +├── 디자인 인사이트 +├── 사운드 로고 스튜디오 +├── CM송 제작 +├── 자동도면 생성 ← 기존 +└── 방화셔터 도면생성 ← 신규 (자동도면 하위에 배치) +``` + +### 7.2 메뉴 등록 (tinker) + +```php +// 개발 서버 +App\Models\Commons\Menu::create([ + 'tenant_id' => 1, + 'parent_id' => , + 'name' => '방화셔터 도면생성', + 'url' => '/rd/fire-shutter-drawing', + 'icon' => 'shield', + 'sort_order' => <자동도면 다음 순서>, + 'is_active' => true, +]); +``` + +> **주의**: 메뉴 시더 실행 금지 — tinker로 수동 등록 + +--- + +## 8. 향후 확장 가능성 + +| 확장 | 설명 | 우선순위 | +|------|------|---------| +| **STL/OBJ 내보내기** | 3D 프린팅/CAD 호환 | 🟡 중요 | +| **견적 연동** | 도면 파라미터 → 견적 자동 산출 | 🔴 필수 | +| **제품 카탈로그 연동** | DB에서 제품별 기본 파라미터 로드 | 🟡 중요 | +| **비교 모드** | 2개 설정을 나란히 비교 | 🟢 권장 | +| **PDF 도면 출력** | A3/A4 도면 양식 포함 출력 | 🟡 중요 | +| **설치 시뮬레이션** | 현장 사진 위에 3D 오버레이 | 🟢 권장 | + +--- + +## 관련 문서 + +- `/home/aweso/sam/docs/features/academy/fire-shutter-image-prompts.md` — 방화셔터 이미지 프롬프트 +- `/home/aweso/sam/docs/samples/방화셔터_견적구조_인터뷰.md` — 견적 구조 인터뷰 +- `/home/aweso/sam/docs/features/quotes/README.md` — 견적 시스템 분석 +- `/home/aweso/sam/docs/projects/quotation/phase-1-5130-analysis/js-formulas.md` — 견적 수식 상세 +- `/home/aweso/sam/mng/resources/views/rd/auto-drawing/index.blade.php` — 기존 자동도면 생성 + +--- + +**최종 업데이트**: 2026-03-08 diff --git a/plans/sound-logo-generator-plan.md b/plans/sound-logo-generator-plan.md new file mode 100644 index 0000000..a3ecf48 --- /dev/null +++ b/plans/sound-logo-generator-plan.md @@ -0,0 +1,637 @@ +# 사운드 로고 생성기 — 기획서 + +> **작성일**: 2026-03-08 +> **상태**: 기획 확정 +> **메뉴명**: 사운드 로고 생성기 +> **라우트**: `GET /rd/sound-logo` +> **담당**: R&D실 + +--- + +## 1. 개요 + +### 1.1 목적 + +1~5초의 **짧고 강렬한 시그니처 사운드(Sound Logo)**를 생성하는 도구. 브라우저 내 Web Audio API 신디사이저와 Google Gemini AI를 결합하여, 누구나 전문적인 사운드 로고를 만들 수 있도록 한다. + +### 1.2 벤치마킹 + +| 브랜드 | 사운드 | 길이 | 특징 | +|--------|--------|------|------| +| Intel | 봉-봉봉봉-봉 | 1.5초 | 5음, 밝고 미래적 | +| Netflix | 타-둠 | 3초 | 2음, 깊은 울림 + 리버브 | +| Samsung | 오버더호라이즌 | 2초 | 5음, 따뜻한 멜로디 | +| McDonald's | 바다바바~ | 2초 | 5음, 경쾌한 리듬 | +| Windows | 시작 사운드 | 3초 | 4음, 화음 진행 | +| 카카오톡 | 카톡~ | 0.5초 | 2음, 귀여운 효과음 | +| T-Mobile | 띠-띠띠-띠-띠 | 1초 | 5음, 단순 반복 | + +### 1.3 핵심 차별점 — AI 어드바이저 엔진 + +단순 신디사이저 도구가 아니라, **Google Gemini AI가 음악 이론 기반으로 조언하고 생성을 도와주는** 지능형 도구. + +``` +┌─────────────────────────────────────────────────────┐ +│ 사운드 로고 생성기 │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 수동 모드 │ │ AI 어시스트│ │ AI 자동 │ │ +│ │ (신디사이저)│ ←→ │ (Gemini) │ ←→ │ (Lyria) │ │ +│ │ │ │ │ │ │ │ +│ │ 음표 직접 │ │ 브랜드 분석│ │ 프롬프트→ │ │ +│ │ 배치/편집 │ │ 음악 추천 │ │ AI 음악 │ │ +│ │ │ │ 코드 제안 │ │ 직접 생성 │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ ↕ ↕ ↕ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Web Audio API 재생 엔진 │ │ +│ │ (실시간 미리듣기 + WAV 내보내기) │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 3가지 모드 설계 + +### 2.1 모드 A — 수동 모드 (신디사이저) + +> 음표를 직접 배치하고 파라미터를 조절하여 사운드를 만드는 전통적 방식 + +| 기능 | 설명 | +|------|------| +| 음표 시퀀서 | 음표(C4, E4 등) + 길이(0.05~2초) + 쉼표를 시각적 배열로 편집 | +| 신디사이저 4종 | Sine(부드러움), Square(8bit), Triangle(따뜻함), Sawtooth(날카로움) | +| ADSR 엔벨로프 | Attack(0~500ms), Decay(0~1s), Sustain(0~1), Release(0~3s) | +| 화음(Chord) | 동시에 여러 음 재생 (C+E+G = C Major 등) | +| 이펙트 | 리버브, 딜레이, 로우패스/하이패스 필터 | +| 파형 시각화 | Canvas 실시간 파형 + 스펙트럼 표시 | + +**기술 기반**: Web Audio API (`OscillatorNode`, `GainNode`, `BiquadFilterNode`, `ConvolverNode`) + +### 2.2 모드 B — AI 어시스트 (Gemini 텍스트) + +> 브랜드 정보를 입력하면 Gemini가 **음악 이론 기반으로 사운드 로고를 설계**해 주고, 사용자가 미세 조정 + +**입력 → AI 분석 → 음표 데이터 출력 → Web Audio 재생** + +``` +사용자 입력 Gemini 분석·추천 출력 +┌───────────────┐ ┌──────────────────────┐ ┌──────────────┐ +│ 브랜드명 │ │ │ │ │ +│ 업종/분위기 │──요청──→ │ 1. 브랜드 성격 분석 │──JSON──→ │ 음표 시퀀스 │ +│ 키워드 │ │ 2. 조성/스케일 추천 │ │ BPM, 조성 │ +│ 참고 브랜드 │ │ 3. 음표 시퀀스 생성 │ │ 신디 파라미터 │ +│ 원하는 느낌 │ │ 4. ADSR 파라미터 제안 │ │ ADSR 값 │ +└───────────────┘ │ 5. 이펙트 추천 │ │ 이펙트 설정 │ + │ 6. 근거 설명 │ │ │ + └──────────────────────┘ └──────────────┘ + ↓ + Web Audio 재생 + ↓ + 사용자 미세 조정 +``` + +**Gemini 프롬프트 설계**: + +``` +당신은 사운드 브랜딩 전문가이자 음악 이론가입니다. +다음 브랜드 정보를 분석하여 1~5초 사운드 로고를 설계해 주세요. + +[브랜드 정보] +- 브랜드명: {name} +- 업종: {industry} +- 브랜드 성격: {personality} (예: 혁신적, 신뢰, 친근함) +- 참고 사운드: {reference} (예: 인텔처럼 밝은 느낌) +- 원하는 길이: {duration}초 + +[응답 형식 - 반드시 JSON으로] +{ + "analysis": "브랜드 분석 설명 (한글)", + "reasoning": "이 사운드를 추천하는 음악 이론적 근거 (한글)", + "key": "C", + "scale": "major", + "bpm": 120, + "synth": "sine", + "adsr": { "attack": 0.01, "decay": 0.1, "sustain": 0.7, "release": 0.5 }, + "effects": { "reverb": 0.3, "delay": 0 }, + "notes": [ + { "note": "C5", "duration": 0.2, "velocity": 0.8 }, + { "note": "E5", "duration": 0.2, "velocity": 0.9 }, + { "note": "G5", "duration": 0.15, "velocity": 0.7 }, + { "rest": 0.05 }, + { "chord": ["C5", "E5", "G5"], "duration": 0.8, "velocity": 1.0 } + ], + "variations": [ + { "name": "밝은 버전", "notes": [...] }, + { "name": "차분한 버전", "notes": [...] } + ] +} +``` + +**AI 어시스트 기능 상세**: + +| 기능 | 설명 | +|------|------| +| 브랜드 분석 | 업종·성격 기반 적합한 조성/템포/음색 추천 | +| 음표 시퀀스 생성 | 음악 이론(화성학, 리듬 패턴) 기반 멜로디 제안 | +| 변형 3종 제공 | 밝은/차분한/임팩트 버전 동시 생성 | +| 근거 설명 | "C Major → 신뢰감, 5도 상행 → 상승 에너지" 등 이론 설명 | +| 반복 개선 | "좀 더 밝게" "더 짧게" 등 자연어로 수정 요청 | + +### 2.3 모드 C — AI 자동 생성 (Google Lyria) + +> Google Lyria AI가 프롬프트 기반으로 **실제 음악을 직접 생성** + +**2가지 Lyria 엔진 지원** (기존 API 키로 사용 가능, 별도 발급 불필요): + +| 엔진 | 인증 | 방식 | 특징 | +|------|------|------|------| +| **Lyria RealTime** (권장) | 기존 Gemini API 키 | WebSocket (브라우저 직접) | 실시간 스트리밍, BPM/스케일 실시간 조절 | +| Lyria 2 (폴백) | Vertex AI 서비스 계정 | REST API (서버 경유) | 30초 단위 파일 생성, $0.06/30초 | + +#### Lyria RealTime — 브라우저에서 직접 음악 생성 + +``` +브라우저 (Alpine.js) Google API +┌───────────────────┐ ┌──────────────────┐ +│ BPM: 130 │ │ │ +│ Scale: C Major │──WebSocket 연결──→ │ Lyria RealTime │ +│ 프롬프트 입력 │ │ (lyria-realtime- │ +│ │←─2초 단위 오디오── │ exp) │ +│ 🔊 실시간 재생 │ │ │ +│ BPM 슬라이더 조절 │──실시간 파라미터──→ │ 즉시 반영 │ +└───────────────────┘ └──────────────────┘ +``` + +- **모델**: `lyria-realtime-exp` (experimental, Gemini API v1alpha) +- **인증**: 기존 `.env`의 `GEMINI_API_KEY` 그대로 사용 +- **출력**: 48kHz 스테레오, 2초 청크 단위 스트리밍 +- **제어 파라미터**: BPM(60~200), Scale(Key + Mode), 텍스트 프롬프트 +- **지연**: 파라미터 변경 후 최대 2초 이내 반영 + +**Lyria RealTime 프롬프트 예시**: + +``` +Short sonic logo. Bright, futuristic, memorable melody. +Clean synthesizer with light reverb. +Ascending progression, major chord resolution. +``` + +#### Lyria 2 — 서버 경유 파일 생성 (폴백) + +기존 `BgmService::generateWithLyria()` 패턴 재활용. Vertex AI 서비스 계정(`google_service_account.json`) 이미 보유. + +``` +사용자 입력 서버 (Laravel) 결과 +┌───────────────┐ ┌──────────────────────┐ ┌──────────────┐ +│ 분위기 선택 │ │ SoundLogoService │ │ │ +│ 길이 (1~5초) │──POST──→ │ → Lyria 2 API (REST) │──→ │ WAV/MP3 파일 │ +│ 프롬프트 입력 │ │ (Vertex AI) │ │ (다운로드) │ +└───────────────┘ └──────────────────────┘ └──────────────┘ +``` + +> **우선순위**: Lyria RealTime(브라우저) 먼저 시도 → 실패 시 Lyria 2(서버) 폴백. +> 두 엔진 모두 기존 인증 정보로 사용 가능하며 별도 API 키 발급 불필요. + +--- + +## 3. UI 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎵 사운드 로고 생성기 [내 프로젝트 ▾] [저장] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 모드: [🎹 수동] [🤖 AI 어시스트] [✨ AI 자동] │ +│ │ +│ ┌─ 모드 B: AI 어시스트 ──────────────────────────────────────┐ │ +│ │ │ │ +│ │ 브랜드명 [SAM ] │ │ +│ │ 업종 [ERP/MES 통합 솔루션 ] │ │ +│ │ 브랜드 성격 [○혁신적 ●신뢰 ○친근 ○고급 ○에너지] │ │ +│ │ 참고 사운드 [인텔처럼 짧고 밝은 ▾ ] │ │ +│ │ 길이 [━━━●━━━ 2초 ] │ │ +│ │ │ │ +│ │ [🤖 AI에게 사운드 설계 요청] │ │ +│ │ │ │ +│ │ ┌─ AI 분석 결과 ─────────────────────────────────────┐ │ │ +│ │ │ 💡 "SAM은 ERP/MES 통합 솔루션으로, 신뢰와 기술력을 │ │ │ +│ │ │ 전달해야 합니다. C Major 조성으로 안정감을, │ │ │ +│ │ │ 5도 상행 진행으로 성장과 발전을 표현합니다." │ │ │ +│ │ │ │ │ │ +│ │ │ 추천: C Major | BPM 130 | Sine + 리버브 30% │ │ │ +│ │ │ │ │ │ +│ │ │ 변형 3종: │ │ │ +│ │ │ [▶ 밝은 버전] [▶ 차분한 버전] [▶ 임팩트 버전] │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 💬 추가 요청: [좀 더 짧고 강렬하게 해줘 ] [전송] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 음표 에디터 (AI 결과 또는 수동 편집) ──────────────────────┐ │ +│ │ C5(0.2s) E5(0.2s) G5(0.15s) .(0.05s) [CEG](0.8s) │ │ +│ │ ████ ████ ███ · ████████████ │ │ +│ │ [+음표] [+쉼표] [+화음] [삭제] │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 파라미터 ────────────────────────────────────────────────┐ │ +│ │ 음색: ●Sine ○Square ○Triangle ○Sawtooth │ │ +│ │ BPM: ━━━━━━●━━━━━ 130 │ │ +│ │ 리버브: ━━━●━━━━━━━ 30% │ │ +│ │ Attack: ━●━━━━━━━━ 10ms Release: ━━━━━●━━━ 500ms │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 파형 시각화 ─────────────────────────────────────────────┐ │ +│ │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ [▶ 재생] [⏹ 정지] [💾 WAV 저장] [📋 JSON 내보내기] │ +│ │ +├─ 내 사운드 라이브러리 ──────────────────────────────────────────┤ +│ 🎵 SAM 시그널 v1 | 🎵 알림음 v2 | 🎵 전환 효과 | [+ 새로] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 프리셋 템플릿 (10종) + +| 프리셋 | 스타일 | 음표 수 | 길이 | 용도 | +|--------|--------|---------|------|------| +| 기업 시그널 (밝음) | Intel 스타일 | 5 | 1.5초 | 브랜드 인트로 | +| 기업 시그널 (무게감) | Netflix 스타일 | 2 | 3초 | 프리미엄 브랜드 | +| 알림음 (경쾌) | 카카오톡 스타일 | 2~3 | 0.5초 | 푸시 알림 | +| 알림음 (정보) | Slack 스타일 | 3 | 1초 | 시스템 알림 | +| 성공 사운드 | 게임 레벨업 | 4 | 1초 | 작업 완료 | +| 에러 사운드 | 경고음 | 2 | 0.5초 | 오류 알림 | +| 전환 효과 (업) | 상승 스윕 | 연속 | 0.5초 | 화면 전환 | +| 전환 효과 (다운) | 하강 스윕 | 연속 | 0.5초 | 메뉴 닫기 | +| 팡파레 | 축하 | 6 | 2초 | 이벤트/달성 | +| 로딩 루프 | 반복 패턴 | 4 | 2초 | 대기 상태 | + +--- + +## 5. 기술 아키텍처 + +### 5.1 기술 스택 + +| 계층 | 기술 | 설명 | +|------|------|------| +| 프론트엔드 | Blade + Alpine.js | 단일 파일 SPA (디자인 인사이트 패턴) | +| 오디오 엔진 | Web Audio API | `OscillatorNode`, `GainNode`, `ConvolverNode` | +| 시각화 | Canvas API | `AnalyserNode` → 파형/스펙트럼 렌더링 | +| AI 어시스트 | Gemini 2.5 Flash | 텍스트 기반 음악 이론 분석·추천 (모드 B) | +| AI 자동 생성 (1차) | Lyria RealTime | WebSocket 실시간 음악 스트리밍 (모드 C, 브라우저 직접) | +| AI 자동 생성 (폴백) | Lyria 2 (Vertex AI) | REST API 파일 생성 (모드 C, 서버 경유) | +| 저장 | localStorage + DB | 프로젝트 데이터(localStorage), 음원 파일(DB+Storage) | + +### 5.2 기존 인프라 재활용 + +| 기존 코드 | 재활용 내용 | +|----------|-----------| +| `BgmService::generateWithLyria()` | Lyria API 호출 패턴, Vertex AI 인증 흐름 | +| `BgmService::getMoodChord()` | 분위기별 화음 주파수 매핑 | +| `BgmService::generateAmbient()` | FFmpeg 기반 오디오 합성 (서버사이드 폴백) | +| `CmSongController::generateLyrics()` | Gemini API 호출 패턴 (프롬프트 → JSON 응답) | +| `CmSongController::pcmToWav()` | PCM → WAV 변환 유틸리티 | +| `AiConfig::getActiveGemini()` | AI 설정 조회 (API 키, 모델, 리전) | +| `GoogleCloudService::getAccessToken()` | Vertex AI 인증 토큰 | + +### 5.3 파일 구조 + +``` +app/Http/Controllers/Rd/ +└── SoundLogoController.php # 컨트롤러 (AI API 프록시) + +app/Services/Rd/ +└── SoundLogoService.php # Gemini 프롬프트 + Lyria 호출 + +resources/views/rd/sound-logo/ +└── index.blade.php # 단일 파일 SPA + +routes/web.php # 라우트 추가 +``` + +### 5.4 라우트 설계 + +| Method | Path | 컨트롤러 | 설명 | +|--------|------|---------|------| +| `GET` | `/rd/sound-logo` | `soundLogo.index` | 메인 페이지 | +| `POST` | `/rd/sound-logo/ai-assist` | `soundLogo.aiAssist` | Gemini 음악 설계 요청 (모드 B) | +| `POST` | `/rd/sound-logo/ai-refine` | `soundLogo.aiRefine` | Gemini 추가 수정 요청 | +| `POST` | `/rd/sound-logo/ai-generate` | `soundLogo.aiGenerate` | Lyria 음악 생성 (모드 C) | +| `POST` | `/rd/sound-logo/save` | `soundLogo.save` | 사운드 저장 (DB + Storage) | +| `GET` | `/rd/sound-logo/{id}/download` | `soundLogo.download` | WAV 다운로드 | + +### 5.5 Web Audio API 핵심 구조 + +```javascript +// 노드 그래프 +const ctx = new AudioContext(); + +// Oscillator → Gain(ADSR) → Filter → Reverb → Analyser → Destination +function createSynthChain(type, freq, adsr, effects) { + const osc = ctx.createOscillator(); // 음원 + const gain = ctx.createGain(); // ADSR 엔벨로프 + const filter = ctx.createBiquadFilter(); // LP/HP 필터 + const analyser = ctx.createAnalyser(); // 시각화 + + osc.type = type; // sine | square | triangle | sawtooth + osc.frequency.value = freq; // Hz + + // ADSR + const now = ctx.currentTime; + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(1, now + adsr.attack); + gain.gain.linearRampToValueAtTime(adsr.sustain, now + adsr.attack + adsr.decay); + + osc.connect(gain).connect(filter).connect(analyser).connect(ctx.destination); + return { osc, gain, filter, analyser }; +} + +// WAV 내보내기 (OfflineAudioContext) +async function exportWav(notes, params) { + const offline = new OfflineAudioContext(2, 44100 * duration, 44100); + // ... 노트 렌더링 + const buffer = await offline.startRendering(); + const wav = audioBufferToWav(buffer); + // Blob → 다운로드 +} +``` + +### 5.6 음표 ↔ 주파수 매핑 + +```javascript +const NOTE_FREQ = { + 'C3': 130.81, 'D3': 146.83, 'E3': 164.81, 'F3': 174.61, + 'G3': 196.00, 'A3': 220.00, 'B3': 246.94, + 'C4': 261.63, 'D4': 293.66, 'E4': 329.63, 'F4': 349.23, + 'G4': 392.00, 'A4': 440.00, 'B4': 493.88, + 'C5': 523.25, 'D5': 587.33, 'E5': 659.25, 'F5': 698.46, + 'G5': 783.99, 'A5': 880.00, 'B5': 987.77, + 'C6': 1046.50 + // 반음(#/b)도 포함 +}; +``` + +--- + +## 6. Phase별 개발 계획 + +### Phase 1 — MVP (수동 모드 + 프리셋) + +| 항목 | 내용 | +|------|------| +| 음표 시퀀서 UI | 음표 추가/삭제/편집, 드래그 순서 변경 | +| 신디사이저 4종 | Sine, Square, Triangle, Sawtooth | +| ADSR 슬라이더 | Attack, Decay, Sustain, Release | +| 실시간 재생 | Web Audio API 즉시 재생 | +| WAV 내보내기 | OfflineAudioContext → WAV 다운로드 | +| 프리셋 10종 | 즉시 로드 가능한 사운드 패턴 | +| 프로젝트 저장 | localStorage (디자인 인사이트 패턴) | + +### Phase 2 — AI 어시스트 (Gemini) + +| 항목 | 내용 | +|------|------| +| 브랜드 입력 폼 | 브랜드명, 업종, 성격, 참고 사운드, 길이 | +| Gemini 분석 API | 브랜드 → 음악 이론 기반 사운드 설계 JSON | +| 변형 3종 생성 | 밝은/차분한/임팩트 버전 동시 제공 | +| 대화형 개선 | "좀 더 밝게" 등 자연어 추가 수정 | +| 근거 표시 | AI가 이 사운드를 추천하는 이유 설명 | +| 라우트 | `POST /rd/sound-logo/ai-assist`, `ai-refine` | + +### Phase 3 — AI 자동 생성 (Lyria RealTime + Lyria 2) + 고도화 + +| 항목 | 내용 | +|------|------| +| Lyria RealTime 연동 | WebSocket으로 브라우저에서 직접 실시간 음악 생성 (기존 Gemini API 키) | +| Lyria 2 폴백 | Vertex AI REST API로 서버 경유 파일 생성 (기존 서비스 계정) | +| 실시간 BPM/스케일 조절 | Lyria RealTime의 파라미터 실시간 변경 | +| 화음(Chord) 편집 | 동시에 여러 음 배치 | +| 이펙트 체인 | 리버브, 딜레이, 필터 | +| 파형 시각화 | Canvas 실시간 파형 + 스펙트럼 | +| DB 저장 | 사운드 로고를 DB + Storage에 영구 저장 | +| 공유/내보내기 | JSON 설정 공유, MP3 변환 | + +### Phase 4 — 프로급 확장 (선택) + +| 항목 | 내용 | +|------|------| +| 타임라인 UI | 드래그로 음표 배치하는 DAW 스타일 | +| 샘플 기반 음색 | 피아노, 벨, 마림바 등 실제 악기 | +| 드럼/퍼커션 | 노이즈 기반 킥/스네어/하이햇 | +| MIDI 내보내기 | 전문 DAW에서 추가 편집 가능 | +| A/B 비교 | 두 사운드를 나란히 비교 재생 | + +--- + +## 7. Gemini AI 연동 상세 + +### 7.1 API 호출 흐름 + +``` +Frontend (Alpine.js) + │ + │ POST /rd/sound-logo/ai-assist + │ { brand_name, industry, personality, reference, duration } + │ + ▼ +SoundLogoController::aiAssist() + │ + │ 프롬프트 구성 + │ + ▼ +Gemini 2.5 Flash API + │ + │ JSON 응답 (notes, adsr, effects, analysis) + │ + ▼ +SoundLogoController → JsonResponse + │ + │ { success: true, data: { notes, params, analysis, variations } } + │ + ▼ +Frontend: 음표 에디터에 자동 로드 → 즉시 재생 +``` + +### 7.2 대화형 개선 흐름 + +``` +사용자: "좀 더 짧고 강렬하게" + │ + ▼ +POST /rd/sound-logo/ai-refine +{ previous_notes: [...], feedback: "좀 더 짧고 강렬하게" } + │ + ▼ +Gemini: 기존 노트를 분석하고 피드백 반영하여 수정된 JSON 반환 + │ + ▼ +수정된 음표가 에디터에 반영 +``` + +### 7.3 Lyria 음악 생성 흐름 + +#### 7.3.1 Lyria RealTime (브라우저 직접, 권장) + +``` +사용자: "AI 자동 생성" 탭 → Lyria RealTime 선택 + │ + ▼ +브라우저 JavaScript (Alpine.js) + │ + │ WebSocket 연결 (Gemini API v1alpha) + │ wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent + │ API Key: .env GEMINI_API_KEY (서버 경유 프록시) + │ Model: lyria-realtime-exp + │ + ▼ +Lyria RealTime 스트리밍 + │ + │ 2초 청크 단위 48kHz 스테레오 오디오 + │ ← BPM/Scale 실시간 조절 가능 + │ + ▼ +Web Audio API로 실시간 재생 + 녹음(MediaRecorder) → WAV 저장 +``` + +> **API 키 보안**: 브라우저에서 직접 Gemini API 키를 노출하지 않기 위해, +> 서버를 WebSocket 프록시로 사용하거나 `/rd/sound-logo/ws-token` 엔드포인트에서 +> 임시 토큰을 발급하는 방식을 검토한다. + +#### 7.3.2 Lyria 2 (서버 경유, 폴백) + +``` +사용자: Lyria RealTime 실패 시 자동 전환 + │ + ▼ +POST /rd/sound-logo/ai-generate +{ mood: "bright_futuristic", duration: 3, prompt: "..." } + │ + ▼ +SoundLogoService::generateWithLyria() + ├── AiConfig::getActiveGemini() → Vertex AI 설정 확인 + ├── GoogleCloudService::getAccessToken() → OAuth 토큰 + └── Lyria API 호출 → audioContent (base64) + │ + ▼ +WAV 파일 저장 → 다운로드 URL 반환 +``` + +--- + +## 8. 데이터 모델 + +### 8.1 localStorage 구조 (Phase 1~2) + +```json +{ + "sl_projects": [ + { + "id": "sl_1709000000_abc", + "title": "SAM 사운드 로고", + "sounds": [ + { + "id": "snd_001", + "name": "SAM 시그널 v1", + "notes": [ + { "note": "C5", "duration": 0.2, "velocity": 0.8 }, + { "note": "E5", "duration": 0.2, "velocity": 0.9 }, + { "chord": ["C5", "E5", "G5"], "duration": 0.8, "velocity": 1.0 } + ], + "params": { + "synth": "sine", + "bpm": 130, + "adsr": { "attack": 0.01, "decay": 0.1, "sustain": 0.7, "release": 0.5 }, + "effects": { "reverb": 0.3, "delay": 0, "filterFreq": 2000 } + }, + "aiAnalysis": "C Major 조성, 5도 상행으로 신뢰감과 성장 표현", + "createdAt": "2026-03-08T00:00:00.000Z" + } + ], + "createdAt": "2026-03-08T00:00:00.000Z" + } + ], + "sl_current": "sl_1709000000_abc" +} +``` + +### 8.2 DB 테이블 (Phase 3, API 프로젝트에서 마이그레이션) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | bigint | PK | +| `tenant_id` | bigint | FK → tenants | +| `user_id` | bigint | FK → users | +| `name` | varchar(200) | 사운드 이름 | +| `audio_path` | varchar(500) | WAV/MP3 파일 경로 | +| `options` | json | notes, params, aiAnalysis 등 | +| `created_at` | timestamp | | +| `updated_at` | timestamp | | + +--- + +## 9. API 인증 및 키 현황 + +> **별도 API 키 발급 불필요** — 기존 인증 정보로 모든 엔진 사용 가능 + +### 9.1 사용 가능한 인증 정보 + +| 엔진 | 인증 방식 | 설정 위치 | 상태 | +|------|----------|----------|------| +| Gemini 2.5 Flash (모드 B) | API 키 | `.env` `GEMINI_API_KEY` | ✅ 운영 중 | +| Lyria RealTime (모드 C) | 동일 API 키 | `.env` `GEMINI_API_KEY` | ✅ 사용 가능 (experimental) | +| Lyria 2 (모드 C 폴백) | 서비스 계정 | `GOOGLE_APPLICATION_CREDENTIALS` | ✅ 파일 존재 | +| Vertex AI | 프로젝트 ID | `.env` `VERTEX_AI_PROJECT_ID=codebridge-chatbot` | ✅ 설정됨 | + +### 9.2 현재 .env 설정 (관련 항목) + +```env +GEMINI_API_KEY=AIzaSy... # Gemini + Lyria RealTime 공용 +GEMINI_MODEL=gemini-2.5-flash # 텍스트 AI (모드 B) +GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta +VERTEX_AI_PROJECT_ID=codebridge-chatbot # Lyria 2 (폴백) +VERTEX_AI_LOCATION=us-central1 +GOOGLE_APPLICATION_CREDENTIALS=/var/www/sales/apikey/google_service_account.json +``` + +### 9.3 Lyria RealTime API 사양 + +| 항목 | 값 | +|------|------| +| 모델 | `lyria-realtime-exp` | +| API 버전 | `v1alpha` (experimental) | +| 프로토콜 | WebSocket (양방향 스트리밍) | +| 출력 포맷 | 48kHz 스테레오 PCM | +| 청크 크기 | 2초 단위 | +| 제어 파라미터 | BPM (60~200), Scale (Key + Mode) | +| 비용 | 무료 (experimental 기간) | +| 참고 | [공식 문서](https://ai.google.dev/gemini-api/docs/music-generation) | + +### 9.4 Lyria 2 API 사양 (폴백) + +| 항목 | 값 | +|------|------| +| 모델 | `lyria` | +| API | Vertex AI REST (`/publishers/google/models/lyria:predict`) | +| 인증 | 서비스 계정 OAuth 토큰 | +| 출력 포맷 | WAV (base64) | +| 비용 | $0.06 / 30초 | +| 기존 코드 | `BgmService::generateWithLyria()` | + +--- + +## 10. 관련 문서 + +- [AI 관리 종합 가이드](../guides/ai-management.md) — Gemini API 설정, 호출 흐름 +- [R&D 메뉴 개요](../features/rd/README.md) — R&D 메뉴 구조 +- [디자인 인사이트](../features/rd/design-insight.md) — 유사 SPA 패턴 참고 +- [Lyria RealTime 공식 문서](https://ai.google.dev/gemini-api/docs/music-generation) — Gemini API 음악 생성 +- [Lyria 2 Vertex AI 문서](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/lyria-music-generation) — REST API 레퍼런스 +- [Lyria RealTime 개발자 가이드](https://dev.to/googleai/lyria-realtime-the-developers-guide-to-infinite-music-streaming-4m1h) — 구현 튜토리얼 + +--- + +**최종 업데이트**: 2026-03-08 diff --git a/projects/org-chart/README.md b/projects/org-chart/README.md new file mode 100644 index 0000000..238ce52 --- /dev/null +++ b/projects/org-chart/README.md @@ -0,0 +1,317 @@ +# 조직도 관리 시스템 + +> **작성일**: 2026-03-06 +> **상태**: 🟢 v1.0 구현 완료 +> **프로젝트**: MNG 전용 (Blade + Alpine.js + SortableJS) + +--- + +## 1. 개요 + +### 1.1 목적 + +테넌트별 조직 구조를 시각적으로 관리하는 트리형 조직도 시스템. +부서 계층 구조와 직원 배치를 드래그 앤 드롭으로 관리한다. + +### 1.2 주요 기능 + +| 기능 | 설명 | +|------|------| +| 트리형 조직도 | 회사 → 부서 → 하위부서 (무한 depth) 계층 표시 | +| 직원 배치 | 드래그 앤 드롭으로 직원을 부서에 배치/해제 | +| 부서 순서 변경 | 같은 레벨 내 부서 순서 드래그로 변경 | +| 부서 계층 이동 | 부서를 다른 부서 아래로 드래그하여 parent 변경 | +| 부서 숨기기 | 더블클릭 → 숨기기 버튼 → DB 저장 (영구) | +| 임원 필터링 | 대표이사/사장 등은 미배치 목록에서 제외 | + +--- + +## 2. 기술 스택 + +| 구분 | 기술 | +|------|------| +| 백엔드 | Laravel (MNG 프로젝트) | +| 프론트엔드 | Alpine.js + 수동 DOM 렌더링 | +| 드래그 앤 드롭 | SortableJS | +| 스타일 | Tailwind CSS + inline style | +| 데이터 저장 | MySQL `departments`, `employees` 테이블 | + +--- + +## 3. 아키텍처 + +### 3.1 렌더링 방식 + +> **핵심**: Alpine.js `x-for` 대신 수동 `innerHTML` 렌더링을 사용한다. + +SortableJS와 Alpine.js `x-for` 템플릿이 동시에 DOM을 조작하면 **이중 업데이트 버그**가 발생한다. +이를 해결하기 위해 부서 트리는 JavaScript로 HTML 문자열을 생성하고 `innerHTML`로 삽입한다. + +``` +Alpine.js 데이터 변경 + ↓ +renderTree() 호출 + ↓ +기존 SortableJS 인스턴스 destroy + ↓ +buildChildrenHtml(null, 0) → 재귀적 HTML 생성 + ↓ +$refs.deptTree.innerHTML = html + ↓ +$nextTick → initDeptSortables() + initEmpSortables() +``` + +### 3.2 이벤트 처리 + +수동 렌더링된 HTML에는 Alpine 디렉티브가 없으므로 **이벤트 위임(Event Delegation)** 패턴을 사용한다. + +``` +루트 div @click="handleClick($event)" + @dblclick="handleDblClick($event)" + ↓ +e.target.closest('[data-action]') 으로 액션 식별 + ↓ +data-action 값에 따라 분기: + - "unassign" → 직원 미배치 + - "hide-dept" → 부서 숨기기 + - "restore-dept" → 부서 복원 + - "dept-dblclick" → 더블클릭 시 숨기기 버튼 토글 +``` + +### 3.3 순환 참조 방지 + +부서를 자신의 하위로 드래그하면 무한 루프가 발생한다. +`isDescendant(ancestorId, targetId)` 재귀 함수로 이를 차단한다. + +```javascript +// 드래그 대상(dragId)의 자손인 곳으로는 이동 불가 +onMove: (evt) => { + const dragId = parseInt(evt.dragged.dataset.deptId); + const toPid = evt.to.dataset.parentId ? parseInt(evt.to.dataset.parentId) : null; + if (toPid === dragId || this.isDescendant(dragId, toPid)) return false; +} +``` + +--- + +## 4. 파일 구조 + +### 4.1 MNG 프로젝트 + +| 파일 | 역할 | +|------|------| +| `app/Http/Controllers/RdController.php` | 컨트롤러 (7개 메서드) | +| `app/Models/Tenants/Department.php` | 부서 모델 (`options` JSON cast) | +| `resources/views/rd/org-chart.blade.php` | 뷰 (Alpine.js + SortableJS) | +| `routes/web.php` | 라우트 (6개 엔드포인트) | + +### 4.2 API 프로젝트 + +| 파일 | 역할 | +|------|------| +| `database/migrations/2026_03_06_201500_add_options_to_departments_table.php` | `options` JSON 컬럼 추가 | + +--- + +## 5. API 엔드포인트 + +> 모든 엔드포인트는 `rd.` 네임 프리픽스 하위에 위치한다. + +| Method | Route | 컨트롤러 메서드 | 설명 | +|--------|-------|---------------|------| +| GET | `/rd/org-chart` | `orgChart` | 조직도 페이지 | +| POST | `/rd/org-chart/assign` | `orgChartAssign` | 직원 부서 배치 | +| POST | `/rd/org-chart/unassign` | `orgChartUnassign` | 직원 부서 해제 | +| POST | `/rd/org-chart/reorder` | `orgChartReorder` | 직원 일괄 이동 | +| POST | `/rd/org-chart/reorder-depts` | `orgChartReorderDepts` | 부서 순서/계층 변경 | +| POST | `/rd/org-chart/toggle-hide` | `orgChartToggleHide` | 부서 숨기기/표시 토글 | + +### 5.1 요청/응답 형식 + +**부서 배치** (`POST /rd/org-chart/assign`): +```json +{ "employee_id": 1, "department_id": 5 } +→ { "success": true } +``` + +**부서 순서 변경** (`POST /rd/org-chart/reorder-depts`): +```json +{ + "orders": [ + { "id": 1, "parent_id": null, "sort_order": 1 }, + { "id": 2, "parent_id": null, "sort_order": 2 }, + { "id": 3, "parent_id": 1, "sort_order": 1 } + ] +} +→ { "success": true } +``` + +**부서 숨기기** (`POST /rd/org-chart/toggle-hide`): +```json +{ "department_id": 5, "hidden": true } +→ { "success": true } +``` + +--- + +## 6. DB 구조 + +### 6.1 departments 테이블 (관련 컬럼) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | int | PK | +| `tenant_id` | int | 테넌트 FK | +| `parent_id` | int (nullable) | 상위 부서 (null = 최상위) | +| `name` | varchar | 부서명 | +| `code` | varchar | 부서 코드 | +| `is_active` | bool | 활성 여부 | +| `sort_order` | int | 정렬 순서 | +| `options` | json (nullable) | 확장 속성 | + +**`options` 키**: + +| 키 | 타입 | 설명 | +|----|------|------| +| `orgchart_hidden` | boolean | 조직도에서 숨김 여부 | + +### 6.2 employees 테이블 (관련 컬럼) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | int | PK | +| `tenant_id` | int | 테넌트 FK | +| `department_id` | int (nullable) | 소속 부서 (null = 미배치) | +| `display_name` | varchar | 표시 이름 | +| `position_label` | varchar | 직책/직급 | +| `employee_status` | enum | `active`, `leave`, `resigned` | + +--- + +## 7. 프론트엔드 구현 상세 + +### 7.1 Alpine.js 컴포넌트 (`orgChart()`) + +**데이터**: + +| 속성 | 타입 | 설명 | +|------|------|------| +| `departments` | Array | 전체 부서 목록 (서버에서 전달) | +| `employees` | Array | 전체 직원 목록 (서버에서 전달) | +| `hiddenDepts` | Set | 숨긴 부서 ID (DB에서 초기화) | +| `dblClickDept` | int/null | 더블클릭된 부서 ID (숨기기 버튼 표시용) | +| `execTitles` | Array | 임원 직책 목록 (`['대표이사', '사장', '부사장', '회장', '부회장']`) | + +**핵심 메서드**: + +| 메서드 | 설명 | +|--------|------| +| `renderTree()` | SortableJS 파괴 → HTML 재생성 → SortableJS 재초기화 | +| `buildChildrenHtml(parentId, level)` | 재귀적 자식 부서 HTML 생성 | +| `buildNodeHtml(dept, level)` | 단일 부서 카드 HTML (level별 스타일 차등) | +| `buildEmpHtml(emp, isLarge)` | 직원 카드 HTML | +| `isDeptHidden(deptId)` | 부서 또는 상위 부서가 숨김인지 재귀 체크 | +| `isDescendant(ancestorId, targetId)` | 순환 참조 방지 | +| `isExecutive(emp)` | 임원 여부 판별 | + +### 7.2 SortableJS 그룹 + +| 그룹 | 대상 | 핸들 | 기능 | +|------|------|------|------| +| `departments` | `.org-children`, `.org-drop-target` | `.dept-drag-handle` | 부서 순서/계층 변경 | +| `employees` | `.emp-zone`, `#unassigned-zone` | (전체) | 직원 배치/해제 | + +### 7.3 CSS 연결선 + +부서 간 연결선은 CSS `::before`/`::after` 의사 요소로 구현한다. + +``` + 부모 노드 + │ (vertical: div 1px × 24px) + ┌───────┼───────┐ (horizontal: ::before + ::after) + │ │ │ (vertical: div 1px × 24px) + 자식1 자식2 자식3 +``` + +| 선택자 | 역할 | +|--------|------| +| `.org-node-wrap` 내부 div (1px × 24px) | 세로 연결선 | +| `.org-node-wrap:not(:first-child)::before` | 왼쪽 가로선 (left:0 ~ right:50%) | +| `.org-node-wrap:not(:last-child)::after` | 오른쪽 가로선 (left:50% ~ right:0) | +| `:only-child` | 단일 자식이면 가로선 숨김 | + +### 7.4 부서 숨기기 UX 흐름 + +``` +① 부서 헤더 더블클릭 + ↓ +② dblClickDept = dept.id → renderTree() + ↓ +③ 헤더에 빨간 "숨기기" 버튼 표시 + ↓ +④ "숨기기" 클릭 + ↓ +⑤ hiddenDepts.add(id) → renderTree() → POST /toggle-hide (DB 저장) + ↓ +⑥ 해당 부서 + 하위 부서가 트리에서 제거 +⑦ "숨겨진 부서" 패널에 표시 + ↓ +⑧ 패널에서 👁 아이콘 클릭 → hiddenDepts.delete(id) → POST /toggle-hide +``` + +### 7.5 부서 레벨별 스타일 + +| Level | 색상 테마 | 너비 | 아이콘 | +|-------|---------|------|--------| +| 0 (최상위) | 보라 (`#7C3AED`) | 200px | `ri-building-2-line` | +| 1 (중간) | 인디고 (`#6366F1`) | 180px | `ri-git-branch-line` | +| 2+ (하위) | 회색 (`#6B7280`) | 160px | `ri-subtract-line` | + +--- + +## 8. 비즈니스 규칙 + +### 8.1 임원 필터링 + +미배치 직원 목록에서 다음 조건에 해당하면 제외: + +- `position_label`이 `['대표이사', '사장', '부사장', '회장', '부회장']` 중 하나 +- `display_name`이 테넌트의 `ceo_name`과 일치 + +> 이유: 조직도 최상단에 "대표이사 OOO"이 이미 표시되므로 중복 방지 + +### 8.2 부서 숨기기 + +- `departments.options` JSON의 `orgchart_hidden` 키로 저장 +- 숨긴 부서의 **하위 부서도 자동으로 숨겨짐** (`isDeptHidden` 재귀 체크) +- 숨겨진 부서 패널에는 **직접 숨긴 부서만** 표시 (자식은 부모 복원 시 같이 복원) +- 숨기기는 **조직도 표시 전용** — `is_active`와 무관하며, 부서 데이터에 영향 없음 + +### 8.3 직원 표시 형식 + +- 직책이 있으면: `{직책} {이름}` (예: "과장 전진선") +- 직책이 없으면: `{이름}` (예: "김보곤") + +--- + +## 9. 개발 이력 + +| 날짜 | 커밋 | 내용 | +|------|------|------| +| 2026-03-06 | `a12ee886` | CSS 연결선 수정 + 빈 드롭 타겟 숨김 | +| 2026-03-06 | `9fd72e49` | 부서 숨기기 기능 추가 (프론트 전용) | +| 2026-03-06 | `8c8fd5f6` | 대표이사 미배치 제외 + 숨긴 부서 연결선 제거 | +| 2026-03-06 | `81157a15` | 부서 숨기기 상태 DB 저장 (`options.orgchart_hidden`) | + +--- + +## 관련 문서 + +- [rules/department-tree-api.md](../../rules/department-tree-api.md) — 부서 트리 API 규칙 +- [rules/employee-api.md](../../rules/employee-api.md) — 직원 API 규칙 +- [system/database/hr.md](../../system/database/hr.md) — HR 테이블 스키마 +- [standards/options-column-policy.md](../../standards/options-column-policy.md) — options JSON 컬럼 정책 + +--- + +**최종 업데이트**: 2026-03-06 diff --git a/projects/planning-design/README.md b/projects/planning-design/README.md new file mode 100644 index 0000000..12dba75 --- /dev/null +++ b/projects/planning-design/README.md @@ -0,0 +1,157 @@ +# 기획디자인 스토리보드 에디터 + +> **시작일**: 2026-03-07 +> **상태**: 🟢 v1.2 운영 중 (고도화 진행중) +> **경로**: MNG `/rd/planning-design` +> **담당**: Claude Code + 개발팀 + +--- + +## 1. 프로젝트 개요 + +### 1.1 배경 + +ERP 화면 기획서(스토리보드)를 PowerPoint나 Figma 없이 **SAM 관리자 웹 내에서 직접 설계**할 수 있는 도구가 필요했다. 기획자와 개발자가 같은 플랫폼에서 화면을 설계하고, 즉시 HTML/인쇄 출력까지 가능한 올인원 솔루션을 목표로 했다. + +### 1.2 목표 + +- 브라우저 내 Notion/Figma 스타일 블록 에디터 구현 +- ERP 스토리보드 표준 양식 (메뉴트리 + 와이어프레임 + Description) 지원 +- 서버 API 없이 localStorage 기반 즉시 사용 가능 +- HTML 내보내기 및 좌표 기반 WYSIWYG 인쇄 + +### 1.3 기술 스택 + +| 항목 | 선택 | 이유 | +|------|------|------| +| 프레임워크 | Alpine.js | 서버 없이 반응형 SPA, 기존 MNG 스택과 일치 | +| 캔버스 | DOM absolute positioning | Canvas API보다 접근성 좋고 텍스트 편집 용이 | +| 저장 | localStorage | 서버 API 불필요, 즉시 사용 가능 | +| 내보내기 | HTML 생성 + window.print() | 별도 라이브러리 없이 브라우저 내장 기능 활용 | + +--- + +## 2. 구현 이력 + +### v1.0 — 기본 블록 에디터 (2026-03-07) + +| 커밋 | 내용 | +|------|------| +| `063d8c61` | 스토리보드 블록 Undo/Redo 기능 (Ctrl+Z/Y, 50단계 히스토리) | +| `78c8f3f8` | 페이지 복사 기능 (블록 ID 재생성) | +| `a27d9921` | placeholder 색상 옅게 + italic 스타일 | +| `08cc866a` | 블록 툴바를 단위업무 상단으로 이동 (기획서 보기 방해 제거) | +| `20e5ab78` | 메뉴/캔버스 경계 드래그 리사이즈 (80~400px) | +| `7785dfed` | 올가미(마퀴) 다중 선택 + 그룹 이동/복사/삭제 | +| `ff373c71` | 올가미 선택 동작 수정 (캔버스 빈 영역 판별 개선) | +| `95cd217c` | Ctrl+X 잘라내기 기능 (단일/다중) | +| `f4131df0` | Ctrl+X 후 Ctrl+Z 복구 수정 (히스토리 인덱스 보정) | +| `8ff84e7f` | Description 패널 리사이즈 + 번호 마커 블록 (D&D/툴바) | +| `ac5ae6eb` | 좌표 기반 인쇄 + HTML 내보내기 블록 좌표 배치 | + +### v1.1 — 서식 시스템 (2026-03-08) + +| 커밋 | 내용 | +|------|------| +| `dfbbd3a1` | 플로팅 서식 툴바 + 우클릭 컨텍스트 메뉴 추가 | +| `280bfddb` | 블록 서식 CSS 상속 수정 (자식 요소 color inherit) | + +### v1.2 — 작업 영역 극대화 (2026-03-08) + +| 커밋 | 내용 | +|------|------| +| `5e0f1a63` | 좌측 사이드바 접기/펼치기 버튼 추가 | +| `f1202731` | 메뉴트리/Description 패널 접기/펼치기 + 캔버스 폭 자동 확장 (1100→1400px) | +| `a38c017c` | 이미지 블록 업로드를 더블클릭으로 변경 (드래그 중 파일 창 오픈 방지) | + +--- + +## 3. 현재 기능 목록 + +### 3.1 블록 유형 (15종) + +| 분류 | 유형 | +|------|------| +| 텍스트 | Heading (H1), Heading2 (H2), Text, Code | +| 레이아웃 | Divider, Callout | +| 데이터 | Table | +| UI 모형 | Button, Input, Select, Card, Badges | +| 미디어 | Image, Marker (번호 뱃지) | +| 체크 | Todo (체크리스트) | + +### 3.2 편집 기능 + +- 자유 배치 캔버스 (드래그 이동, 핸들 리사이즈) +- 올가미 다중 선택 + 그룹 이동/복사/삭제 +- Undo/Redo (50단계) +- 복사/붙여넣기/잘라내기 (Ctrl+C/V/X) +- 전체 선택 (Ctrl+A) +- 더블클릭 인라인 편집 (contenteditable) +- 이미지 블록 더블클릭 업로드 (드래그 충돌 방지) + +### 3.3 서식 시스템 + +- 플로팅 서식 툴바: 글자색, 배경색, 크기, 굵기, 기울임, 정렬, z-index +- 우클릭 컨텍스트 메뉴: 복제/잘라내기/삭제/색상/정렬/레이어/서식 초기화 + +### 3.4 문서 관리 + +- 멀티 페이지 (추가/복사/삭제/이동) +- ERP 메뉴 트리 편집 (드래그 순서 변경) +- Description 패널 (기능 설명 + 번호 마커 D&D) +- 프리셋/커스텀 템플릿 + +### 3.6 작업 영역 극대화 + +- 좌측 사이드바(메뉴트리) 접기/펼치기 토글 +- Description 패널 접기/펼치기 토글 바 +- 패널 접힘 시 캔버스 폭 자동 확장 (1100px → 1400px) +- sb-editor 패딩 축소 (24px → 12px) + +### 3.5 출력 + +- HTML 파일 내보내기 (좌표 기반 WYSIWYG) +- 인쇄 미리보기 (A4 Landscape, 페이지 분할) + +--- + +## 4. 향후 로드맵 + +| 우선순위 | 기능 | 설명 | 상태 | +|---------|------|------|------| +| 🔴 필수 | DB 저장 | localStorage → DB 전환 (협업, 용량 해결) | ⚪ 대기 | +| 🔴 필수 | 스냅/그리드 정렬 | 블록 간 자석 가이드라인 | ⚪ 대기 | +| 🟡 중요 | 그룹핑 | 여러 블록을 하나의 그룹으로 묶기/풀기 | ⚪ 대기 | +| 🟡 중요 | 레이어 패널 | z-index 순서를 시각적으로 관리 | ⚪ 대기 | +| 🟡 중요 | 리치 텍스트 | 블록 내 부분 텍스트 서식 (인라인 B/I/색상) | ⚪ 대기 | +| 🟢 권장 | PDF 내보내기 | 서버사이드 PDF 생성 | ⚪ 대기 | +| 🟢 권장 | 버전 관리 | 명시적 스냅샷 저장 및 비교 | ⚪ 대기 | +| 🟢 권장 | 공유 링크 | 읽기 전용 공유 URL 생성 | ⚪ 대기 | + +--- + +## 5. 기술적 특이사항 + +### 5.1 단일 파일 아키텍처 + +모든 CSS + HTML + JavaScript가 `index.blade.php` 하나에 포함 (~4,430줄). 서버 API가 없고 localStorage만 사용하므로, 컨트롤러는 뷰만 반환한다. + +### 5.2 CSS 스타일 상속 문제 + +블록 자식 요소에 하드코딩된 `color`가 있어 부모의 인라인 스타일이 무시되는 문제를 CSS attribute selector(`[style*="color"]`)로 해결했다. 향후 블록 유형 추가 시 inherit 규칙도 함께 추가해야 한다. + +### 5.3 localStorage 용량 한계 + +이미지를 base64 Data URL로 저장하므로 대량 사용 시 5~10MB 한계에 도달할 수 있다. DB 저장 전환이 중장기 과제. + +--- + +## 6. 관련 문서 + +- [기술 스펙](../../features/rd/planning-design.md) — 데이터 구조, 블록 유형, CSS 상속 상세 +- [R&D 메뉴 개요](../../features/rd/README.md) — R&D 전체 메뉴 구조 +- [프로젝트 인덱스](../index_projects.md) — 전체 프로젝트 목록 + +--- + +**최종 업데이트**: 2026-03-08 diff --git a/rules/slides/usage-plan/SAM_활용방안.pptx b/rules/slides/usage-plan/SAM_활용방안.pptx new file mode 100644 index 0000000000000000000000000000000000000000..ed996ef54512b2edd90d38b08859909c8ff895fa GIT binary patch literal 279940 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 mFnP)ZVa~x(AXk>6K!_PR7e{Gg#>fVih}UV|*?E literal 0 HcmV?d00001 diff --git a/rules/slides/usage-plan/convert.cjs b/rules/slides/usage-plan/convert.cjs new file mode 100644 index 0000000..b35464a --- /dev/null +++ b/rules/slides/usage-plan/convert.cjs @@ -0,0 +1,31 @@ +const path = require('path'); +module.paths.unshift(path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/node_modules')); + +const html2pptx = require(path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/html2pptx.js')); +const PptxGenJS = require('pptxgenjs'); + +async function main() { + const pres = new PptxGenJS(); + pres.defineLayout({ name: 'CUSTOM_16x9', width: 10, height: 5.625 }); + pres.layout = 'CUSTOM_16x9'; + pres.author = '(주)코드브릿지엑스'; + pres.subject = 'SAM 활용방안 - AI 자동화로 중소 제조업을 혁신하다'; + + const slideDir = __dirname; + const slideFiles = [ + 'slide-01.html', 'slide-02.html', 'slide-03.html', + 'slide-04.html', 'slide-05.html', 'slide-06.html', 'slide-07.html' + ]; + + for (const file of slideFiles) { + const htmlPath = path.join(slideDir, file); + console.log(`Converting: ${file}`); + await html2pptx(htmlPath, pres); + } + + const outputPath = path.join(slideDir, 'SAM_활용방안.pptx'); + await pres.writeFile({ fileName: outputPath }); + console.log(`\nPPTX saved: ${outputPath}`); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/rules/slides/usage-plan/slide-01.html b/rules/slides/usage-plan/slide-01.html new file mode 100644 index 0000000..bfa9d9f --- /dev/null +++ b/rules/slides/usage-plan/slide-01.html @@ -0,0 +1,42 @@ + + + + + + + +
+
+
+ +
+

SAM PROJECT

+
+ +

SAM 활용방안

+

AI 자동화로 중소 제조업을 혁신하다

+ +

방화셔터 제조업 실증 | 80% 공통화 전략 | Multi-tenant SaaS 플랫폼

+ +
+
+

코어 모델 실증

+
+
+

AI 자동화

+
+
+

다산업군 확장

+
+
+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스 | 2026.03

+
+ + diff --git a/rules/slides/usage-plan/slide-02.html b/rules/slides/usage-plan/slide-02.html new file mode 100644 index 0000000..c16747f --- /dev/null +++ b/rules/slides/usage-plan/slide-02.html @@ -0,0 +1,58 @@ + + + + + + + +
+

왜 SAM인가? — Before / After

+

중소 제조업의 현실과 SAM이 제시하는 변화

+
+ +
+
+
+
+ +
+

Before — 기존 방식

+
+

Excel 수기 관리

+

데이터 유실, 버전 혼란, 실시간 공유 불가

+

ERP 도입비 수천만원

+

중소기업에 과도한 초기 투자 부담

+

업체별 커스텀 6개월+

+

도입까지 긴 시간, 업데이트 어려움

+

부서간 정보 단절

+

영업/생산/경영 각자 관리, 의사결정 지연

+
+ +
+
+
+ +
+

After — SAM 도입 후

+
+

시스템 기반 통합 관리

+

실시간 데이터 공유, 단일 진실 공급원(SSOT)

+

월 구독 SaaS

+

초기 비용 최소화, 사용한 만큼 지불

+

멀티테넌시 즉시 입주

+

설정만으로 바로 사용, 지속적 업데이트

+

영업~출고 원스톱 자동화

+

AI가 연결하는 End-to-End 프로세스

+
+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스

+

2 / 7

+
+ + diff --git a/rules/slides/usage-plan/slide-03.html b/rules/slides/usage-plan/slide-03.html new file mode 100644 index 0000000..07e9995 --- /dev/null +++ b/rules/slides/usage-plan/slide-03.html @@ -0,0 +1,108 @@ + + + + + + + +
+

전체 프로세스 — 영업에서 출고까지

+

6단계 비즈니스 플로우와 AI 자동화 포인트

+
+ +
+
+

01

+

영업

+

고객 DB 자동분류

+
+

+
+

02

+

상담

+

STT 음성 기록

+
+

+
+

03

+

견적서

+

AI 자동 산출

+
+

+
+

04

+

수주서

+

자동 전환

+
+

+
+

05

+

작업공정

+

AI 공정 최적화

+
+

+
+

06

+

출고

+

배송 자동화

+
+
+ +
+

경동/주일 실증 현황

+
+
+

단계

+

구현 기능

+

상태

+

AI 적용

+
+
+

영업관리

+

고객/거래처 CRM

+

운영중

+

고객 분류 자동화

+
+
+

상담/문의

+

상담 이력, 음성 입력

+

운영중

+

STT 음성→텍스트

+
+
+

견적서

+

견적 작성/승인/발송

+

운영중

+

AI 견적 산출 (개발중)

+
+
+

수주서

+

견적→수주 연동

+

운영중

+

자동 전환 프로세스

+
+
+

작업공정

+

BOM, 공정 관리

+

개발중

+

AI 공정 최적화 (계획)

+
+
+

출고/배송

+

출고 지시, 배송 추적

+

계획

+

물류 자동화 (계획)

+
+
+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스

+

3 / 7

+
+ + diff --git a/rules/slides/usage-plan/slide-04.html b/rules/slides/usage-plan/slide-04.html new file mode 100644 index 0000000..d6fb551 --- /dev/null +++ b/rules/slides/usage-plan/slide-04.html @@ -0,0 +1,88 @@ + + + + + + + +
+

80% 공통화론 — 핵심 설득 논거

+

중소 제조업 업무의 80%는 업종과 무관하게 동일하다

+
+ +
+
+

공통 업무

+
+
+

80% — 영업, 회계, 인사, 재고, 문서, 품질

+
+
+
+
+

커스텀

+
+
+

20%

+
+
+
+

커스텀 20% = 상품 마스터, 견적 계산식, 공정 시퀀스

+
+ +
+

업종별 확장 시나리오

+
+
+

업종

+

공통 (80%)

+

커스텀 (20%)

+

난이도

+
+
+

방화셔터

+

영업, 견적, 수주, 회계, 인사

+

셔터 규격 계산, 설치 공정

+

실증완료

+
+
+

블라인드

+

영업, 견적, 수주, 회계, 인사

+

원단/슬랫 규격, 재단 공정

+

즉시가능

+
+
+

금속가공

+

영업, 견적, 수주, 회계, 인사

+

소재/두께 단가표, CNC 공정

+

단기적용

+
+
+

식품제조

+

영업, 견적, 수주, 회계, 인사

+

레시피 관리, HACCP, 유통기한

+

중기적용

+
+
+

전자부품

+

영업, 견적, 수주, 회계, 인사

+

PCB BOM, SMT 공정, 검사

+

중기적용

+
+
+ +
+

"상품만 바꾸면 새로운 제조업이 된다. 영업, 회계, 인사, 재고 — 이 80%는 이미 완성되어 있다."

+
+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스

+

4 / 7

+
+ + diff --git a/rules/slides/usage-plan/slide-05.html b/rules/slides/usage-plan/slide-05.html new file mode 100644 index 0000000..87cf510 --- /dev/null +++ b/rules/slides/usage-plan/slide-05.html @@ -0,0 +1,74 @@ + + + + + + + +
+

멀티테넌시 — 하나의 플랫폼, 다수의 기업

+

tenant_id 기반 데이터 격리로 안전하게 다수 기업을 서비스

+
+ +
+
+
+
+

A 기업 (경동)

+
+
+

B 기업 (주일)

+
+
+

C 기업 (금속)

+
+
+

D 기업 (식품)

+
+
+

▼ ▼ ▼ ▼

+
+

SAM 플랫폼

+
+

공유: 코드 100%

+

격리: 데이터 100%

+

기반: tenant_id

+
+
+
+ +
+
+
+
+

비용 절감

+
+

하나의 코드베이스로 N개 기업 서비스. 기업이 늘어도 개발비 동일.

+
+
+
+
+

즉시 입주

+
+

tenant_id 발급 + 기본 설정. 별도 개발 없이 수일 내 사용.

+
+
+
+
+

데이터 격리

+
+

모든 쿼리에 tenant_id 자동 적용. A기업과 B기업 데이터 완전 분리.

+
+
+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스

+

5 / 7

+
+ + diff --git a/rules/slides/usage-plan/slide-06.html b/rules/slides/usage-plan/slide-06.html new file mode 100644 index 0000000..ea16e8a --- /dev/null +++ b/rules/slides/usage-plan/slide-06.html @@ -0,0 +1,68 @@ + + + + + + + +
+

AI 자동화 현황 & 로드맵

+

구현 완료된 AI 기능과 향후 계획

+
+ +
+
+
+
+

구현 완료

+
+
+
+

AI 재무 분석

+

CEO 대시보드에서 매출/비용/손익 AI 분석. Claude API로 자연어 인사이트 제공.

+
+
+

STT 음성 입력

+

상담 메모, 현장 보고를 음성 입력. 자동 텍스트 변환 후 시스템 기록.

+
+
+

Claude Code 개발 자동화

+

SAM 시스템을 Claude Code로 개발. 코드 생성, 리뷰, 배포 자동화.

+
+
+ +
+
+
+

향후 계획

+
+
+
+

AI 견적 자동 생성

+

고객 요구사항 입력 시 과거 데이터 기반 최적 견적 자동 산출.

+
+
+

AI 공정 최적화

+

생산 데이터 분석으로 최적 공정 순서, 자재 배치 제안.

+
+
+

AI 고객 상담

+

FAQ 자동 응답, 견적 문의 자동 접수. 필요 시 담당자 연결.

+
+
+
+ +
+

"공정의 다양성은 천차만별. 이를 AI와 데이터로 정복하는 것이 SAM의 연구 과제다."

+
+ +
+

SAM 활용방안 | (주)코드브릿지엑스

+

6 / 7

+
+ + diff --git a/rules/slides/usage-plan/slide-07.html b/rules/slides/usage-plan/slide-07.html new file mode 100644 index 0000000..ed9da70 --- /dev/null +++ b/rules/slides/usage-plan/slide-07.html @@ -0,0 +1,82 @@ + + + + + + + +
+

로드맵 & 비전

+

방화셔터에서 시작하여 모든 중소 제조업으로

+
+ +
+
+ +
+
+
+

Phase 1

+

코어 실증

+

2025~2026 상반기

+
+

진행중

+
+
+

경동/주일 방화셔터 제조업에서 전 프로세스 실증. 영업→출고 파이프라인 완성.

+
+ +
+
+
+

Phase 2

+

3~5사 확장

+

2026 하반기

+
+

계획

+
+
+

블라인드, 금속가공 등 유사 제조업 3~5사에 멀티테넌시 확장.

+
+ +
+
+
+

Phase 3

+

AI 고도화

+

2027

+
+

계획

+
+
+

AI 견적 자동 산출, AI 공정 최적화, AI 고객 상담 순차 적용.

+
+ +
+
+
+

Phase 4

+

다산업군 플랫폼

+

2028~

+
+

비전

+
+
+

식품, 전자부품 등 다양한 제조업종. 중소 제조업 표준 SaaS.

+
+
+ +
+

"방화셔터에서 시작하여, 모든 중소 제조업의 디지털 전환을 이끄는 SAM"

+

AI 자동화 + 멀티테넌시 + 80% 공통화 = 중소 제조업 혁신 플랫폼 | (주)코드브릿지엑스

+
+ +
+

7 / 7

+
+ + diff --git a/system/ai-automation-vision.md b/system/ai-automation-vision.md new file mode 100644 index 0000000..18534a5 --- /dev/null +++ b/system/ai-automation-vision.md @@ -0,0 +1,174 @@ +# SAM 활용방안 — AI 자동화 비전 + +> **작성일**: 2026-03-02 +> **상태**: 설계 확정 +> **대상**: CEO, 경영진, 전 직원 +> **관련 페이지**: MNG 관리자 → Claude Code → 활용방안 + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM(Smart Automation Management)은 방화셔터 제조업(경동기업, 주일기업)을 코어 모델로 실증한 차세대 ERP/MES 통합 시스템이다. 이 문서는 SAM의 장기 비전과 AI 자동화 전략을 기술한다. + +### 1.2 핵심 논지 + +> "중소 제조업 업무의 80%는 업종과 무관하게 동일하다. 상품만 바꾸면 새로운 제조업이 된다." + +| 항목 | 내용 | +|------|------| +| **코어 모델** | 방화셔터 제조업 (경동/주일 실증 완료) | +| **확장 전략** | 80% 공통 프로세스 + 20% 상품 커스텀 | +| **최종 목표** | Multi-tenant SaaS 플랫폼 (다산업군) | + +--- + +## 2. Before / After — 왜 SAM인가 + +### 2.1 기존 방식의 문제 + +| 문제 | 상세 | +|------|------| +| Excel 수기 관리 | 데이터 유실, 버전 혼란, 실시간 공유 불가 | +| ERP 도입비 수천만원 | 중소기업에 과도한 초기 투자 부담 | +| 업체별 커스텀 6개월+ | 도입까지 긴 시간, 업데이트 어려움 | +| 부서간 정보 단절 | 영업/생산/경영 각자 관리, 의사결정 지연 | + +### 2.2 SAM 도입 후 + +| 개선 | 상세 | +|------|------| +| 시스템 기반 통합 관리 | 실시간 데이터 공유, 단일 진실 공급원(SSOT) | +| 월 구독 SaaS | 초기 비용 최소화, 사용한 만큼 지불 | +| 멀티테넌시 즉시 입주 | 설정만으로 바로 사용, 지속적 업데이트 | +| 영업~출고 원스톱 자동화 | AI가 연결하는 End-to-End 프로세스 | + +--- + +## 3. 전체 프로세스 — 영업에서 출고까지 + +``` +영업 → 상담 → 견적서 → 수주서 → 작업공정 → 출고 + (01) (02) (03) (04) (05) (06) +``` + +### 3.1 각 단계별 AI 자동화 포인트 + +| 단계 | 구현 기능 | AI 적용 | 상태 | +|------|----------|---------|------| +| 영업관리 | 고객/거래처 CRM | 고객 분류 자동화 | 운영중 | +| 상담/문의 | 상담 이력, 음성 입력 | STT 음성→텍스트 변환 | 운영중 | +| 견적서 | 견적 작성/승인/발송 | AI 견적 자동 산출 | 운영중 (AI 개발중) | +| 수주서 | 견적→수주 연동 | 자동 전환 프로세스 | 운영중 | +| 작업공정 | BOM, 공정 관리 | AI 공정 최적화 | 개발중 | +| 출고/배송 | 출고 지시, 배송 추적 | 물류 자동화 | 계획 | + +--- + +## 4. 80% 공통화론 + +### 4.1 업무 구성 비율 + +``` +공통 업무 ██████████████████████████████████████████ 80% +커스텀 ██████████ 20% +``` + +- **공통 80%**: 영업/CRM, 회계/재무, 인사/근태, 재고관리, 문서/전자결재, 품질관리 +- **커스텀 20%**: 상품 마스터, 견적 계산식, 공정 시퀀스 (업종마다 다른 부분) + +### 4.2 업종별 확장 시나리오 + +| 업종 | 공통 (80%) | 커스텀 (20%) | 난이도 | +|------|-----------|-------------|--------| +| 방화셔터 | 영업, 견적, 수주, 회계, 인사 | 셔터 규격 계산, 설치 공정 | 실증완료 | +| 블라인드 | 영업, 견적, 수주, 회계, 인사 | 원단/슬랫 규격, 재단 공정 | 즉시가능 | +| 금속가공 | 영업, 견적, 수주, 회계, 인사 | 소재/두께 단가표, CNC 공정 | 단기적용 | +| 식품제조 | 영업, 견적, 수주, 회계, 인사 | 레시피 관리, HACCP, 유통기한 | 중기적용 | +| 전자부품 | 영업, 견적, 수주, 회계, 인사 | PCB BOM, SMT 공정, 검사 | 중기적용 | + +--- + +## 5. 멀티테넌시 구조 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ A 기업 │ │ B 기업 │ │ C 기업 │ │ D 기업 │ +│ (경동기업) │ │ (주일기업) │ │ (금속가공) │ │ (식품제조) │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + └─────────────┴──────┬──────┴─────────────┘ + │ + ┌─────────▼─────────┐ + │ SAM 플랫폼 │ + │ │ + │ 공유: 코드 100% │ + │ 격리: 데이터 100% │ + │ (tenant_id 기반) │ + └───────────────────┘ +``` + +### 5.1 핵심 이점 + +| 이점 | 상세 | +|------|------| +| **비용 절감** | 하나의 코드베이스로 N개 기업 서비스. 기업이 늘어도 개발비 동일 | +| **즉시 입주** | 새 기업 추가 = tenant_id 발급 + 기본 설정. 별도 개발 없이 수일 내 사용 | +| **데이터 격리** | 모든 쿼리에 tenant_id 자동 적용. A기업이 B기업 데이터에 접근 불가 | + +--- + +## 6. AI 자동화 현황 & 로드맵 + +### 6.1 구현 완료 + +| 기능 | 상세 | +|------|------| +| **AI 재무 분석** | CEO 대시보드에서 매출/비용/손익 AI 분석. Claude API로 자연어 인사이트 제공 | +| **STT 음성 입력** | 상담 메모, 현장 보고를 음성으로 입력. 자동 텍스트 변환 후 시스템 기록 | +| **Claude Code 개발 자동화** | SAM 시스템 자체를 Claude Code로 개발. 코드 생성, 리뷰, 테스트, 배포 자동화 | + +### 6.2 향후 계획 + +| 기능 | 상세 | +|------|------| +| **AI 견적 자동 생성** | 고객 요구사항 입력 시 과거 데이터 기반으로 최적 견적 자동 산출 | +| **AI 공정 최적화** | 생산 데이터 분석으로 최적 공정 순서, 자재 배치 제안. 불량률 예측 및 사전 경고 | +| **AI 고객 상담** | FAQ 자동 응답, 견적 문의 자동 접수. 사람의 개입이 필요한 경우만 담당자 연결 | + +> "공정의 다양성은 천차만별. 이를 AI와 데이터로 정복하는 것이 SAM의 연구 과제다." + +--- + +## 7. 로드맵 — 4단계 비전 + +| Phase | 제목 | 기간 | 상태 | 핵심 목표 | +|-------|------|------|------|----------| +| **Phase 1** | 코어 실증 | 2025~2026 상반기 | 진행중 | 경동/주일 방화셔터에서 영업→출고 전 프로세스 실증 | +| **Phase 2** | 3~5사 확장 | 2026 하반기 | 계획 | 블라인드, 금속가공 등 유사 제조업 멀티테넌시 확장 | +| **Phase 3** | AI 고도화 | 2027 | 계획 | AI 견적 자동 산출, 공정 최적화, 고객 상담 순차 적용 | +| **Phase 4** | 다산업군 플랫폼 | 2028~ | 비전 | 식품, 전자부품 등 다양한 업종. 중소 제조업 표준 SaaS | + +--- + +## 결론 + +> "방화셔터에서 시작하여, 모든 중소 제조업의 디지털 전환을 이끄는 SAM" + +**AI 자동화 + 멀티테넌시 + 80% 공통화 = 중소 제조업 혁신 플랫폼** + +--- + +## 관련 문서 + +| 문서 | 설명 | +|------|------| +| [SAM 프로젝트 개요](../SAM_PROJECT_OVERVIEW_FOR_AI.md) | 기술적 개요 | +| [스케일링 로드맵](scaling-roadmap.md) | 10,000 테넌트 기술 스케일링 | +| [보안 정책](security-policy.md) | 보안 아키텍처 | + +--- + +**최종 업데이트**: 2026-03-02 diff --git a/system/database/codebridge-separation.md b/system/database/codebridge-separation.md new file mode 100644 index 0000000..9022cbe --- /dev/null +++ b/system/database/codebridge-separation.md @@ -0,0 +1,443 @@ +# codebridge DB 분리 + +> **작성일**: 2026-03-07 +> **상태**: 로컬/개발 서버 적용 완료, **운영 서버 코드 revert 상태 — DB 선행 작업 필요** +> **최종 수정**: 2026-03-09 — API 사용 테이블 점검, 로컬/개발 samdb 삭제 완료, 운영 코드 revert + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM 프로젝트의 DB를 **서비스용**과 **내부 관리용**으로 분리한다. + +- **samdb**: React 서비스가 사용하는 테이블 (수주, 견적, 생산, 거래처 등) +- **codebridge**: MNG(관리자 패널)에서만 사용하는 코드브릿지엑스 내부 관리 테이블 + +### 1.2 핵심 원칙 + +- codebridge DB에 테이블을 복사한 후, samdb에서 해당 테이블을 **삭제**하여 실질적 분리 +- MNG 모델에 `$connection = 'codebridge'`를 설정하여 읽기 대상 DB만 변경 +- React/API 서비스에는 **영향 없음** +- 개발 서버: samdb에서 59개 테이블 삭제 완료 (백업: `/home/pro/backup/sam_backup_20260309.sql.gz`) + +### 1.3 분리 기준 + +| 분류 | 대상 DB | 기준 | +|------|---------|------| +| React 서비스 테이블 | samdb (유지) | React 프론트엔드 또는 API에서 사용 | +| MNG 전용 테이블 | codebridge (이동) | MNG에서만 사용, React/API 미참조 | +| 공통 테이블 | samdb (유지) | 양쪽 모두 사용 (users, tenants 등) | +| **API 사용 테이블** | **samdb (유지 필수)** | **API에 모델/서비스/컨트롤러 존재 — 이동 시 데이터 불일치 발생** | + +> **경고: API에서 모델/서비스/컨트롤러로 참조하는 테이블을 codebridge로 이동하면, API는 samdb에 쓰고 MNG는 codebridge에서 읽게 되어 데이터 불일치가 발생한다. 절대 이동 금지.** + +--- + +## 2. codebridge 테이블 목록 (59개) + +> 2026-03-09 점검: API 프로젝트 전체 코드 조사를 통해 API에서 사용하는 22개 테이블을 제외함. 제외된 테이블은 [3절](#3-api-사용-테이블--samdb-유지-필수-22개) 참조. +> Equipment 하위 테이블 4개 추가 (FK 의존성으로 equipments와 동일 DB 필수). + +### Admin (9) + +| 테이블 | 설명 | +|--------|------| +| `admin_api_flows` | API 플로우 정의 | +| `admin_api_flow_runs` | API 플로우 실행 이력 | +| `admin_pm_daily_logs` | PM 일일 로그 | +| `admin_pm_daily_log_entries` | PM 일일 로그 항목 | +| `admin_pm_issues` | PM 이슈 | +| `admin_pm_projects` | PM 프로젝트 | +| `admin_pm_tasks` | PM 태스크 | +| `admin_roadmap_milestones` | 로드맵 마일스톤 | +| `admin_roadmap_plans` | 로드맵 계획 | + +### DevTools (5) + +| 테이블 | 설명 | 비고 | +|--------|------|------| +| `admin_api_bookmarks` | API 북마크 | 기존명 `api_bookmarks` | +| `admin_api_deprecations` | API 지원종료 관리 | 기존명 `api_deprecations` | +| `admin_api_environments` | API 환경 설정 | 기존명 `api_environments` | +| `admin_api_histories` | API 호출 이력 | 기존명 `api_histories` | +| `admin_api_templates` | API 템플릿 | 기존명 `api_templates` | + +### Sales (17) + +| 테이블 | 설명 | +|--------|------| +| `sales_partners` | 영업 파트너 | +| `sales_managers` | 영업 담당자 | +| `sales_manager_documents` | 영업 담당자 문서 | +| `sales_commissions` | 영업 수당 | +| `sales_commission_details` | 영업 수당 상세 | +| `sales_consultations` | 영업 상담 | +| `sales_contract_products` | 계약 제품 | +| `sales_products` | 영업 제품 | +| `sales_product_categories` | 영업 제품 카테고리 | +| `sales_prospects` | 영업 전망 | +| `sales_prospect_consultations` | 전망 상담 | +| `sales_prospect_products` | 전망 제품 | +| `sales_prospect_scenarios` | 전망 시나리오 | +| `sales_records` | 영업 실적 | +| `sales_scenario_checklists` | 시나리오 체크리스트 | +| `sales_tenant_managements` | 테넌트 영업 관리 | +| `tenant_prospects` | 테넌트 전망 | + +### Finance (9) + +| 테이블 | 설명 | +|--------|------| +| `condolence_expenses` | 경조사비 | +| `consulting_fees` | 컨설팅비 | +| `corporate_cards` | 법인카드 | +| `corporate_card_prepayments` | 법인카드 선결제 | +| `customer_settlements` | 고객 정산 | +| `daily_fund_memos` | 일일 자금 메모 | +| `daily_fund_transactions` | 일일 자금 거래 | +| `incomes` | 수입 | +| `vat_records` | 부가세 기록 | + +### ESign (2) + +| 테이블 | 설명 | +|--------|------| +| `esign_field_templates` | 전자서명 필드 템플릿 | +| `esign_field_template_items` | 전자서명 필드 항목 | + +> esign_contracts, esign_audit_logs, esign_sign_fields, esign_signers는 API에서 전자계약 기능으로 사용 중 → samdb 유지 + +### Equipment (6) + +| 테이블 | 설명 | +|--------|------| +| `equipments` | 설비 | +| `equipment_processes` | 설비 공정 | +| `equipment_inspections` | 설비 점검 (FK → equipments) | +| `equipment_inspection_details` | 설비 점검 상세 (FK → equipment_inspections) | +| `equipment_inspection_templates` | 설비 점검 템플릿 (FK → equipments) | +| `equipment_repairs` | 설비 수리 (FK → equipments) | + +> Equipment 하위 4개 테이블은 `equipments`에 FK 의존하므로 반드시 동일 DB에 있어야 한다. + +### HR (1) + +| 테이블 | 설명 | +|--------|------| +| `business_income_payments` | 사업소득 지급 | + +> income_tax_brackets는 API IncomeTaxBracketSeeder에서 초기 데이터 관리 → samdb 유지 + +### System (1) + +| 테이블 | 설명 | +|--------|------| +| `ai_configs` | AI 설정 | + +> ai_pricing_configs, ai_token_usages는 API 모델에서 직접 사용 → samdb 유지 + +### 기타 (9) + +| 테이블 | 설명 | 비고 | +|--------|------|------| +| `biz_cert` | 사업자등록증 | 문서 기존명 `biz_certs` → 실제 테이블명 (단수) | +| `cm_songs` | R&D 곡 관리 | | +| `construction_site_photos` | 시공 현장 사진 | | +| `construction_site_photo_rows` | 시공 사진 행 | | +| `admin_meeting_logs` | 회의 로그 | 문서 기존명 `meeting_logs` → 실제 테이블명 | +| `meeting_minutes` | 회의록 | | +| `meeting_minute_segments` | 회의록 세그먼트 | | +| `interview_knowledges` | 면접 지식 | | +| `sales_records` | 매출 기록 | | + +--- + +## 3. API 사용 테이블 — samdb 유지 필수 (22개) + +> **경고: 아래 테이블은 API 프로젝트에서 모델/서비스/컨트롤러/시더로 직접 참조한다. 절대 codebridge로 이동 금지.** +> +> **2026-03-09 점검**: sam/api 프로젝트 전체 코드 (모델, 컨트롤러, 서비스, 라우트, 시더) 조사 완료. + +### Barobill (12) — 전체 samdb 유지 + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `barobill_billing_records` | BarobillBillingService | 과금 기록 CRUD | +| `barobill_members` | BarobillUsageService | 회원사 사용량 집계 | +| `barobill_monthly_summaries` | BarobillBillingService | 월별 집계 갱신 | +| `barobill_pricing_policies` | BarobillUsageService | 과금 계산 | +| `bank_sync_statuses` | BankSyncStatus 모델 | 동기화 상태 추적 | +| `bank_transactions` | BankTransactionController | 은행 거래 조회/분개 | +| `bank_transaction_overrides` | BankTransactionOverride 모델 | 거래 재정의 | +| `bank_transaction_splits` | BankTransactionController | 은행 거래 분개 | +| `card_transaction_amount_logs` | CardTransactionAmountLog 모델 | 금액 수정 이력 + FK → card_transactions | +| `card_transaction_hides` | CardTransactionHide 모델 | 거래 숨김 + FK → card_transactions | +| `hometax_invoices` | BarobillUsageService | 세금계산서 사용량 집계 | +| `hometax_invoice_journals` | HometaxInvoiceJournal 모델 | 세금계산서 분개 + FK → hometax_invoices | + +> **핵심**: API의 BarobillController, BarobillSettingController, BarobillService, EntertainmentService가 바로빌 테이블을 직접 참조. `barobill_card_transactions` (samdb 유지)와 FK로 연결된 자식 테이블도 분리 불가. + +### ESign (4) — API 전자계약 기능 + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `esign_contracts` | EsignContractController, EsignService | 전자계약 CRUD | +| `esign_audit_logs` | EsignService | 감사 추적 기록 | +| `esign_sign_fields` | EsignService | 서명 위치 데이터 | +| `esign_signers` | EsignService | 서명자 정보/인증 | + +### Audit (2) — API 전사 감사 시스템 + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `audit_logs` | AuditLog 모델, AuditLogService, AuditRollbackService | 전사 DML 감사 | +| `trigger_audit_logs` | TriggerAuditLog 모델, TriggerAuditLogController, RegenerateAuditTriggers 명령 | DB 트리거 감사 + 파티셔닝 관리 | + +### DevTools (1) + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `api_request_logs` | ApiRequestLog 모델, SystemStatService | API 통계 집계 | + +### System (2) + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `ai_pricing_configs` | AiPricingConfig 모델 | AI 서비스 비용 계산 (캐시 기반) | +| `ai_token_usages` | AiTokenUsage 모델 | 멀티테넌트 AI 토큰 사용량 추적 | + +### HR (1) + +| 테이블 | API 사용처 | 사유 | +|--------|-----------|------| +| `income_tax_brackets` | IncomeTaxBracketSeeder | 소득세 구간 초기 데이터 관리 | + +--- + +## 4. 적용 현황 + +### 4.1 환경별 상태 + +| 환경 | codebridge DB | 테이블 복사 | samdb 삭제 | .env 설정 | MNG 코드 | 상태 | +|------|:---:|:---:|:---:|:---:|:---:|------| +| **로컬 Docker** | O | 100개 | **58개 삭제** | O | O (develop) | ✅ 정상 작동, samdb 265개 | +| **개발 서버** | O | 101개 | **63개 삭제** | O | O (develop) | ✅ 정상 작동, samdb 265개 | +| **운영 서버** | **X** | **X** | **X** | **X** | **revert됨** | ⚠️ DB 선행 작업 후 코드 재배포 필요 | + +> **2026-03-09 작업 내역**: +> - API 사용 테이블 22개: codebridge 이동 대상에서 제외 → samdb 유지 +> - `finance_*` 17개 + `barobill_companies` 1개: codebridge에 없는 유령 테이블 → samdb에서만 삭제 +> - Equipment 하위 4개 테이블: FK 의존성으로 codebridge 이동 대상에 추가 (55→59개) +> - **개발 서버 samdb에서 63개 테이블 DROP 완료** (59개 + DevTools 실제 테이블명 4개 추가분) +> - **로컬 samdb에서 58개 테이블 DROP 완료** → 로컬/개발 265개로 동기화 +> - 로컬에 `quality_documents` 등 4개 테이블 구조 동기화 (개발서버에서 복사) +> - 백업: `/home/pro/backup/sam_backup_20260309.sql.gz` (6.3MB) +> +> **테이블명 불일치 발견 (수정 완료)**: +> - `api_bookmarks` → 실제: `admin_api_bookmarks` +> - `meeting_logs` → 실제: `admin_meeting_logs` +> - `biz_certs` → 실제: `biz_cert` (단수형) +> - DevTools 4개: `api_deprecations` → `admin_api_deprecations`, `api_environments` → `admin_api_environments`, `api_histories` → `admin_api_histories`, `api_templates` → `admin_api_templates` +> +> **운영 서버 revert 사유 (2026-03-09)**: +> - MNG main에 codebridge 코드 2건 cherry-pick → Jenkins 배포됨 (빌드 #456, #457) +> - 운영 서버에 codebridge DB가 없는 상태에서 코드 배포 → **59개 모델 사용 페이지 오류 발생 위험** +> - kent가 main에서 revert 2건 push → 운영 서버 정상 복구 +> - **교훈: 운영 서버는 반드시 DB 선행 작업(1~2단계) 완료 후 코드 배포(3단계)** + +### 4.2 코드 변경 사항 + +**config/database.php** — `codebridge` connection 추가: + +```php +'codebridge' => [ + 'driver' => 'mysql', + 'host' => env('CODEBRIDGE_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('CODEBRIDGE_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('CODEBRIDGE_DB_DATABASE', 'codebridge'), + 'username' => env('CODEBRIDGE_DB_USERNAME', env('DB_USERNAME')), + 'password' => env('CODEBRIDGE_DB_PASSWORD', env('DB_PASSWORD')), + // ... (mysql 기본 설정과 동일) +], +``` + +**.env** — 추가 설정: + +``` +CODEBRIDGE_DB_DATABASE=codebridge +``` + +**MNG 모델** — `$connection` 속성 추가 (codebridge 59개만): + +```php +class SalesPartner extends Model +{ + protected $connection = 'codebridge'; // 추가 + protected $table = 'sales_partners'; + // ... +} +``` + +> **주의**: API 사용 테이블 22개에 해당하는 MNG 모델은 `$connection = 'codebridge'`를 설정하지 않는다. 기본 samdb connection을 사용해야 API와 동일한 데이터를 참조한다. + +### 4.3 samdb 테이블 삭제 절차 (개발 서버 완료) + +> Sales 테이블 그룹은 FK 상호 참조가 있어 `FOREIGN_KEY_CHECKS = 0`으로 일괄 삭제. + +```sql +-- FK 체크 비활성화 (Sales, Equipment 등 FK 체인 테이블) +SET FOREIGN_KEY_CHECKS = 0; + +-- 59개 테이블 DROP (codebridge에 복제 완료 확인 후) +DROP TABLE IF EXISTS admin_api_flows, admin_api_flow_runs, ...; + +SET FOREIGN_KEY_CHECKS = 1; +``` + +> **롤백**: 백업에서 특정 테이블만 복원 가능 +> ```bash +> gunzip < /home/pro/backup/sam_backup_20260309.sql.gz | mysql -u codebridge -p sam +> ``` + +--- + +## 5. 운영 서버 적용 절차 (미완료) + +> **전제**: 운영 서버 SSH 접근 + DB root 권한 필요 +> **현재 상태**: 운영 서버 main 코드는 revert 상태 (codebridge 코드 없음). DB 작업 완료 후 코드 재배포 필요. +> **⚠️ 교훈**: 2026-03-09에 DB 없이 코드만 배포하여 장애 위험 발생 → **반드시 DB 선행 후 코드 배포** + +### 순서 (반드시 1 → 2 → 3 → 4 → 5 순서로) + +**1단계: 운영 sam DB 백업** + +```bash +# 운영 서버 접속 후 +mysqldump -u codebridge -p'[운영PW]' sam --single-transaction > ~/backup/sam_backup_$(date +%Y%m%d).sql +gzip ~/backup/sam_backup_$(date +%Y%m%d).sql +``` + +**2단계: codebridge DB 생성 + 59개 테이블 복사** + +```bash +# DB 생성 +mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS codebridge CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + +# DB 계정 권한 부여 +mysql -u root -p -e "GRANT ALL PRIVILEGES ON codebridge.* TO 'codebridge'@'localhost'; FLUSH PRIVILEGES;" + +# sam에서 59개 테이블 구조+데이터 복사 +mysqldump -u codebridge -p sam \ + admin_api_flows admin_api_flow_runs \ + admin_pm_daily_logs admin_pm_daily_log_entries admin_pm_issues admin_pm_projects admin_pm_tasks \ + admin_roadmap_milestones admin_roadmap_plans \ + admin_api_bookmarks admin_api_deprecations admin_api_environments admin_api_histories admin_api_templates \ + sales_partners sales_managers sales_manager_documents sales_commissions sales_commission_details \ + sales_consultations sales_contract_products sales_products sales_product_categories \ + sales_prospects sales_prospect_consultations sales_prospect_products sales_prospect_scenarios \ + sales_records sales_scenario_checklists sales_tenant_managements tenant_prospects \ + condolence_expenses consulting_fees corporate_cards corporate_card_prepayments \ + customer_settlements daily_fund_memos daily_fund_transactions incomes vat_records \ + esign_field_templates esign_field_template_items \ + equipments equipment_process equipment_inspections equipment_inspection_details \ + equipment_inspection_templates equipment_repairs \ + business_income_payments ai_configs \ + biz_cert cm_songs construction_site_photos construction_site_photo_rows \ + admin_meeting_logs meeting_minutes meeting_minute_segments \ + interview_knowledge \ + | mysql -u codebridge -p codebridge +``` + +**3단계: .env 설정** + +```bash +echo 'CODEBRIDGE_DB_DATABASE=codebridge' >> /home/webservice/mng/.env +cd /home/webservice/mng && php artisan config:clear +``` + +**4단계: MNG 코드 재배포 (main cherry-pick)** + +> develop에 codebridge 코드가 있으므로, revert 커밋 이후 develop의 최신 커밋을 cherry-pick. +> 또는 develop의 해당 커밋을 다시 cherry-pick하여 main에 push. + +```bash +# 로컬에서 실행 +cd /home/aweso/sam/mng +git checkout main && git pull origin main +git cherry-pick +git push origin main +git checkout develop +``` + +**5단계: 동작 확인 + samdb 테이블 삭제 (선택)** + +MNG 관리자 페이지에서 영업관리, 설비, 재무 등 주요 메뉴 동작 확인 후, 문제없으면 sam DB에서 59개 테이블 삭제. + +```sql +SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS + admin_api_flows, admin_api_flow_runs, + admin_pm_daily_logs, admin_pm_daily_log_entries, admin_pm_issues, admin_pm_projects, admin_pm_tasks, + admin_roadmap_milestones, admin_roadmap_plans, + admin_api_bookmarks, admin_api_deprecations, admin_api_environments, admin_api_histories, admin_api_templates, + sales_partners, sales_managers, sales_manager_documents, sales_commissions, sales_commission_details, + sales_consultations, sales_contract_products, sales_products, sales_product_categories, + sales_prospects, sales_prospect_consultations, sales_prospect_products, sales_prospect_scenarios, + sales_records, sales_scenario_checklists, sales_tenant_managements, tenant_prospects, + condolence_expenses, consulting_fees, corporate_cards, corporate_card_prepayments, + customer_settlements, daily_fund_memos, daily_fund_transactions, incomes, vat_records, + esign_field_templates, esign_field_template_items, + equipments, equipment_process, equipment_inspections, equipment_inspection_details, + equipment_inspection_templates, equipment_repairs, + business_income_payments, ai_configs, + biz_cert, cm_songs, construction_site_photos, construction_site_photo_rows, + admin_meeting_logs, meeting_minutes, meeting_minute_segments, + interview_knowledge; +SET FOREIGN_KEY_CHECKS = 1; +``` + +> **⚠️ 핵심 주의사항**: +> - 반드시 **1→2→3→4** 순서 (DB 먼저, 코드 나중) +> - 4단계(코드 배포) 전에 3단계(.env)까지 완료되어야 함 +> - 5단계(samdb 삭제)는 4단계 동작 확인 후 선택적 수행 + +--- + +## 6. 아키텍처 다이어그램 + +``` + React (사용자) + | + API 서버 (Laravel) + | + ┌─────┴─────┐ + | | + samdb sam_stat + (서비스 DB) (통계 DB) + | + | (공통 + API 사용 테이블: users, tenants, barobill_*, esign_*, audit_* 등) + | + MNG (관리자) + | + ┌─────┴─────┐ + | | + samdb codebridge + (공통 참조) (MNG 전용 59개) +``` + +- **React → API → samdb**: 서비스 트래픽 (수주, 견적, 생산, 바로빌, 전자서명 등) +- **MNG → samdb**: 공통 테이블 (users, tenants, menus) + API 사용 테이블 22개 참조 +- **MNG → codebridge**: MNG 전용 데이터 (영업관리, 재무, 설비, PM 도구 등) + +--- + +## 관련 문서 + +- [database/README.md](README.md) — DB 스키마 전체 현황 +- [codebridge-db-separation-plan.md](/home/aweso/sam/docs/plans/codebridge-db-separation-plan.md) — 분리 작업 계획서 (plans/) + +--- + +**최종 업데이트**: 2026-03-09 (운영 revert 반영, 적용 절차 5단계로 개정)