diff --git a/PLAN_RENEWAL.md b/PLAN_RENEWAL.md new file mode 100644 index 00000000..7d5575a6 --- /dev/null +++ b/PLAN_RENEWAL.md @@ -0,0 +1,680 @@ +# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화 + +## 1. 개요 + +현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다. +각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다. + +### 현재 컴포넌트 현황 (AS-IS) + +| 카테고리 | 파일 수 | 주요 파일들 | +| :------------- | :-----: | :------------------------------------------------------------------ | +| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 | +| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 | +| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 | +| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 | + +--- + +## 2. 통합 전략: 9 Core Widgets + +### A. 입력 위젯 (Input Widgets) - 5종 + +단순 데이터 입력 필드를 통합합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | +| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"
**`source`**: "static" / "code" / "db" / "api"
**`dependency`**: { parentField: "..." } | +| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"
**`format`**: "email", "currency", "biz_no"
**`mask`**: "000-0000-0000" | +| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"
**`range`**: true/false | +| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"
**`rows`**: number | +| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"
**`multiple`**: true/false
**`preview`**: true/false | + +### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종 + +레이아웃 배치와 데이터 시각화를 담당합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 | +| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- | +| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"
**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트
- `viewMode='card'`: **카드 디스플레이**
- `editable=true`: **반복 필드 그룹** | +| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"
**`columns`**: number | - `type='split'`: **화면 분할 패널**
- `type='grid'`: 격자 레이아웃 | +| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 | +| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**
- 특수 비즈니스 로직 플러그인 탑재 | + +### C. Config Panel 통합 전략 (핵심) + +현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다. + +| AS-IS | TO-BE | 방식 | +| :-------------------- | :--------------------- | :------------------------------- | +| TextConfigPanel.tsx | | | +| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 | +| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 | +| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 | +| ... 24개 더 | | | + +--- + +## 3. 구현 시나리오 (속성 기반 변신) + +### Case 1: "테이블을 카드 리스트로 변경" + +- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함. +- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영. + +### Case 2: "단일 선택을 라디오 버튼으로 변경" + +- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가. +- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경. + +### Case 3: "입력 폼에 반복 필드(Repeater) 추가" + +- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정. + +--- + +## 4. 실행 로드맵 (Action Plan) + +### Phase 0: 준비 단계 (1주) + +통합 작업 전 필수 분석 및 설계를 진행합니다. + +- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사) +- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의) +- [ ] `sys_input_type` 테이블 JSON Schema 설계 +- [ ] DynamicConfigPanel 프로토타입 설계 + +### Phase 1: 입력 위젯 통합 (2주) + +가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다. + +- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합 +- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합 +- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합 +- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음) + +### Phase 2: Config Panel 통합 (2주) + +28개의 ConfigPanel을 단일 동적 패널로 통합합니다. + +- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성 +- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장 +- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음) + +### Phase 3: 데이터/레이아웃 위젯 통합 (2주) + +프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다. + +- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발 +- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합 +- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합 + +### Phase 4: 안정화 및 마이그레이션 (2주) + +신규 컴포넌트 안정화 후 점진적 전환을 진행합니다. + +- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드 +- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발 +- [ ] 마이그레이션 테스트 (스테이징 환경) +- [ ] 문서화 및 개발 가이드 작성 + +### Phase 5: 레거시 정리 (추후 결정) + +충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다. + +- [ ] 사용 현황 재분석 (Unified 전환율 확인) +- [ ] 미전환 화면 목록 정리 +- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의) + +--- + +## 5. 데이터 마이그레이션 전략 + +### 5.1 위젯 타입 매핑 테이블 + +기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다. + +| 기존 widgetType | 신규 컴포넌트 | 속성 설정 | +| :-------------- | :------------ | :------------------------------ | +| `text` | UnifiedInput | `type: "text"` | +| `number` | UnifiedInput | `type: "number"` | +| `email` | UnifiedInput | `type: "text", format: "email"` | +| `tel` | UnifiedInput | `type: "text", format: "tel"` | +| `select` | UnifiedSelect | `mode: "dropdown"` | +| `radio` | UnifiedSelect | `mode: "radio"` | +| `checkbox` | UnifiedSelect | `mode: "check"` | +| `date` | UnifiedDate | `type: "date"` | +| `datetime` | UnifiedDate | `type: "datetime"` | +| `textarea` | UnifiedText | `mode: "simple"` | +| `file` | UnifiedMedia | `type: "file"` | +| `image` | UnifiedMedia | `type: "image"` | + +### 5.2 마이그레이션 원칙 + +1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식 +2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가 +3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환 + +--- + +## 6. 기대 효과 + +1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소) +2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel) +3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능 +4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능 + +--- + +## 7. 리스크 및 대응 방안 + +| 리스크 | 영향도 | 대응 방안 | +| :----------------------- | :----: | :-------------------------------- | +| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 | +| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 | +| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 | +| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 | + +--- + +## 8. 현재 컴포넌트 매핑 분석 + +### 8.1 Registry 등록 컴포넌트 전수 조사 (44개) + +현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다. + +#### UnifiedInput으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :------------- | +| text-input | `type: "text"` | | +| number-input | `type: "number"` | | +| slider-basic | `type: "slider"` | 속성 추가 필요 | +| button-primary | `type: "button"` | 별도 검토 | + +#### UnifiedSelect로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------------ | :----------------------------------- | :------------- | +| select-basic | `mode: "dropdown"` | | +| checkbox-basic | `mode: "check"` | | +| radio-basic | `mode: "radio"` | | +| toggle-switch | `mode: "toggle"` | 속성 추가 필요 | +| autocomplete-search-input | `mode: "dropdown", searchable: true` | | +| entity-search-input | `source: "entity"` | | +| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 | +| location-swap-selector | `mode: "swap"` | 특수 UI | + +#### UnifiedDate로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------- | :--- | +| date-input | `type: "date"` | | + +#### UnifiedText로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :--- | +| textarea-basic | `mode: "simple"` | | + +#### UnifiedMedia로 통합 (3개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------------------------ | :--- | +| file-upload | `type: "file"` | | +| image-widget | `type: "image"` | | +| image-display | `type: "image", readonly: true` | | + +#### UnifiedList로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------------------ | :------------ | +| table-list | `viewMode: "table"` | | +| card-display | `viewMode: "card"` | | +| repeater-field-group | `editable: true` | | +| modal-repeater-table | `viewMode: "table", modal: true` | | +| simple-repeater-table | `viewMode: "table", simple: true` | | +| repeat-screen-modal | `viewMode: "card", modal: true` | | +| table-search-widget | `viewMode: "table", searchable: true` | | +| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 | + +#### UnifiedLayout으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------ | :-------------------------- | :------------- | +| split-panel-layout | `type: "split"` | | +| split-panel-layout2 | `type: "split", version: 2` | | +| divider-line | `type: "divider"` | 속성 추가 필요 | +| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 | + +#### UnifiedGroup으로 통합 (5개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------- | :--------------------- | :------------ | +| accordion-basic | `type: "accordion"` | | +| tabs | `type: "tabs"` | | +| section-paper | `type: "section"` | | +| section-card | `type: "card-section"` | | +| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 | + +#### UnifiedBiz로 통합 (7개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------ | :--------------- | +| flow-widget | `type: "flow"` | 플로우 관리 | +| rack-structure | `type: "rack"` | 창고 렉 구조 | +| map | `type: "map"` | 지도 | +| numbering-rule | `type: "numbering"` | 채번 규칙 | +| category-manager | `type: "category"` | 카테고리 관리 | +| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 | +| related-data-buttons | `type: "related-buttons"` | 연관 데이터 | + +#### 별도 검토 필요 (3개) + +| 현재 컴포넌트 | 문제점 | 제안 | +| :-------------------------- | :------------------- | :------------------------------ | +| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 | +| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 | +| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) | + +### 8.2 매핑 분석 결과 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 전체 44개 컴포넌트 분석 결과 │ +├─────────────────────────────────────────────────────────┤ +│ ✅ 즉시 통합 가능 : 36개 (82%) │ +│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │ +│ 🔄 별도 검토 필요 : 3개 (7%) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 8.3 속성 확장 필요 사항 + +#### UnifiedInput 속성 확장 + +```typescript +// 기존 +type: "text" | "number" | "password"; + +// 확장 +type: "text" | "number" | "password" | "slider" | "color" | "button"; +``` + +#### UnifiedSelect 속성 확장 + +```typescript +// 기존 +mode: "dropdown" | "radio" | "check" | "tag"; + +// 확장 +mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; +``` + +#### UnifiedLayout 속성 확장 + +```typescript +// 기존 +type: "grid" | "split" | "flex"; + +// 확장 +type: "grid" | "split" | "flex" | "divider" | "screen-embed"; +``` + +### 8.4 조건부 렌더링 공통화 + +`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다. + +```typescript +// 모든 Unified 컴포넌트에 적용 가능한 공통 속성 +interface BaseUnifiedProps { + // ... 기존 속성 + + /** 조건부 렌더링 설정 */ + conditional?: { + enabled: boolean; + field: string; // 참조할 필드명 + operator: "=" | "!=" | ">" | "<" | "in" | "notIn"; + value: any; // 비교 값 + hideOnFalse?: boolean; // false일 때 숨김 (기본: true) + }; +} +``` + +--- + +## 9. 계층 구조(Hierarchy) 컴포넌트 전략 + +### 9.1 현재 계층 구조 지원 현황 + +DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다: + +| 타입 | 설명 | 예시 | +| :----------------- | :---------------------- | :--------------- | +| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 | +| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 | +| **BOM** | 자재명세서 구조 | 부품 > 하위부품 | +| **TREE** | 일반 트리 | 카테고리 | + +### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트) + +계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다. + +```typescript +interface UnifiedHierarchyProps { + /** 계층 유형 */ + type: "tree" | "org" | "bom" | "cascading"; + + /** 표시 방식 */ + viewMode: "tree" | "table" | "indent" | "dropdown"; + + /** 계층 그룹 코드 (cascading_hierarchy_group 연동) */ + source: string; + + /** 편집 가능 여부 */ + editable?: boolean; + + /** 드래그 정렬 가능 */ + draggable?: boolean; + + /** BOM 수량 표시 (BOM 타입 전용) */ + showQty?: boolean; + + /** 최대 레벨 제한 */ + maxLevel?: number; +} +``` + +### 9.3 활용 예시 + +| 설정 | 결과 | +| :---------------------------------------- | :------------------------- | +| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 | +| `type: "org", viewMode: "tree"` | 조직도 | +| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 | +| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) | + +--- + +## 10. 최종 통합 컴포넌트 목록 (10개) + +| # | 컴포넌트 | 역할 | 커버 범위 | +| :-: | :------------------- | :------------- | :----------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 | +| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 | +| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range | +| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown | +| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio | +| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban | +| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal | +| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 | +| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading | + +--- + +## 11. 연쇄관계 관리 메뉴 통합 전략 + +### 11.1 현재 연쇄관계 관리 현황 + +**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭) + +| 탭 | DB 테이블 | 실제 데이터 | 복잡도 | +| :--------------- | :--------------------------------------- | :---------: | :----: | +| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 | +| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 | +| 조건부 필터 | `cascading_condition` | 0건 | 중간 | +| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 | +| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 | +| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 | + +### 11.2 통합 방향: 속성 기반 vs 공통 정의 + +#### 판단 기준 + +| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 | +| :--------------- | :---------: | :---------: | :----------------------- | +| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** | +| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** | +| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** | +| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** | +| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** | +| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** | + +### 11.3 속성 통합 설계 + +#### 2단계 연쇄 → UnifiedSelect 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 정의 후 참조 + + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 조건부 필터 → 공통 conditional 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 조건 정의 +// cascading_condition 테이블에 저장 + +// TO-BE: 모든 컴포넌트에 공통 속성으로 적용 + +``` + +#### 자동 입력 → autoFill 속성 + +```typescript +// AS-IS: cascading_auto_fill_group 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 상호 배제 → mutualExclusion 속성 + +```typescript +// AS-IS: cascading_mutual_exclusion 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +### 11.4 관리 메뉴 정리 계획 + +| 현재 메뉴 | TO-BE | 비고 | +| :-------------------------- | :----------------------- | :-------------------- | +| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 | +| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 | +| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 | +| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 | +| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 | +| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 | +| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 | + +### 11.5 DB 테이블 정리 (Phase 5) + +| 테이블 | 조치 | 시점 | +| :--------------------------- | :----------------------- | :------ | +| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 | +| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_hierarchy_*` | **유지** | - | +| `category_value_cascading_*` | **유지** (카테고리 관리) | - | + +### 11.6 마이그레이션 스크립트 필요 항목 + +```sql +-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션 +-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서 +-- 해당 컴포넌트의 cascading 속성으로 변환 + +-- 예시: WAREHOUSE_LOCATION 연쇄관계 +-- 이 관계를 사용하는 화면의 컴포넌트에 +-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" } +-- 속성 추가 +``` + +--- + +## 12. 최종 아키텍처 요약 + +### 12.1 통합 컴포넌트 (10개) + +| # | 컴포넌트 | 역할 | +| :-: | :------------------- | :--------------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) | +| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) | +| 3 | **UnifiedDate** | 날짜/시간 입력 | +| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) | +| 5 | **UnifiedMedia** | 파일/미디어 (file, image) | +| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) | +| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) | +| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) | +| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) | + +### 12.2 공통 속성 (모든 컴포넌트에 적용) + +```typescript +interface BaseUnifiedProps { + // 기본 속성 + id: string; + label?: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + + // 스타일 + style?: ComponentStyle; + className?: string; + + // 조건부 렌더링 (conditional-container 대체) + conditional?: { + enabled: boolean; + field: string; + operator: + | "=" + | "!=" + | ">" + | "<" + | "in" + | "notIn" + | "isEmpty" + | "isNotEmpty"; + value: any; + action: "show" | "hide" | "disable" | "enable"; + }; + + // 자동 입력 (autoFill 대체) + autoFill?: { + enabled: boolean; + sourceTable: string; + filterColumn: string; + userField: "companyCode" | "userId" | "deptCode"; + displayColumn: string; + }; + + // 유효성 검사 + validation?: ValidationRule[]; +} +``` + +### 12.3 UnifiedSelect 전용 속성 + +```typescript +interface UnifiedSelectProps extends BaseUnifiedProps { + // 표시 모드 + mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; + + // 데이터 소스 + source: "static" | "code" | "db" | "api" | "entity"; + + // static 소스 + options?: Array<{ value: string; label: string }>; + + // db 소스 + table?: string; + valueColumn?: string; + labelColumn?: string; + + // code 소스 + codeGroup?: string; + + // 연쇄 관계 (cascading_relation 대체) + cascading?: { + parentField: string; // 부모 필드명 + filterColumn: string; // 필터링할 컬럼 + clearOnChange?: boolean; // 부모 변경 시 초기화 + }; + + // 상호 배제 (mutual_exclusion 대체) + mutualExclusion?: { + enabled: boolean; + targetField: string; // 상호 배제 대상 + type: "exclusive" | "inclusive"; + }; + + // 다중 선택 + multiple?: boolean; + maxSelect?: number; +} +``` + +### 12.4 관리 메뉴 정리 결과 + +| AS-IS | TO-BE | +| :---------------------------- | :----------------------------------- | +| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** | +| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 | +| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 | +| - 조건부 필터 | → 공통 conditional 속성 | +| - 자동 입력 | → 공통 autoFill 속성 | +| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 | +| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 | + +--- + +## 13. 주의사항 + +> **기존 컴포넌트 삭제 금지** +> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다. +> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다. + +> **연쇄관계 마이그레이션 필수** +> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를 +> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다. diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index b9528ee0..f826a86a 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -12,12 +12,15 @@ "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", + "bwip-js": "^4.8.0", "compression": "^1.7.4", "cors": "^2.8.5", + "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "html-to-docx": "^1.8.0", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", @@ -2256,6 +2259,93 @@ "node": ">= 8" } }, + "node_modules/@oozcitak/dom": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz", + "integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.5", + "@oozcitak/url": "1.0.0", + "@oozcitak/util": "8.3.4" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz", + "integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "8.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/infra/node_modules/@oozcitak/util": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz", + "integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz", + "integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.3", + "@oozcitak/util": "1.0.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/infra": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz", + "integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==", + "license": "MIT", + "dependencies": { + "@oozcitak/util": "1.0.1" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz", + "integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/url/node_modules/@oozcitak/util": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz", + "integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz", + "integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -4326,6 +4416,12 @@ "node": ">=8" } }, + "node_modules/browser-split": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz", + "integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.26.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", @@ -4445,6 +4541,15 @@ "node": ">=10.16.0" } }, + "node_modules/bwip-js": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz", + "integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==", + "license": "MIT", + "bin": { + "bwip-js": "bin/bwip-js.js" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4521,6 +4626,15 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001745", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", @@ -5202,6 +5316,56 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz", + "integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.0.1", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5216,6 +5380,11 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -5349,6 +5518,27 @@ "node": ">=8.10.0" } }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ent/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5361,6 +5551,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz", + "integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==", + "dependencies": { + "camelize": "^1.0.0", + "string-template": "~0.2.0", + "xtend": "~4.0.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5643,6 +5843,14 @@ "node": ">= 0.6" } }, + "node_modules/ev-store": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz", + "integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==", + "dependencies": { + "individual": "^3.0.0" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -6279,6 +6487,16 @@ "node": "*" } }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -6413,6 +6631,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6443,6 +6671,22 @@ "node": ">=16.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6450,6 +6694,27 @@ "dev": true, "license": "MIT" }, + "node_modules/html-to-docx": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz", + "integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "1.15.6", + "@oozcitak/util": "8.3.4", + "color-name": "^1.1.4", + "html-entities": "^2.3.3", + "html-to-vdom": "^0.7.0", + "image-size": "^1.0.0", + "image-to-base64": "^2.2.0", + "jszip": "^3.7.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "nanoid": "^3.1.25", + "virtual-dom": "^2.1.1", + "xmlbuilder2": "2.1.2" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -6466,6 +6731,106 @@ "node": ">=14" } }, + "node_modules/html-to-vdom": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz", + "integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==", + "license": "ISC", + "dependencies": { + "ent": "^2.0.0", + "htmlparser2": "^3.8.2" + } + }, + "node_modules/html-to-vdom/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-to-vdom/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/html-to-vdom/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/html-to-vdom/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause" + }, + "node_modules/html-to-vdom/node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/html-to-vdom/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -6590,6 +6955,30 @@ "dev": true, "license": "ISC" }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/image-to-base64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz", + "integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.0" + } + }, "node_modules/imap": { "version": "0.8.19", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", @@ -6626,6 +7015,12 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6673,6 +7068,11 @@ "node": ">=0.8.19" } }, + "node_modules/individual": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz", + "integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6854,6 +7254,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -7696,6 +8105,18 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -7812,6 +8233,15 @@ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8177,6 +8607,21 @@ "node": ">=6" } }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -8300,6 +8745,24 @@ "node": ">=12" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-duplexpair": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", @@ -8329,6 +8792,12 @@ "dev": true, "license": "MIT" }, + "node_modules/next-tick": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz", + "integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==", + "license": "MIT" + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -8670,6 +9139,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parchment": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", @@ -9179,6 +9654,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9595,6 +10079,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -9610,6 +10111,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -9744,6 +10251,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10020,6 +10533,11 @@ "node": ">=10" } }, + "node_modules/string-template": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", + "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10685,6 +11203,22 @@ "node": ">= 0.8" } }, + "node_modules/virtual-dom": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz", + "integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==", + "license": "MIT", + "dependencies": { + "browser-split": "0.0.1", + "error": "^4.3.0", + "ev-store": "^7.0.0", + "global": "^4.3.0", + "is-object": "^1.0.1", + "next-tick": "^0.2.2", + "x-is-array": "0.1.0", + "x-is-string": "0.1.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -10862,6 +11396,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/x-is-array": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz", + "integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==" + }, + "node_modules/x-is-string": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", + "integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==" + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xmlbuilder2": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz", + "integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==", + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "1.15.5", + "@oozcitak/infra": "1.0.5", + "@oozcitak/util": "8.3.3" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/dom": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz", + "integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==", + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "1.0.5", + "@oozcitak/url": "1.0.0", + "@oozcitak/util": "8.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz", + "integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/xmlbuilder2/node_modules/@oozcitak/util": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz", + "integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index bacd9fb3..e9ce3729 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -26,12 +26,15 @@ "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", + "bwip-js": "^4.8.0", "compression": "^1.7.4", "cors": "^2.8.5", + "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "html-to-docx": "^1.8.0", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 5c2415ea..e928f96c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 -import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -81,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 +import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -249,7 +249,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 -app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 @@ -257,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 +app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a28712c1..231a7cdc 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3245,6 +3245,7 @@ export const resetUserPassword = async ( /** * 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용) + * column_labels 테이블에서 라벨 정보도 함께 가져옴 */ export async function getTableSchema( req: AuthenticatedRequest, @@ -3264,20 +3265,25 @@ export async function getTableSchema( logger.info("테이블 스키마 조회", { tableName, companyCode }); - // information_schema에서 컬럼 정보 가져오기 + // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 const schemaQuery = ` SELECT - column_name, - data_type, - is_nullable, - column_default, - character_maximum_length, - numeric_precision, - numeric_scale - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position + ic.column_name, + ic.data_type, + ic.is_nullable, + ic.column_default, + ic.character_maximum_length, + ic.numeric_precision, + ic.numeric_scale, + cl.column_label, + cl.display_order + FROM information_schema.columns ic + LEFT JOIN column_labels cl + ON cl.table_name = ic.table_name + AND cl.column_name = ic.column_name + WHERE ic.table_schema = 'public' + AND ic.table_name = $1 + ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position `; const columns = await query(schemaQuery, [tableName]); @@ -3290,9 +3296,10 @@ export async function getTableSchema( return; } - // 컬럼 정보를 간단한 형태로 변환 + // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함) const columnList = columns.map((col: any) => ({ name: col.column_name, + label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용 type: col.data_type, nullable: col.is_nullable === "YES", default: col.column_default, @@ -3387,13 +3394,23 @@ export async function copyMenu( } : undefined; + // 추가 복사 옵션 (카테고리, 코드, 채번규칙 등) + const additionalCopyOptions = req.body.additionalCopyOptions + ? { + copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true, + copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true, + copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, userId, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 27f03c71..c40c6aa5 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -662,6 +662,10 @@ export const getParentOptions = async ( /** * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API + * + * 다중 부모값 지원: + * - parentValue: 단일 값 (예: "공정검사") + * - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열) */ export const getCascadingOptions = async ( req: AuthenticatedRequest, @@ -669,10 +673,26 @@ export const getCascadingOptions = async ( ) => { try { const { code } = req.params; - const { parentValue } = req.query; + const { parentValue, parentValues } = req.query; const companyCode = req.user?.companyCode || "*"; - if (!parentValue) { + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + // parentValues가 있으면 우선 사용 (다중 선택) + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + // 콤마로 구분된 문자열 + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + // 기존 단일 값 호환 + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { return res.json({ success: true, data: [], @@ -714,13 +734,17 @@ export const getCascadingOptions = async ( const relation = relationResult.rows[0]; - // 자식 옵션 조회 + // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용 + // SQL Injection 방지를 위해 파라미터화된 쿼리 사용 + const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', '); + let optionsQuery = ` - SELECT + SELECT DISTINCT ${relation.child_value_column} as value, - ${relation.child_label_column} as label + ${relation.child_label_column} as label, + ${relation.child_filter_column} as parent_value FROM ${relation.child_table} - WHERE ${relation.child_filter_column} = $1 + WHERE ${relation.child_filter_column} IN (${placeholders}) `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) @@ -730,7 +754,8 @@ export const getCascadingOptions = async ( [relation.child_table] ); - const optionsParams: any[] = [parentValue]; + const optionsParams: any[] = [...parentValueArray]; + let paramIndex = parentValueArray.length + 1; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( @@ -738,8 +763,9 @@ export const getCascadingOptions = async ( tableInfoResult.rowCount > 0 && companyCode !== "*" ) { - optionsQuery += ` AND company_code = $2`; + optionsQuery += ` AND company_code = $${paramIndex}`; optionsParams.push(companyCode); + paramIndex++; } // 정렬 @@ -751,9 +777,9 @@ export const getCascadingOptions = async ( const optionsResult = await pool.query(optionsQuery, optionsParams); - logger.info("연쇄 옵션 조회", { + logger.info("연쇄 옵션 조회 (다중 부모값 지원)", { relationCode: code, - parentValue, + parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts new file mode 100644 index 00000000..66250bf9 --- /dev/null +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -0,0 +1,1062 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +/** + * 카테고리 값 연쇄관계 그룹 목록 조회 + */ +export const getCategoryValueCascadingGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM category_value_cascading_group + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + params.push(companyCode); + paramIndex++; + } + + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("카테고리 값 연쇄관계 그룹 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 상세 조회 + */ +export const getCategoryValueCascadingGroupById = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupQuery = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE group_id = $1 + `; + + const groupParams: any[] = [groupId]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingQuery = ` + SELECT + mapping_id, + parent_value_code, + parent_value_label, + child_value_code, + child_value_label, + display_order, + is_active + FROM category_value_cascading_mapping + WHERE group_id = $1 AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [groupId]); + + // 부모 값별로 자식 값 그룹화 + const mappingsByParent: Record = {}; + for (const row of mappingResult.rows) { + const parentKey = row.parent_value_code; + if (!mappingsByParent[parentKey]) { + mappingsByParent[parentKey] = []; + } + mappingsByParent[parentKey].push({ + childValueCode: row.child_value_code, + childValueLabel: row.child_value_label, + displayOrder: row.display_order, + }); + } + + return res.json({ + success: true, + data: { + ...groupResult.rows[0], + mappings: mappingResult.rows, + mappingsByParent, + }, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 관계 코드로 조회 + */ +export const getCategoryValueCascadingByCode = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const params: any[] = [code]; + + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + } + + query += ` LIMIT 1`; + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 코드 조회 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 생성 + */ +export const createCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange = true, + showGroupLabel = true, + emptyParentMessage, + noOptionsMessage, + } = req.body; + + // 필수 필드 검증 + if ( + !relationCode || + !relationName || + !parentTableName || + !parentColumnName || + !childTableName || + !childColumnName + ) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group + WHERE relation_code = $1 AND (company_code = $2 OR company_code = '*')`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO category_value_cascading_group ( + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'Y', $15, NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTableName, + parentColumnName, + parentMenuObjid || null, + childTableName, + childColumnName, + childMenuObjid || null, + clearOnParentChange ? "Y" : "N", + showGroupLabel ? "Y" : "N", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + companyCode, + userId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 생성", { + groupId: result.rows[0].group_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 수정 + */ +export const updateCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange, + showGroupLabel, + emptyParentMessage, + noOptionsMessage, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE category_value_cascading_group SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table_name = COALESCE($3, parent_table_name), + parent_column_name = COALESCE($4, parent_column_name), + parent_menu_objid = COALESCE($5, parent_menu_objid), + child_table_name = COALESCE($6, child_table_name), + child_column_name = COALESCE($7, child_column_name), + child_menu_objid = COALESCE($8, child_menu_objid), + clear_on_parent_change = COALESCE($9, clear_on_parent_change), + show_group_label = COALESCE($10, show_group_label), + empty_parent_message = COALESCE($11, empty_parent_message), + no_options_message = COALESCE($12, no_options_message), + is_active = COALESCE($13, is_active), + updated_by = $14, + updated_date = NOW() + WHERE group_id = $15 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange !== undefined + ? clearOnParentChange + ? "Y" + : "N" + : null, + showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, + emptyParentMessage, + noOptionsMessage, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + groupId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 수정", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 삭제 + */ +export const deleteCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 + await pool.query( + `UPDATE category_value_cascading_group + SET is_active = 'N', updated_by = $1, updated_date = NOW() + WHERE group_id = $2`, + [userId, groupId] + ); + + logger.info("카테고리 값 연쇄관계 그룹 삭제", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 카테고리 값 연쇄관계 매핑 CRUD +// ============================================ + +/** + * 매핑 일괄 저장 (기존 매핑 교체) + */ +export const saveCategoryValueCascadingMappings = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { mappings } = req.body; // [{ parentValueCode, parentValueLabel, childValueCode, childValueLabel, displayOrder }] + + if (!Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "mappings는 배열이어야 합니다.", + }); + } + + // 그룹 존재 확인 + const groupCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group WHERE group_id = $1 AND is_active = 'Y'`, + [groupId] + ); + + if (groupCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 트랜잭션으로 처리 + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 매핑 삭제 (하드 삭제) + await client.query( + `DELETE FROM category_value_cascading_mapping WHERE group_id = $1`, + [groupId] + ); + + // 새 매핑 삽입 + if (mappings.length > 0) { + const insertQuery = ` + INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', NOW()) + `; + + for (const mapping of mappings) { + await client.query(insertQuery, [ + groupId, + mapping.parentValueCode, + mapping.parentValueLabel || null, + mapping.childValueCode, + mapping.childValueLabel || null, + mapping.displayOrder || 0, + companyCode, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 연쇄관계 매핑 저장", { + groupId, + mappingCount: mappings.length, + companyCode, + }); + + return res.json({ + success: true, + message: `${mappings.length}개의 매핑이 저장되었습니다.`, + }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { + error: error.message, + }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +/** + * 카테고리 값 연쇄 옵션 조회 + * 부모 값(들)에 해당하는 자식 카테고리 값 목록 반환 + * 다중 부모값 지원 + */ +export const getCategoryValueCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const { parentValue, parentValues } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map((v) => String(v)); + } else { + parentValueArray = String(parentValues) + .split(",") + .map((v) => v.trim()) + .filter((v) => v); + } + } else if (parentValue) { + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let groupQuery = ` + SELECT group_id, show_group_label + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) + const placeholders = parentValueArray + .map((_, idx) => `$${idx + 2}`) + .join(", "); + + const optionsQuery = ` + SELECT DISTINCT + child_value_code as value, + child_value_label as label, + parent_value_code as parent_value, + parent_value_label as parent_label, + display_order + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND parent_value_code IN (${placeholders}) + AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const optionsResult = await pool.query(optionsQuery, [ + group.group_id, + ...parentValueArray, + ]); + + logger.info("카테고리 값 연쇄 옵션 조회", { + relationCode: code, + parentValues: parentValueArray, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + showGroupLabel: group.show_group_label === "Y", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 부모 카테고리 값 목록 조회 + */ +export const getCategoryValueCascadingParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + parent_table_name, + parent_column_name, + parent_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 부모 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [ + group.parent_table_name, + group.parent_column_name, + ]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.parent_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.parent_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 카테고리 값 조회", { + relationCode: code, + tableName: group.parent_table_name, + columnName: group.parent_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) + */ +export const getCategoryValueCascadingChildOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + child_table_name, + child_column_name, + child_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 자식 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [ + group.child_table_name, + group.child_column_name, + ]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.child_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.child_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("자식 카테고리 값 조회", { + relationCode: code, + tableName: group.child_table_name, + columnName: group.child_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("자식 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "자식 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 + * (테이블 목록에서 코드→라벨 변환에 사용) + */ +export const getCategoryValueCascadingMappingsByTable = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + // 해당 테이블이 자식 테이블인 연쇄관계 그룹 찾기 + let groupQuery = ` + SELECT + group_id, + relation_code, + child_column_name + FROM category_value_cascading_group + WHERE child_table_name = $1 + AND is_active = 'Y' + `; + const groupParams: any[] = [tableName]; + let paramIndex = 2; + + // 멀티테넌시 적용 + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + // 연쇄관계가 없으면 빈 객체 반환 + return res.json({ + success: true, + data: {}, + }); + } + + // 각 그룹의 매핑 조회 + const mappings: Record> = {}; + + for (const group of groupResult.rows) { + const mappingQuery = ` + SELECT DISTINCT + child_value_code as code, + child_value_label as label + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND is_active = 'Y' + ORDER BY child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [group.group_id]); + + if (mappingResult.rowCount && mappingResult.rowCount > 0) { + mappings[group.child_column_name] = mappingResult.rows; + } + } + + logger.info("테이블별 연쇄관계 매핑 조회", { + tableName, + groupCount: groupResult.rowCount, + columnMappings: Object.keys(mappings), + }); + + return res.json({ + success: true, + data: mappings, + }); + } catch (error: any) { + logger.error("테이블별 연쇄관계 매핑 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄관계 매핑 조회에 실패했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 00727f1d..fbb88750 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -424,18 +424,16 @@ export class EntityJoinController { config.referenceTable ); - // 현재 display_column으로 사용 중인 컬럼 제외 + // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) const currentDisplayColumn = config.displayColumn || config.displayColumns[0]; - const availableColumns = columns.filter( - (col) => col.columnName !== currentDisplayColumn - ); - + + // 모든 컬럼 표시 (기본 표시 컬럼도 포함) return { joinConfig: config, tableName: config.referenceTable, currentDisplayColumn: currentDisplayColumn, - availableColumns: availableColumns.map((col) => ({ + availableColumns: columns.map((col) => ({ columnName: col.columnName, columnLabel: col.displayName || col.columnName, dataType: col.dataType, diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts deleted file mode 100644 index 82043964..00000000 --- a/backend-node/src/controllers/orderController.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { getPool } from "../database/db"; -import { logger } from "../utils/logger"; - -/** - * 수주 번호 생성 함수 - * 형식: ORD + YYMMDD + 4자리 시퀀스 - * 예: ORD250114001 - */ -async function generateOrderNumber(companyCode: string): Promise { - const pool = getPool(); - const today = new Date(); - const year = today.getFullYear().toString().slice(2); // 25 - const month = String(today.getMonth() + 1).padStart(2, "0"); // 01 - const day = String(today.getDate()).padStart(2, "0"); // 14 - const dateStr = `${year}${month}${day}`; // 250114 - - // 당일 수주 카운트 조회 - const countQuery = ` - SELECT COUNT(*) as count - FROM order_mng_master - WHERE objid LIKE $1 - AND writer LIKE $2 - `; - - const pattern = `ORD${dateStr}%`; - const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]); - const count = parseInt(result.rows[0]?.count || "0"); - const seq = count + 1; - - return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001 -} - -/** - * 수주 등록 API - * POST /api/orders - */ -export async function createOrder(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { - inputMode, // 입력 방식 - customerCode, // 거래처 코드 - deliveryDate, // 납품일 - items, // 품목 목록 - memo, // 메모 - } = req.body; - - // 멀티테넌시 - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - - // 유효성 검사 - if (!customerCode) { - return res.status(400).json({ - success: false, - message: "거래처 코드는 필수입니다", - }); - } - - if (!items || items.length === 0) { - return res.status(400).json({ - success: false, - message: "품목은 최소 1개 이상 필요합니다", - }); - } - - // 수주 번호 생성 - const orderNo = await generateOrderNumber(companyCode); - - // 전체 금액 계산 - const totalAmount = items.reduce( - (sum: number, item: any) => sum + (item.amount || 0), - 0 - ); - - // 수주 마스터 생성 - const masterQuery = ` - INSERT INTO order_mng_master ( - objid, - partner_objid, - final_delivery_date, - reason, - status, - reg_date, - writer - ) VALUES ($1, $2, $3, $4, $5, NOW(), $6) - RETURNING * - `; - - const masterResult = await pool.query(masterQuery, [ - orderNo, - customerCode, - deliveryDate || null, - memo || null, - "진행중", - `${userId}|${companyCode}`, - ]); - - const masterObjid = masterResult.rows[0].objid; - - // 수주 상세 (품목) 생성 - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const subObjid = `${orderNo}_${i + 1}`; - - const subQuery = ` - INSERT INTO order_mng_sub ( - objid, - order_mng_master_objid, - part_objid, - partner_objid, - partner_price, - partner_qty, - delivery_date, - status, - regdate, - writer - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) - `; - - await pool.query(subQuery, [ - subObjid, - masterObjid, - item.item_code || item.id, // 품목 코드 - customerCode, - item.unit_price || 0, - item.quantity || 0, - item.delivery_date || deliveryDate || null, - "진행중", - `${userId}|${companyCode}`, - ]); - } - - logger.info("수주 등록 성공", { - companyCode, - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }); - - res.json({ - success: true, - data: { - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }, - message: "수주가 등록되었습니다", - }); - } catch (error: any) { - logger.error("수주 등록 오류", { - error: error.message, - stack: error.stack, - }); - res.status(500).json({ - success: false, - message: error.message || "수주 등록 중 오류가 발생했습니다", - }); - } -} - -/** - * 수주 목록 조회 API (마스터 + 품목 JOIN) - * GET /api/orders - */ -export async function getOrders(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { page = "1", limit = "20", searchText = "" } = req.query; - const companyCode = req.user!.companyCode; - - const offset = (parseInt(page as string) - 1) * parseInt(limit as string); - - // WHERE 조건 - const whereConditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - // 멀티테넌시 (writer 필드에 company_code 포함) - if (companyCode !== "*") { - whereConditions.push(`m.writer LIKE $${paramIndex}`); - params.push(`%${companyCode}%`); - paramIndex++; - } - - // 검색 - if (searchText) { - whereConditions.push(`m.objid LIKE $${paramIndex}`); - params.push(`%${searchText}%`); - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; - - // 카운트 쿼리 (고유한 수주 개수) - const countQuery = ` - SELECT COUNT(DISTINCT m.objid) as count - FROM order_mng_master m - ${whereClause} - `; - const countResult = await pool.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.count || "0"); - - // 데이터 쿼리 (마스터 + 품목 JOIN) - const dataQuery = ` - SELECT - m.objid as order_no, - m.partner_objid, - m.final_delivery_date, - m.reason, - m.status, - m.reg_date, - m.writer, - COALESCE( - json_agg( - CASE WHEN s.objid IS NOT NULL THEN - json_build_object( - 'sub_objid', s.objid, - 'part_objid', s.part_objid, - 'partner_price', s.partner_price, - 'partner_qty', s.partner_qty, - 'delivery_date', s.delivery_date, - 'status', s.status, - 'regdate', s.regdate - ) - END - ORDER BY s.regdate - ) FILTER (WHERE s.objid IS NOT NULL), - '[]'::json - ) as items - FROM order_mng_master m - LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid - ${whereClause} - GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer - ORDER BY m.reg_date DESC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `; - - params.push(parseInt(limit as string)); - params.push(offset); - - const dataResult = await pool.query(dataQuery, params); - - logger.info("수주 목록 조회 성공", { - companyCode, - total, - page: parseInt(page as string), - itemCount: dataResult.rows.length, - }); - - res.json({ - success: true, - data: dataResult.rows, - pagination: { - total, - page: parseInt(page as string), - limit: parseInt(limit as string), - }, - }); - } catch (error: any) { - logger.error("수주 목록 조회 오류", { error: error.message }); - res.status(500).json({ - success: false, - message: error.message, - }); - } -} diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index f9162016..c6605d3e 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -12,6 +12,27 @@ import { } from "../types/report"; import path from "path"; import fs from "fs"; +import { + Document, + Packer, + Paragraph, + TextRun, + ImageRun, + Table, + TableRow, + TableCell, + WidthType, + AlignmentType, + VerticalAlign, + BorderStyle, + PageOrientation, + convertMillimetersToTwip, + Header, + Footer, + HeadingLevel, +} from "docx"; +import { WatermarkConfig } from "../types/report"; +import bwipjs from "bwip-js"; export class ReportController { /** @@ -207,11 +228,31 @@ export class ReportController { }); } - // components JSON 파싱 - const layoutData = { - ...layout, - components: layout.components ? JSON.parse(layout.components) : [], - }; + // components 컬럼에서 JSON 파싱 + const parsedComponents = layout.components + ? JSON.parse(layout.components) + : null; + + let layoutData; + // 새 구조 (layoutConfig.pages)인지 확인 + if ( + parsedComponents && + parsedComponents.pages && + Array.isArray(parsedComponents.pages) + ) { + // pages 배열을 직접 포함하여 반환 + layoutData = { + ...layout, + pages: parsedComponents.pages, + components: [], // 호환성을 위해 빈 배열 + }; + } else { + // 기존 구조: components 배열 + layoutData = { + ...layout, + components: parsedComponents || [], + }; + } return res.json({ success: true, @@ -232,16 +273,15 @@ export class ReportController { const data: SaveLayoutRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; - // 필수 필드 검증 + // 필수 필드 검증 (페이지 기반 구조) if ( - !data.canvasWidth || - !data.canvasHeight || - !data.pageOrientation || - !data.components + !data.layoutConfig || + !data.layoutConfig.pages || + data.layoutConfig.pages.length === 0 ) { return res.status(400).json({ success: false, - message: "필수 레이아웃 정보가 누락되었습니다.", + message: "레이아웃 설정이 필요합니다.", }); } @@ -534,6 +574,2585 @@ export class ReportController { return next(error); } } + + /** + * 컴포넌트 데이터를 WORD(DOCX)로 변환 + * POST /api/admin/reports/export-word + */ + async exportToWord(req: Request, res: Response, next: NextFunction) { + try { + const { layoutConfig, queryResults, fileName = "리포트" } = req.body; + + if (!layoutConfig || !layoutConfig.pages) { + return res.status(400).json({ + success: false, + message: "레이아웃 데이터가 필요합니다.", + }); + } + + // mm를 twip으로 변환 + const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); + // px를 twip으로 변환 (1px = 15twip at 96DPI) + const pxToTwip = (px: number) => Math.round(px * 15); + + // 쿼리 결과 맵 + const queryResultsMap: Record< + string, + { fields: string[]; rows: Record[] } + > = queryResults || {}; + + // 컴포넌트 값 가져오기 + const getComponentValue = (component: any): string => { + if (component.queryId && component.fieldName) { + const queryResult = queryResultsMap[component.queryId]; + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const value = queryResult.rows[0][component.fieldName]; + if (value !== null && value !== undefined) { + return String(value); + } + } + return `{${component.fieldName}}`; + } + return component.defaultValue || ""; + }; + + // px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) + // px * 0.75 * 2 = px * 1.5 + const pxToHalfPt = (px: number) => Math.round(px * 1.5); + + // 셀 내용 생성 헬퍼 함수 (가로 배치용) + const createCellContent = ( + component: any, + displayValue: string, + pxToHalfPtFn: (px: number) => number, + pxToTwipFn: (px: number) => number, + queryResultsMapRef: Record< + string, + { fields: string[]; rows: Record[] } + >, + AlignmentTypeRef: typeof AlignmentType, + VerticalAlignRef: typeof VerticalAlign, + BorderStyleRef: typeof BorderStyle, + ParagraphRef: typeof Paragraph, + TextRunRef: typeof TextRun, + ImageRunRef: typeof ImageRun, + TableRef: typeof Table, + TableRowRef: typeof TableRow, + TableCellRef: typeof TableCell, + pageIndex: number = 0, + totalPages: number = 1 + ): (Paragraph | Table)[] => { + const result: (Paragraph | Table)[] = []; + + // Text/Label + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + // 줄바꿈 추가 (break: 1은 줄바꿈 1개) + textChildren.push(new TextRunRef({ break: 1 })); + } + textChildren.push( + new TextRunRef({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + + result.push( + new ParagraphRef({ + alignment, + children: textChildren, + }) + ); + } + + // Image + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + result.push(new ParagraphRef({ children: [] })); + } + } + + // Signature + else if (component.type === "signature") { + const sigFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.showLabel !== false) { + textRuns.push( + new TextRunRef({ + text: (component.labelText || "서명:") + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push( + new TextRunRef({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // Stamp + else if (component.type === "stamp") { + const stampFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.personName) { + textRuns.push( + new TextRunRef({ + text: component.personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + } + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push( + new TextRunRef({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // PageNumber + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + result.push( + new ParagraphRef({ + alignment, + children: [ + new TextRunRef({ + text: pageNumberText, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + font: "맑은 고딕", + }), + ], + }) + ); + } + + // Card 컴포넌트 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidth = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPtFn(component.titleFontSize || 14); + const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const titleColor = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColor = (component.borderColor || "#e5e7eb").replace( + "#", + "" + ); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueFn = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; + } + } + return item.value; + }; + + // 제목 + if (showCardTitle) { + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: cardTitle, + size: titleFontSize, + color: titleColor, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + result.push( + new ParagraphRef({ + border: { + bottom: { + color: borderColor, + space: 1, + style: BorderStyleRef.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들 + for (const item of cardItems) { + const itemValue = getCardValueFn( + item as { label: string; value: string; fieldName?: string } + ); + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: labelFontSize, + color: labelColor, + bold: true, + font: "맑은 고딕", + }), + new TextRunRef({ + text: " ", + size: labelFontSize, + font: "맑은 고딕", + }), + new TextRunRef({ + text: itemValue, + size: valueFontSize, + color: valueColor, + font: "맑은 고딕", + }), + ], + }) + ); + } + } + + // 계산 컴포넌트 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPtFn( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = (component.resultColor || "#2563eb").replace( + "#", + "" + ); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const item = calcItems[i]; + const val = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((item as { operator: string }).operator) { + case "+": + calcResult += val; + break; + case "-": + calcResult -= val; + break; + case "x": + calcResult *= val; + break; + case "÷": + calcResult = val !== 0 ? calcResult / val : calcResult; + break; + } + } + } + + // 테이블로 계산 항목 렌더링 + const calcTableRows = []; + + // 각 항목 + for (const item of calcItems) { + const itemValue = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + columnSpan: 2, + children: [new ParagraphRef({ children: [] })], + borders: { + top: { + style: BorderStyleRef.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + result.push( + new TableRef({ + rows: calcTableRows, + width: { size: pxToTwipFn(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }) + ); + } + + // Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우) + else if (component.type === "barcode" && component.barcodeImageBase64) { + try { + const base64Data = + component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + // 바코드 이미지 생성 실패 시 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: `[${barcodeValue}]`, + size: pxToHalfPtFn(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + } + + // Checkbox 컴포넌트 + else if (component.type === "checkbox") { + // 체크 상태 결정 (쿼리 바인딩 또는 고정값) + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[component.checkboxFieldName]; + // truthy/falsy 값 판정 + if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { + isChecked = true; + } else { + isChecked = false; + } + } + } + + const checkboxSymbol = isChecked ? "☑" : "☐"; + const checkboxLabel = component.checkboxLabel || ""; + const labelPosition = component.checkboxLabelPosition || "right"; + const displayText = labelPosition === "left" + ? `${checkboxLabel} ${checkboxSymbol}` + : `${checkboxSymbol} ${checkboxLabel}`; + + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: displayText.trim(), + size: pxToHalfPtFn(component.fontSize || 14), + font: "맑은 고딕", + color: (component.fontColor || "#374151").replace("#", ""), + }), + ], + }) + ); + } + + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 + else if ( + component.type === "divider" && + component.orientation === "horizontal" + ) { + result.push( + new ParagraphRef({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace("#", ""), + space: 1, + style: BorderStyleRef.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }) + ); + } + + // 기타 (빈 paragraph) + else { + result.push(new ParagraphRef({ children: [] })); + } + + return result; + }; + + // 바코드 이미지 생성 헬퍼 함수 + const generateBarcodeImage = async ( + component: any, + queryResultsMapRef: Record[] }> + ): Promise => { + try { + const barcodeType = component.barcodeType || "CODE128"; + const barcodeColor = (component.barcodeColor || "#000000").replace("#", ""); + const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + + // 바코드 값 결정 (쿼리 바인딩 또는 고정값) + let barcodeValue = component.barcodeValue || "SAMPLE123"; + + // QR코드 다중 필드 모드 + if ( + barcodeType === "QR" && + component.qrUseMultiField && + component.qrDataFields && + component.qrDataFields.length > 0 && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + // 모든 행 포함 모드 + if (component.qrIncludeAllRows) { + const allRowsData: Record[] = []; + qResult.rows.forEach((row) => { + const rowData: Record = {}; + component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + allRowsData.push(rowData); + }); + barcodeValue = JSON.stringify(allRowsData); + } else { + // 단일 행 (첫 번째 행만) + const row = qResult.rows[0]; + const jsonData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + barcodeValue = JSON.stringify(jsonData); + } + } + } + // 단일 필드 바인딩 + else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + // QR코드 + 모든 행 포함 + if (barcodeType === "QR" && component.qrIncludeAllRows) { + const allValues = qResult.rows + .map((row) => { + const val = row[component.barcodeFieldName!]; + return val !== null && val !== undefined ? String(val) : ""; + }) + .filter((v) => v !== ""); + barcodeValue = JSON.stringify(allValues); + } else { + // 단일 행 (첫 번째 행만) + const row = qResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + barcodeValue = String(val); + } + } + } + } + + // bwip-js 바코드 타입 매핑 + const bcidMap: Record = { + "CODE128": "code128", + "CODE39": "code39", + "EAN13": "ean13", + "EAN8": "ean8", + "UPC": "upca", + "QR": "qrcode", + }; + + const bcid = bcidMap[barcodeType] || "code128"; + const isQR = barcodeType === "QR"; + + // 바코드 옵션 설정 + const options: any = { + bcid: bcid, + text: barcodeValue, + scale: 3, + includetext: !isQR && component.showBarcodeText !== false, + textxalign: "center", + barcolor: barcodeColor, + backgroundcolor: barcodeBackground, + }; + + // QR 코드 옵션 + if (isQR) { + options.eclevel = component.qrErrorCorrectionLevel || "M"; + } + + // 바코드 이미지 생성 + const png = await bwipjs.toBuffer(options); + const base64 = png.toString("base64"); + return `data:image/png;base64,${base64}`; + } catch (error) { + console.error("바코드 생성 오류:", error); + return null; + } + }; + + // 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성 + for (const page of layoutConfig.pages) { + if (page.components) { + for (const component of page.components) { + if (component.type === "barcode") { + const barcodeImage = await generateBarcodeImage(component, queryResultsMap); + if (barcodeImage) { + component.barcodeImageBase64 = barcodeImage; + } + } + } + } + } + + // 섹션 생성 (페이지별) + const sortedPages = layoutConfig.pages.sort( + (a: any, b: any) => a.page_order - b.page_order + ); + const totalPagesCount = sortedPages.length; + + const sections = sortedPages.map((page: any, pageIndex: number) => { + const pageWidthTwip = mmToTwip(page.width); + const pageHeightTwip = mmToTwip(page.height); + const marginTopMm = page.margins?.top || 10; + const marginBottomMm = page.margins?.bottom || 10; + const marginLeftMm = page.margins?.left || 10; + const marginRightMm = page.margins?.right || 10; + + const marginTop = mmToTwip(marginTopMm); + const marginBottom = mmToTwip(marginBottomMm); + const marginLeft = mmToTwip(marginLeftMm); + const marginRight = mmToTwip(marginRightMm); + + // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) + const marginLeftPx = marginLeftMm * 3.78; + const marginTopPx = marginTopMm * 3.78; + + // 컴포넌트를 Y좌표순으로 정렬 + const sortedComponents = [...(page.components || [])].sort( + (a: any, b: any) => a.y - b.y + ); + + // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 + const Y_GROUP_THRESHOLD = 30; // px + const componentGroups: any[][] = []; + let currentGroup: any[] = []; + let groupBaseY = -Infinity; + + for (const comp of sortedComponents) { + const compY = comp.y - marginTopPx; + if (currentGroup.length === 0) { + currentGroup.push(comp); + groupBaseY = compY; + } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { + currentGroup.push(comp); + } else { + componentGroups.push(currentGroup); + currentGroup = [comp]; + groupBaseY = compY; + } + } + if (currentGroup.length > 0) { + componentGroups.push(currentGroup); + } + + // 컴포넌트를 Paragraph/Table로 변환 + const children: (Paragraph | Table)[] = []; + + // Y좌표를 spacing으로 변환하기 위한 추적 변수 + let lastBottomY = 0; + + // 각 그룹 처리 + for (const group of componentGroups) { + // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 + const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); + + // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) + const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); + const groupHeight = Math.max( + ...sortedGroup.map((c: any) => c.height) + ); + + // spacing 계산 + const gapFromPrevious = Math.max(0, groupY - lastBottomY); + const spacingBefore = pxToTwip(gapFromPrevious); + + // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 + if (sortedGroup.length > 1) { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + // 각 컴포넌트를 셀로 변환 + const cells: TableCell[] = []; + let prevEndX = 0; + + for (const component of sortedGroup) { + const adjustedX = Math.max(0, component.x - marginLeftPx); + const displayValue = getComponentValue(component); + + // 이전 셀과의 간격을 위한 빈 셀 추가 + if (adjustedX > prevEndX + 5) { + const gapWidth = adjustedX - prevEndX; + cells.push( + new TableCell({ + children: [new Paragraph({ children: [] })], + width: { size: pxToTwip(gapWidth), type: WidthType.DXA }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }) + ); + } + + // 컴포넌트 셀 생성 + const cellContent = createCellContent( + component, + displayValue, + pxToHalfPt, + pxToTwip, + queryResultsMap, + AlignmentType, + VerticalAlign, + BorderStyle, + Paragraph, + TextRun, + ImageRun, + Table, + TableRow, + TableCell, + pageIndex, + totalPagesCount + ); + cells.push( + new TableCell({ + children: cellContent, + width: { + size: pxToTwip(component.width), + type: WidthType.DXA, + }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + verticalAlign: VerticalAlign.TOP, + }) + ); + prevEndX = adjustedX + component.width; + } + + // 테이블 행 생성 + const rowTable = new Table({ + rows: [new TableRow({ children: cells })], + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + children.push(rowTable); + lastBottomY = groupY + groupHeight; + continue; + } + + // 단일 컴포넌트 처리 (기존 로직) + const component = sortedGroup[0]; + const displayValue = getComponentValue(component); + const adjustedX = Math.max(0, component.x - marginLeftPx); + const adjustedY = groupY; + + // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) + const indentLeft = pxToTwip(adjustedX); + + // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + textChildren.push(new TextRun({ break: 1 })); + } + textChildren.push( + new TextRun({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: + component.fontWeight === "bold" || + component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + + // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 + const textCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: textChildren, + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const textTable = new Table({ + rows: [new TableRow({ children: [textCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(textTable); + lastBottomY = adjustedY + component.height; + } + + // Image 컴포넌트 + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + lastBottomY = adjustedY + component.height; + } catch (imgError) { + console.error("이미지 처리 오류:", imgError); + } + } + + // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 + else if (component.type === "divider") { + if (component.orientation === "horizontal") { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + // 테이블 셀로 감싸서 너비 제한 + const dividerCell = new TableCell({ + children: [ + new Paragraph({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace( + "#", + "" + ), + space: 1, + style: BorderStyle.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + + const dividerTable = new Table({ + rows: [new TableRow({ children: [dividerCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + children.push(dividerTable); + lastBottomY = adjustedY + component.height; + } + } + + // Signature 컴포넌트 + else if (component.type === "signature") { + const labelText = component.labelText || "서명:"; + const showLabel = component.showLabel !== false; + const sigFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (showLabel) { + textRuns.push( + new TextRun({ + text: labelText + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("서명 이미지 오류:", imgError); + textRuns.push( + new TextRun({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + } else { + textRuns.push( + new TextRun({ + text: "_".repeat(20), + size: sigFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // Stamp 컴포넌트 + else if (component.type === "stamp") { + const personName = component.personName || ""; + const stampFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (personName) { + textRuns.push( + new TextRun({ + text: personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = + component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + height: Math.round( + Math.min(component.width, component.height) * 0.75 + ), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("도장 이미지 오류:", imgError); + textRuns.push( + new TextRun({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + } else { + textRuns.push( + new TextRun({ + text: "(인)", + color: "DC2626", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: textRuns, + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPagesCount}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const pageNumFontSize = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 테이블 셀로 감싸서 width와 indent 정확히 적용 + const pageNumCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: [ + new TextRun({ + text: pageNumberText, + size: pageNumFontSize, + color: (component.fontColor || "#000000").replace( + "#", + "" + ), + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const pageNumTable = new Table({ + rows: [new TableRow({ children: [pageNumCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(pageNumTable); + lastBottomY = adjustedY + component.height; + } + + // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidthPx = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPt(component.titleFontSize || 14); + const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); + const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); + const titleColorCard = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColorCard = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColorCard = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColorCard = ( + component.borderColor || "#e5e7eb" + ).replace("#", ""); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueLocal = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + return row[item.fieldName] !== undefined + ? String(row[item.fieldName]) + : item.value; + } + } + return item.value; + }; + + const cardParagraphs: Paragraph[] = []; + + // 제목 + if (showCardTitle) { + cardParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: cardTitle, + size: titleFontSize, + color: titleColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + cardParagraphs.push( + new Paragraph({ + border: { + bottom: { + color: borderColorCard, + space: 1, + style: BorderStyle.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들을 테이블로 구성 (라벨 + 값) + const itemRows = cardItems.map( + (item: { label: string; value: string; fieldName?: string }) => { + const itemValue = getCardValueLocal(item); + return new TableRow({ + children: [ + new TableCell({ + width: { + size: pxToTwip(labelWidthPx), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: item.label, + size: labelFontSizeCard, + color: labelColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + new TableCell({ + width: { + size: pxToTwip(component.width - labelWidthPx - 16), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: itemValue, + size: valueFontSizeCard, + color: valueColorCard, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }); + } + ); + + const itemsTable = new Table({ + rows: itemRows, + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // 전체를 하나의 테이블 셀로 감싸기 + const cardCell = new TableCell({ + children: [...cardParagraphs, itemsTable], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: + component.showCardBorder !== false + ? { + top: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + bottom: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + left: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + right: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + } + : { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const cardTable = new Table({ + rows: [new TableRow({ children: [cardCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(cardTable); + lastBottomY = adjustedY + component.height; + } + + // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPt( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = ( + component.resultColor || "#2563eb" + ).replace("#", ""); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (num: number): string => { + if (numberFormat === "none") return String(num); + if (numberFormat === "comma") return num.toLocaleString(); + if (numberFormat === "currency") + return num.toLocaleString() + currencySuffix; + return String(num); + }; + + // 쿼리 바인딩된 값 가져오기 + const getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[item.fieldName]; + return typeof val === "number" + ? val + : parseFloat(String(val)) || 0; + } + } + return typeof item.value === "number" + ? item.value + : parseFloat(String(item.value)) || 0; + }; + + // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) + let calcResult = 0; + if (calcItems.length > 0) { + // 첫 번째 항목은 기준값 + calcResult = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const calcItem = calcItems[i]; + const val = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((calcItem as { operator: string }).operator) { + case "+": + calcResult += val; + break; + case "-": + calcResult -= val; + break; + case "x": + calcResult *= val; + break; + case "÷": + calcResult = val !== 0 ? calcResult / val : calcResult; + break; + } + } + } + + // 테이블 행 생성 + const calcTableRows: TableRow[] = []; + + // 각 항목 행 + for (const calcItem of calcItems) { + const itemValue = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: calcItem.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + columnSpan: 2, + children: [new Paragraph({ children: [] })], + borders: { + top: { + style: BorderStyle.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + const calcTable = new Table({ + rows: calcTableRows, + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + children.push(calcTable); + lastBottomY = adjustedY + component.height; + } + + // Barcode 컴포넌트 + else if (component.type === "barcode") { + if (component.barcodeImageBase64) { + try { + const base64Data = + component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + children.push( + new Paragraph({ + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (imgError) { + console.error("바코드 이미지 오류:", imgError); + // 바코드 이미지 생성 실패 시 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: `[${barcodeValue}]`, + size: pxToHalfPt(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + } else { + // 바코드 이미지가 없는 경우 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: `[${barcodeValue}]`, + size: pxToHalfPt(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + lastBottomY = adjustedY + component.height; + } + + // Checkbox 컴포넌트 + else if (component.type === "checkbox") { + // 체크 상태 결정 (쿼리 바인딩 또는 고정값) + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[component.checkboxFieldName]; + // truthy/falsy 값 판정 + if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { + isChecked = true; + } else { + isChecked = false; + } + } + } + + const checkboxSymbol = isChecked ? "☑" : "☐"; + const checkboxLabel = component.checkboxLabel || ""; + const labelPosition = component.checkboxLabelPosition || "right"; + const displayText = labelPosition === "left" + ? `${checkboxLabel} ${checkboxSymbol}` + : `${checkboxSymbol} ${checkboxLabel}`; + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + children.push( + new Paragraph({ + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: displayText.trim(), + size: pxToHalfPt(component.fontSize || 14), + font: "맑은 고딕", + color: (component.fontColor || "#374151").replace("#", ""), + }), + ], + }) + ); + + lastBottomY = adjustedY + component.height; + } + + // Table 컴포넌트 + else if (component.type === "table" && component.queryId) { + const queryResult = queryResultsMap[component.queryId]; + if ( + queryResult && + queryResult.rows && + queryResult.rows.length > 0 + ) { + // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 + if (spacingBefore > 0 || indentLeft > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [], + }) + ); + } + + const columns = + component.tableColumns && component.tableColumns.length > 0 + ? component.tableColumns + : queryResult.fields.map((field: string) => ({ + field, + header: field, + align: "left", + width: undefined, + })); + + // 테이블 폰트 사이즈 (기본 12px) + const tableFontSize = pxToHalfPt(component.fontSize || 12); + + // 헤더 행 + const headerCells = columns.map( + (col: { header: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: col.header, + bold: true, + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + shading: { + fill: ( + component.headerBackgroundColor || "#f3f4f6" + ).replace("#", ""), + }, + verticalAlign: VerticalAlign.CENTER, + }) + ); + const headerRow = new TableRow({ children: headerCells }); + + // 데이터 행 + const dataRows = queryResult.rows.map( + (row: Record) => + new TableRow({ + children: columns.map( + (col: { field: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: String(row[col.field] ?? ""), + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + verticalAlign: VerticalAlign.CENTER, + }) + ), + }) + ); + + const table = new Table({ + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + rows: [headerRow, ...dataRows], + }); + children.push(table); + lastBottomY = adjustedY + component.height; + } + } + } + + // 빈 페이지 방지 + if (children.length === 0) { + children.push(new Paragraph({ children: [] })); + } + + // 워터마크 헤더 생성 (전체 페이지 공유 워터마크) + const watermark: WatermarkConfig | undefined = layoutConfig.watermark; + let headers: { default?: Header } | undefined; + + if (watermark?.enabled && watermark.type === "text" && watermark.text) { + // 워터마크 색상을 hex로 변환 (alpha 적용) + const opacity = watermark.opacity ?? 0.3; + const fontColor = watermark.fontColor || "#CCCCCC"; + // hex 색상에서 # 제거 + const cleanColor = fontColor.replace("#", ""); + + headers = { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: watermark.text, + size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용 + color: cleanColor, + bold: true, + }), + ], + }), + ], + }), + }; + } + + return { + properties: { + page: { + size: { + width: pageWidthTwip, + height: pageHeightTwip, + orientation: + page.width > page.height + ? PageOrientation.LANDSCAPE + : PageOrientation.PORTRAIT, + }, + margin: { + top: marginTop, + bottom: marginBottom, + left: marginLeft, + right: marginRight, + }, + }, + }, + headers, + children, + }; + }); + + // Document 생성 + const doc = new Document({ + sections, + }); + + // Buffer로 변환 + const docxBuffer = await Packer.toBuffer(doc); + + // 파일명 인코딩 (한글 지원) + const timestamp = new Date().toISOString().slice(0, 10); + const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`); + + // DOCX 파일로 응답 + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + res.setHeader( + "Content-Disposition", + `attachment; filename*=UTF-8''${safeFileName}` + ); + res.setHeader("Content-Length", docxBuffer.length); + + return res.send(docxBuffer); + } catch (error: any) { + console.error("WORD 변환 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "WORD 변환에 실패했습니다.", + }); + } + } } export default new ReportController(); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 75e225e6..60a0af08 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons } }; +/** + * 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) + */ +export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + + const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 카테고리 값 목록 조회 (메뉴 스코프 적용) * diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 66c70a77..04fa1add 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -767,11 +767,12 @@ export async function getTableData( const tableManagementService = new TableManagementService(); - // 🆕 현재 사용자 필터 적용 + // 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용) let enhancedSearch = { ...search }; - if (autoFilter?.enabled && req.user) { - const filterColumn = autoFilter.filterColumn || "company_code"; - const userField = autoFilter.userField || "companyCode"; + const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true + if (shouldApplyAutoFilter && req.user) { + const filterColumn = autoFilter?.filterColumn || "company_code"; + const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; if (userValue) { @@ -877,7 +878,17 @@ export async function addTableData( const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { data.company_code = companyCode; - logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`); + logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); + } + } + + // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) + const userId = req.user?.userId; + if (userId && !data.writer) { + const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); + if (hasWriterColumn) { + data.writer = userId; + logger.info(`writer 자동 추가 - ${userId}`); } } diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 5d922dd6..92036080 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -50,3 +50,7 @@ router.get("/data/:groupCode", getAutoFillData); export default router; + + + + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 813dbff1..ed11d3d1 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -46,3 +46,7 @@ router.get("/filtered-options/:relationCode", getFilteredOptions); export default router; + + + + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index be37da49..d74929cb 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -62,3 +62,7 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions); export default router; + + + + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 46bbf427..ce2fbcac 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -50,3 +50,7 @@ router.get("/options/:exclusionCode", getExcludedOptions); export default router; + + + + diff --git a/backend-node/src/routes/categoryValueCascadingRoutes.ts b/backend-node/src/routes/categoryValueCascadingRoutes.ts new file mode 100644 index 00000000..894da819 --- /dev/null +++ b/backend-node/src/routes/categoryValueCascadingRoutes.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import { + getCategoryValueCascadingGroups, + getCategoryValueCascadingGroupById, + getCategoryValueCascadingByCode, + createCategoryValueCascadingGroup, + updateCategoryValueCascadingGroup, + deleteCategoryValueCascadingGroup, + saveCategoryValueCascadingMappings, + getCategoryValueCascadingOptions, + getCategoryValueCascadingParentOptions, + getCategoryValueCascadingChildOptions, + getCategoryValueCascadingMappingsByTable, +} from "../controllers/categoryValueCascadingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +// 그룹 목록 조회 +router.get("/groups", getCategoryValueCascadingGroups); + +// 그룹 상세 조회 (ID) +router.get("/groups/:groupId", getCategoryValueCascadingGroupById); + +// 관계 코드로 조회 +router.get("/code/:code", getCategoryValueCascadingByCode); + +// 그룹 생성 +router.post("/groups", createCategoryValueCascadingGroup); + +// 그룹 수정 +router.put("/groups/:groupId", updateCategoryValueCascadingGroup); + +// 그룹 삭제 +router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup); + +// ============================================ +// 카테고리 값 연쇄관계 매핑 +// ============================================ + +// 매핑 일괄 저장 +router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings); + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +// 부모 카테고리 값 목록 조회 +router.get("/parent-options/:code", getCategoryValueCascadingParentOptions); + +// 자식 카테고리 값 목록 조회 (매핑 설정 UI용) +router.get("/child-options/:code", getCategoryValueCascadingChildOptions); + +// 연쇄 옵션 조회 (부모 값 기반 자식 옵션) +router.get("/options/:code", getCategoryValueCascadingOptions); + +// ============================================ +// 테이블별 매핑 조회 (테이블 목록 표시용) +// ============================================ + +// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 +router.get( + "/table/:tableName/mappings", + getCategoryValueCascadingMappingsByTable +); + +export default router; diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts deleted file mode 100644 index a59b5f43..00000000 --- a/backend-node/src/routes/orderRoutes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from "express"; -import { authenticateToken } from "../middleware/authMiddleware"; -import { createOrder, getOrders } from "../controllers/orderController"; - -const router = Router(); - -/** - * 수주 등록 - * POST /api/orders - */ -router.post("/", authenticateToken, createOrder); - -/** - * 수주 목록 조회 - * GET /api/orders - */ -router.get("/", authenticateToken, getOrders); - -export default router; - diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index 76e1a955..bb644fef 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) => reportController.uploadImage(req, res, next) ); +// WORD(DOCX) 내보내기 +router.post("/export-word", (req, res, next) => + reportController.exportToWord(req, res, next) +); + // 리포트 목록 router.get("/", (req, res, next) => reportController.getReports(req, res, next) diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts index 21c17ecb..0f8a64b0 100644 --- a/backend-node/src/routes/roleRoutes.ts +++ b/backend-node/src/routes/roleRoutes.ts @@ -22,6 +22,15 @@ const router = Router(); // 모든 role 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +/** + * 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함) + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + /** * 권한 그룹 CRUD */ @@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); // 메뉴 권한 설정 router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); -/** - * 사용자 권한 그룹 조회 - */ -// 현재 사용자가 속한 권한 그룹 조회 -router.get("/user/my-groups", getUserRoleGroups); - -// 특정 사용자가 속한 권한 그룹 조회 -router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); - export default router; diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index e59d9b9d..b905a3f2 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { getCategoryColumns, + getAllCategoryColumns, getCategoryValues, addCategoryValue, updateCategoryValue, @@ -22,6 +23,10 @@ const router = Router(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) +// 주의: 더 구체적인 라우트보다 먼저 와야 함 +router.get("/all-columns", getAllCategoryColumns); + // 테이블의 카테고리 컬럼 목록 조회 router.get("/:tableName/columns", getCategoryColumns); diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 8cbd8a29..db19adc3 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -86,11 +86,12 @@ export class CommonCodeService { } // 회사별 필터링 (최고 관리자가 아닌 경우) + // company_code = '*'인 공통 데이터도 함께 조회 if (userCompanyCode && userCompanyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); + whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`); values.push(userCompanyCode); paramIndex++; - logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`); + logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`); } else if (userCompanyCode === "*") { // 최고 관리자는 모든 데이터 조회 가능 logger.info(`최고 관리자: 모든 코드 카테고리 조회`); @@ -116,7 +117,7 @@ export class CommonCodeService { const offset = (page - 1) * size; - // 카테고리 조회 + // code_category 테이블에서만 조회 (comm_code 제거) const categories = await query( `SELECT * FROM code_category ${whereClause} @@ -134,7 +135,7 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` + `카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` ); return { @@ -224,7 +225,7 @@ export class CommonCodeService { paramIndex, }); - // 코드 조회 + // code_info 테이블에서만 코드 조회 (comm_code fallback 제거) const codes = await query( `SELECT * FROM code_info ${whereClause} @@ -242,20 +243,9 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` + `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` ); - logger.info(`📊 [getCodes] 조회된 코드 상세:`, { - categoryCode, - menuObjid, - codes: codes.map((c) => ({ - code_value: c.code_value, - code_name: c.code_name, - menu_objid: c.menu_objid, - company_code: c.company_code, - })), - }); - return { data: codes, total }; } catch (error) { logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 65efcd1b..68c30252 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -854,6 +854,11 @@ export class DynamicFormService { if (tableColumns.includes("updated_at")) { changedFields.updated_at = new Date(); } + // updated_date 컬럼도 지원 (sales_order_mng 등) + if (tableColumns.includes("updated_date")) { + changedFields.updated_date = new Date(); + console.log("📅 updated_date 자동 추가:", changedFields.updated_date); + } console.log("🎯 실제 업데이트할 필드들:", changedFields); @@ -903,7 +908,7 @@ export class DynamicFormService { return `${key} = $${index + 1}::numeric`; } else if (dataType === "boolean") { return `${key} = $${index + 1}::boolean`; - } else if (dataType === 'jsonb' || dataType === 'json') { + } else if (dataType === "jsonb" || dataType === "json") { // 🆕 JSONB/JSON 타입은 명시적 캐스팅 return `${key} = $${index + 1}::jsonb`; } else { @@ -917,9 +922,13 @@ export class DynamicFormService { const values: any[] = Object.keys(changedFields).map((key) => { const value = changedFields[key]; const dataType = columnTypes[key]; - + // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 - if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) { + if ( + (dataType === "jsonb" || dataType === "json") && + (Array.isArray(value) || + (typeof value === "object" && value !== null)) + ) { return JSON.stringify(value); } return value; @@ -1588,6 +1597,7 @@ export class DynamicFormService { /** * 제어관리 실행 (화면에 설정된 경우) + * 다중 제어를 순서대로 순차 실행 지원 */ private async executeDataflowControlIfConfigured( screenId: number, @@ -1629,105 +1639,67 @@ export class DynamicFormService { hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, + hasFlowControls: + !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 if ( properties?.componentType === "button-primary" && properties?.componentConfig?.action?.type === "save" && - properties?.webTypeConfig?.enableDataflowControl === true && - properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId + properties?.webTypeConfig?.enableDataflowControl === true ) { - controlConfigFound = true; - const diagramId = - properties.webTypeConfig.dataflowConfig.selectedDiagramId; - const relationshipId = - properties.webTypeConfig.dataflowConfig.selectedRelationshipId; + const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; - console.log(`🎯 제어관리 설정 발견:`, { - componentId: layout.component_id, - diagramId, - relationshipId, - triggerType, - }); + // 다중 제어 설정 확인 (flowControls 배열) + const flowControls = dataflowConfig?.flowControls || []; - // 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) - let controlResult: any; + // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행 + if (flowControls.length > 0) { + controlConfigFound = true; + console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`); - if (!relationshipId) { - // 노드 플로우 실행 - console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); - const { NodeFlowExecutionService } = await import( - "./nodeFlowExecutionService" + // 순서대로 정렬 + const sortedControls = [...flowControls].sort( + (a: any, b: any) => (a.order || 0) - (b.order || 0) ); - const executionResult = await NodeFlowExecutionService.executeFlow( + // 다중 제어 순차 실행 + await this.executeMultipleFlowControls( + sortedControls, + savedData, + screenId, + tableName, + triggerType, + userId, + companyCode + ); + } else if (dataflowConfig?.selectedDiagramId) { + // 기존 단일 제어 실행 (하위 호환성) + controlConfigFound = true; + const diagramId = dataflowConfig.selectedDiagramId; + const relationshipId = dataflowConfig.selectedRelationshipId; + + console.log(`🎯 단일 제어관리 설정 발견:`, { + componentId: layout.component_id, diagramId, - { - sourceData: [savedData], - dataSourceType: "formData", - buttonId: "save-button", - screenId: screenId, - userId: userId, - companyCode: companyCode, - formData: savedData, - } - ); + relationshipId, + triggerType, + }); - controlResult = { - success: executionResult.success, - message: executionResult.message, - executedActions: executionResult.nodes?.map((node) => ({ - nodeId: node.nodeId, - status: node.status, - duration: node.duration, - })), - errors: executionResult.nodes - ?.filter((node) => node.status === "failed") - .map((node) => node.error || "실행 실패"), - }; - } else { - // 관계 기반 제어관리 실행 - console.log( - `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` + await this.executeSingleFlowControl( + diagramId, + relationshipId, + savedData, + screenId, + tableName, + triggerType, + userId, + companyCode ); - controlResult = - await this.dataflowControlService.executeDataflowControl( - diagramId, - relationshipId, - triggerType, - savedData, - tableName, - userId - ); } - console.log(`🎯 제어관리 실행 결과:`, controlResult); - - if (controlResult.success) { - console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); - if ( - controlResult.executedActions && - controlResult.executedActions.length > 0 - ) { - console.log(`📊 실행된 액션들:`, controlResult.executedActions); - } - - // 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패) - if (controlResult.errors && controlResult.errors.length > 0) { - console.warn( - `⚠️ 제어관리 실행 중 일부 오류 발생:`, - controlResult.errors - ); - // 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능 - // 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행 - } - } else { - console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); - // 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음 - } - - // 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우) + // 첫 번째 설정된 버튼의 제어관리만 실행 break; } } @@ -1741,6 +1713,218 @@ export class DynamicFormService { } } + /** + * 다중 제어 순차 실행 + */ + private async executeMultipleFlowControls( + flowControls: Array<{ + id: string; + flowId: number; + flowName: string; + executionTiming: string; + order: number; + }>, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`); + + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const results: Array<{ + order: number; + flowId: number; + flowName: string; + success: boolean; + message: string; + duration: number; + }> = []; + + for (let i = 0; i < flowControls.length; i++) { + const control = flowControls[i]; + const startTime = Date.now(); + + console.log( + `\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})` + ); + + try { + // 유효하지 않은 flowId 스킵 + if (!control.flowId || control.flowId <= 0) { + console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`); + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: "유효하지 않은 flowId", + duration: 0, + }); + continue; + } + + const executionResult = await NodeFlowExecutionService.executeFlow( + control.flowId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + const duration = Date.now() - startTime; + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: executionResult.success, + message: executionResult.message, + duration, + }); + + if (executionResult.success) { + console.log( + `✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)` + ); + } else { + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}` + ); + // 이전 제어 실패 시 다음 제어 실행 중단 + console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`); + break; + } + } catch (error: any) { + const duration = Date.now() - startTime; + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`, + error + ); + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: error.message || "실행 오류", + duration, + }); + + // 오류 발생 시 다음 제어 실행 중단 + console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`); + break; + } + } + + // 실행 결과 요약 + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); + + console.log(`\n📊 다중 제어 실행 완료:`, { + total: flowControls.length, + executed: results.length, + success: successCount, + failed: failCount, + totalDuration: `${totalDuration}ms`, + }); + } + + /** + * 단일 제어 실행 (기존 로직, 하위 호환성) + */ + private async executeSingleFlowControl( + diagramId: number, + relationshipId: string | null, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + let controlResult: any; + + if (!relationshipId) { + // 노드 플로우 실행 + console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const executionResult = await NodeFlowExecutionService.executeFlow( + diagramId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + controlResult = { + success: executionResult.success, + message: executionResult.message, + executedActions: executionResult.nodes?.map((node) => ({ + nodeId: node.nodeId, + status: node.status, + duration: node.duration, + })), + errors: executionResult.nodes + ?.filter((node) => node.status === "failed") + .map((node) => node.error || "실행 실패"), + }; + } else { + // 관계 기반 제어관리 실행 + console.log( + `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` + ); + controlResult = await this.dataflowControlService.executeDataflowControl( + diagramId, + relationshipId, + triggerType, + savedData, + tableName, + userId + ); + } + + console.log(`🎯 제어관리 실행 결과:`, controlResult); + + if (controlResult.success) { + console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); + if ( + controlResult.executedActions && + controlResult.executedActions.length > 0 + ) { + console.log(`📊 실행된 액션들:`, controlResult.executedActions); + } + + if (controlResult.errors && controlResult.errors.length > 0) { + console.warn( + `⚠️ 제어관리 실행 중 일부 오류 발생:`, + controlResult.errors + ); + } + } else { + console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); + } + } + /** * 특정 테이블의 특정 필드 값만 업데이트 * (다른 테이블의 레코드 업데이트 지원) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 5557d8b5..25d96927 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -186,8 +186,13 @@ export class EntityJoinService { } } - // 별칭 컬럼명 생성 (writer -> writer_name) - const aliasColumn = `${column.column_name}_name`; + // 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성 + // 단일 컬럼: manager + user_name → manager_user_name + // 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨) + const firstDisplayColumn = displayColumns[0] || "name"; + const aliasColumn = `${column.column_name}_${firstDisplayColumn}`; + + logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`); const joinConfig: EntityJoinConfig = { sourceTable: tableName, diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 73077ef1..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } try { - const [rows] = await this.pool.execute(sql, params); - return rows; + const [rows] = await this.pool.execute(sql, params); + return rows; } catch (error: any) { // 연결 닫힘 오류 감지 if ( diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a0e707c1..eb230454 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,12 +10,29 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories: number; + copiedCodes: number; + copiedNumberingRules: number; + copiedCategoryMappings: number; + copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 + copiedCascadingRelations: number; // 연쇄관계 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } +/** + * 추가 복사 옵션 + */ +export interface AdditionalCopyOptions { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 + copyCascadingRelation?: boolean; // 연쇄관계 설정 +} + /** * 메뉴 정보 */ @@ -230,7 +247,9 @@ export class MenuCopyService { typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); - logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + logger.debug( + ` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})` + ); } } } @@ -240,7 +259,9 @@ export class MenuCopyService { if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = - typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + typeof leftScreenId === "number" + ? leftScreenId + : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); @@ -250,7 +271,9 @@ export class MenuCopyService { if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = - typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + typeof rightScreenId === "number" + ? rightScreenId + : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); @@ -276,18 +299,16 @@ export class MenuCopyService { const screenIds = new Set(); const visited = new Set(); - // 1) 메뉴에 직접 할당된 화면 - for (const menuObjid of menuObjids) { - const assignmentsResult = await client.query<{ screen_id: number }>( - `SELECT DISTINCT screen_id - FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); + // 1) 메뉴에 직접 할당된 화면 - 배치 조회 + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); - for (const assignment of assignmentsResult.rows) { - screenIds.add(assignment.screen_id); - } + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); } logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); @@ -332,6 +353,8 @@ export class MenuCopyService { /** * 플로우 수집 + * - 화면 레이아웃에서 참조된 모든 flowId 수집 + * - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집 */ private async collectFlows( screenIds: Set, @@ -340,25 +363,73 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); + const flowDetails: Array<{ + flowId: number; + flowName: string; + screenId: number; + }> = []; - for (const screenId of screenIds) { - const layoutsResult = await client.query( - `SELECT properties FROM screen_layouts WHERE screen_id = $1`, - [screenId] - ); + // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회 + const screenIdArray = Array.from(screenIds); + if (screenIdArray.length === 0) { + return flowIds; + } - for (const layout of layoutsResult.rows) { - const props = layout.properties; + const layoutsResult = await client.query< + ScreenLayout & { screen_id: number } + >( + `SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIdArray] + ); - // webTypeConfig.dataflowConfig.flowConfig.flowId - const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - if (flowId) { + for (const layout of layoutsResult.rows) { + const props = layout.properties; + const screenId = layout.screen_id; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + const flowName = + props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info( + ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` + ); + } + } + + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = + props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if ( + selectedDiagramId && + typeof selectedDiagramId === "number" && + selectedDiagramId > 0 + ) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ + flowId: selectedDiagramId, + flowName: "SelectedDiagram", + screenId, + }); + logger.info( + ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` + ); } } } - logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + if (flowIds.size > 0) { + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`); + } else { + logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); + } + return flowIds; } @@ -406,12 +477,13 @@ export class MenuCopyService { * properties 내부 참조 업데이트 */ /** - * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, - flowIdMap: Map + flowIdMap: Map, + numberingRuleIdMap?: Map ): any { if (!properties) return properties; @@ -419,7 +491,13 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + this.recursiveUpdateReferences( + updated, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap + ); return updated; } @@ -431,7 +509,8 @@ export class MenuCopyService { obj: any, screenIdMap: Map, flowIdMap: Map, - path: string = "" + path: string = "", + numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -442,7 +521,8 @@ export class MenuCopyService { item, screenIdMap, flowIdMap, - `${path}[${index}]` + `${path}[${index}]`, + numberingRuleIdMap ); }); return; @@ -473,27 +553,56 @@ export class MenuCopyService { } } - // flowId 매핑 (숫자 또는 숫자 문자열) - if (key === "flowId") { + // flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열) + // selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환 + if (key === "flowId" || key === "selectedDiagramId") { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = flowIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 - logger.debug( - ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + logger.info( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } else { + // 매핑이 없으면 경고 로그 + logger.warn( + ` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음` ); } } } + // numberingRuleId 매핑 (문자열) + if ( + key === "numberingRuleId" && + numberingRuleIdMap && + typeof value === "string" && + value + ) { + const newRuleId = numberingRuleIdMap.get(value); + if (newRuleId) { + obj[key] = newRuleId; + logger.info( + ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` + ); + } else { + // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) + logger.warn( + ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` + ); + obj[key] = ""; + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( value, screenIdMap, flowIdMap, - currentPath + currentPath, + numberingRuleIdMap ); } } @@ -503,6 +612,8 @@ export class MenuCopyService { * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 + * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ private async deleteExistingCopy( sourceMenuObjid: number, @@ -511,9 +622,9 @@ export class MenuCopyService { ): Promise { logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); - // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + // 1. 원본 메뉴 정보 확인 const sourceMenuResult = await client.query( - `SELECT menu_name_kor, menu_name_eng + `SELECT menu_name_kor, menu_name_eng, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] @@ -525,14 +636,19 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; + const isRootMenu = + !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) - const existingMenuResult = await client.query<{ objid: number }>( - `SELECT objid + // 최상위/하위 구분 없이 모든 복사본 검색 + const existingMenuResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 - AND company_code = $2 - AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); @@ -542,11 +658,15 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; + const existingIsRoot = + !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; + logger.info( - `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` ); - // 3. 기존 메뉴 트리 수집 + // 3. 기존 메뉴 트리 수집 (해당 메뉴 + 하위 메뉴 모두) const existingMenus = await this.collectMenuTree(existingMenuObjid, client); const existingMenuIds = existingMenus.map((m) => m.objid); @@ -564,16 +684,7 @@ export class MenuCopyService { // 5. 삭제 순서 (외래키 제약 고려) - // 5-1. 화면 레이아웃 삭제 - if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, - [screenIds] - ); - logger.info(` ✅ 화면 레이아웃 삭제 완료`); - } - - // 5-2. 화면-메뉴 할당 삭제 + // 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, @@ -581,27 +692,116 @@ export class MenuCopyService { ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); - // 5-3. 화면 정의 삭제 + // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_definitions + // 다른 메뉴에서도 사용 중인 화면 ID 조회 + const sharedScreensResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - logger.info(` ✅ 화면 정의 삭제 완료`); + const sharedScreenIds = new Set( + sharedScreensResult.rows.map((r) => r.screen_id) + ); + + // 공유되지 않은 화면만 삭제 + const screensToDelete = screenIds.filter( + (id) => !sharedScreenIds.has(id) + ); + + if (screensToDelete.length > 0) { + // 레이아웃 삭제 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screensToDelete] + ); + + // 화면 정의 삭제 + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screensToDelete, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); + } + + if (sharedScreenIds.size > 0) { + logger.info( + ` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)` + ); + } } - // 5-4. 메뉴 권한 삭제 + // 5-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) - // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 - for (let i = existingMenus.length - 1; i >= 0; i--) { - await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ - existingMenus[i].objid, + // 5-4. 채번 규칙 처리 (체크 제약조건 고려) + // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) + // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 + const menuScopedRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, + [existingMenuIds, targetCompanyCode] + ); + if (menuScopedRulesResult.rows.length > 0) { + const menuScopedRuleIds = menuScopedRulesResult.rows.map( + (r) => r.rule_id + ); + // 채번 규칙 파트 먼저 삭제 + await client.query( + `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + // 채번 규칙 삭제 + await client.query( + `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + logger.info( + ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` + ); + } + + // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) + const updatedNumberingRules = await client.query( + `UPDATE numbering_rules + SET menu_objid = NULL + WHERE menu_objid = ANY($1) AND company_code = $2 + AND (scope_type IS NULL OR scope_type != 'menu') + RETURNING rule_id`, + [existingMenuIds, targetCompanyCode] + ); + if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { + logger.info( + ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` + ); + } + + // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) + // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 + const deletedCategoryMappings = await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING mapping_id`, + [existingMenuIds, targetCompanyCode] + ); + if ( + deletedCategoryMappings.rowCount && + deletedCategoryMappings.rowCount > 0 + ) { + logger.info( + ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` + ); + } + + // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) + // 외래키 제약이 해제되었으므로 배치 삭제 가능 + if (existingMenuIds.length > 0) { + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, ]); } logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); @@ -619,7 +819,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + additionalCopyOptions?: AdditionalCopyOptions ): Promise { logger.info(` 🚀 ============================================ @@ -671,30 +872,118 @@ export class MenuCopyService { client ); - // === 3단계: 화면 복사 === - logger.info("\n📄 [3단계] 화면 복사"); + // 변수 초기화 + let copiedCodeCategories = 0; + let copiedCodes = 0; + let copiedNumberingRules = 0; + let copiedCategoryMappings = 0; + let copiedTableTypeColumns = 0; + let copiedCascadingRelations = 0; + let numberingRuleIdMap = new Map(); + + const menuObjids = menus.map((m) => m.objid); + + // 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해) + const tempMenuIdMap = new Map(); + let tempObjId = await this.getNextMenuObjid(client); + for (const menu of menus) { + tempMenuIdMap.set(menu.objid, tempObjId++); + } + + // === 3단계: 메뉴 복사 (외래키 의존성 해결을 위해 먼저 실행) === + // 채번 규칙, 코드 카테고리 등이 menu_info를 참조하므로 메뉴를 먼저 생성 + logger.info("\n📂 [3단계] 메뉴 복사 (외래키 선행 조건)"); + const menuIdMap = await this.copyMenus( + menus, + sourceMenuObjid, + sourceCompanyCode, + targetCompanyCode, + new Map(), // screenIdMap은 아직 없음 (나중에 할당에서 처리) + userId, + client, + tempMenuIdMap + ); + + // === 4단계: 채번 규칙 복사 (메뉴 복사 후, 화면 복사 전) === + if (additionalCopyOptions?.copyNumberingRules) { + logger.info("\n📦 [4단계] 채번 규칙 복사"); + const ruleResult = await this.copyNumberingRulesWithMap( + menuObjids, + menuIdMap, // 실제 생성된 메뉴 ID 사용 + targetCompanyCode, + userId, + client + ); + copiedNumberingRules = ruleResult.copiedCount; + numberingRuleIdMap = ruleResult.ruleIdMap; + } + + // === 4.1단계: 코드 카테고리 + 코드 복사 === + if (additionalCopyOptions?.copyCodeCategory) { + logger.info("\n📦 [4.1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // === 4.2단계: 카테고리 매핑 + 값 복사 === + if (additionalCopyOptions?.copyCategoryMapping) { + logger.info("\n📦 [4.2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // === 4.3단계: 연쇄관계 복사 === + if (additionalCopyOptions?.copyCascadingRelation) { + logger.info("\n📦 [4.3단계] 연쇄관계 복사"); + copiedCascadingRelations = await this.copyCascadingRelations( + sourceCompanyCode, + targetCompanyCode, + menuIdMap, + userId, + client + ); + } + + // === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 === + // 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + // 대상 회사에서 같은 이름의 채번규칙으로 매핑 + if (screenIds.size > 0) { + logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완"); + await this.supplementNumberingRuleMapping( + Array.from(screenIds), + sourceCompanyCode, + targetCompanyCode, + numberingRuleIdMap, + client + ); + } + + // === 5단계: 화면 복사 === + logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( screenIds, targetCompanyCode, flowIdMap, userId, client, - screenNameConfig + screenNameConfig, + numberingRuleIdMap ); - // === 4단계: 메뉴 복사 === - logger.info("\n📂 [4단계] 메뉴 복사"); - const menuIdMap = await this.copyMenus( - menus, - sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 - targetCompanyCode, - screenIdMap, - userId, - client - ); - - // === 5단계: 화면-메뉴 할당 === - logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + // === 6단계: 화면-메뉴 할당 === + logger.info("\n🔗 [6단계] 화면-메뉴 할당"); await this.createScreenMenuAssignments( menus, menuIdMap, @@ -703,6 +992,17 @@ export class MenuCopyService { client ); + // === 7단계: 테이블 타입 설정 복사 === + if (additionalCopyOptions?.copyTableTypeColumns) { + logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), + sourceCompanyCode, + targetCompanyCode, + client + ); + } + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -712,6 +1012,12 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, + copiedCodeCategories, + copiedCodes, + copiedNumberingRules, + copiedCategoryMappings, + copiedTableTypeColumns, + copiedCascadingRelations, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -724,8 +1030,12 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. + - 코드 카테고리: ${copiedCodeCategories}개 + - 코드: ${copiedCodes}개 + - 채번규칙: ${copiedNumberingRules}개 + - 카테고리 매핑: ${copiedCategoryMappings}개 + - 테이블 타입 설정: ${copiedTableTypeColumns}개 + - 연쇄관계: ${copiedCascadingRelations}개 ============================================ `); @@ -742,6 +1052,8 @@ export class MenuCopyService { /** * 플로우 복사 + * - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만) + * - 없으면 새로 복사 */ private async copyFlows( flowIds: Set, @@ -756,122 +1068,228 @@ export class MenuCopyService { return flowIdMap; } + const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); - for (const originalFlowId of flowIds) { - try { - // 1) flow_definition 조회 - const flowDefResult = await client.query( - `SELECT * FROM flow_definition WHERE id = $1`, - [originalFlowId] + // === 최적화: 배치 조회 === + // 1) 모든 원본 플로우 한 번에 조회 + const allFlowDefsResult = await client.query( + `SELECT * FROM flow_definition WHERE id = ANY($1)`, + [flowIdArray] + ); + const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); + + // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) + const flowNames = allFlowDefsResult.rows.map((f) => f.name); + const existingFlowsResult = await client.query<{ + id: number; + name: string; + table_name: string; + }>( + `SELECT id, name, table_name FROM flow_definition + WHERE company_code = $1 AND name = ANY($2)`, + [targetCompanyCode, flowNames] + ); + const existingFlowMap = new Map( + existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) + ); + + // 3) 복사가 필요한 플로우 ID 목록 + const flowsToCopy: FlowDefinition[] = []; + + for (const originalFlowId of flowIdArray) { + const flowDef = flowDefMap.get(originalFlowId); + if (!flowDef) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const key = `${flowDef.name}|${flowDef.table_name}`; + const existingId = existingFlowMap.get(key); + + if (existingId) { + flowIdMap.set(originalFlowId, existingId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` ); + } else { + flowsToCopy.push(flowDef); + } + } - if (flowDefResult.rows.length === 0) { - logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); - continue; - } + // 4) 새 플로우 복사 (배치 처리) + if (flowsToCopy.length > 0) { + // 배치 INSERT로 플로우 생성 + const flowValues = flowsToCopy + .map( + (f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ) + .join(", "); - const flowDef = flowDefResult.rows[0]; + const flowParams = flowsToCopy.flatMap((f) => [ + f.name, + f.description, + f.table_name, + f.is_active, + targetCompanyCode, + userId, + f.db_source_type, + f.db_connection_id, + ]); - // 2) flow_definition 복사 - const newFlowResult = await client.query<{ id: number }>( - `INSERT INTO flow_definition ( + const newFlowsResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ) VALUES ${flowValues} RETURNING id`, - [ - flowDef.name, - flowDef.description, - flowDef.table_name, - flowDef.is_active, - targetCompanyCode, // 새 회사 코드 - userId, - flowDef.db_source_type, - flowDef.db_connection_id, - ] - ); - - const newFlowId = newFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); + flowParams + ); + // 새 플로우 ID 매핑 + flowsToCopy.forEach((flowDef, index) => { + const newFlowId = newFlowsResult.rows[index].id; + flowIdMap.set(flowDef.id, newFlowId); logger.info( - ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` ); + }); - // 3) flow_step 복사 - const stepsResult = await client.query( - `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, - [originalFlowId] - ); + // 5) 스텝 및 연결 복사 (복사된 플로우만) + const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); + // 모든 스텝 한 번에 조회 + const allStepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`, + [originalFlowIdsToCopy] + ); + + // 플로우별 스텝 그룹핑 + const stepsByFlow = new Map(); + for (const step of allStepsResult.rows) { + if (!stepsByFlow.has(step.flow_definition_id)) { + stepsByFlow.set(step.flow_definition_id, []); + } + stepsByFlow.get(step.flow_definition_id)!.push(step); + } + + // 스텝 복사 (플로우별) + const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap + + for (const originalFlowId of originalFlowIdsToCopy) { + const newFlowId = flowIdMap.get(originalFlowId)!; + const steps = stepsByFlow.get(originalFlowId) || []; const stepIdMap = new Map(); - for (const step of stepsResult.rows) { - const newStepResult = await client.query<{ id: number }>( + if (steps.length > 0) { + // 배치 INSERT로 스텝 생성 + const stepValues = steps + .map( + (_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ) + .join(", "); + + const stepParams = steps.flatMap((s) => [ + newFlowId, + s.step_name, + s.step_order, + s.condition_json, + s.color, + s.position_x, + s.position_y, + s.table_name, + s.move_type, + s.status_column, + s.status_value, + s.target_table, + s.field_mappings, + s.required_fields, + s.integration_type, + s.integration_config, + s.display_config, + ]); + + const newStepsResult = await client.query<{ id: number }>( `INSERT INTO flow_step ( flow_definition_id, step_name, step_order, condition_json, color, position_x, position_y, table_name, move_type, status_column, status_value, target_table, field_mappings, required_fields, integration_type, integration_config, display_config - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ${stepValues} RETURNING id`, - [ - newFlowId, // 새 플로우 ID - step.step_name, - step.step_order, - step.condition_json, - step.color, - step.position_x, - step.position_y, - step.table_name, - step.move_type, - step.status_column, - step.status_value, - step.target_table, - step.field_mappings, - step.required_fields, - step.integration_type, - step.integration_config, - step.display_config, - ] + stepParams ); - const newStepId = newStepResult.rows[0].id; - stepIdMap.set(step.id, newStepId); + steps.forEach((step, index) => { + stepIdMap.set(step.id, newStepsResult.rows[index].id); + }); + + logger.info( + ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` + ); } - logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + allStepIdMaps.set(originalFlowId, stepIdMap); + } - // 4) flow_step_connection 복사 (스텝 ID 재매핑) - const connectionsResult = await client.query( - `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, - [originalFlowId] + // 모든 연결 한 번에 조회 + const allConnectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = ANY($1)`, + [originalFlowIdsToCopy] + ); + + // 연결 복사 (배치 INSERT) + const connectionsToInsert: { + newFlowId: number; + newFromStepId: number; + newToStepId: number; + label: string; + }[] = []; + + for (const conn of allConnectionsResult.rows) { + const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); + if (!stepIdMap) continue; + + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + const newFlowId = flowIdMap.get(conn.flow_definition_id); + + if (newFromStepId && newToStepId && newFlowId) { + connectionsToInsert.push({ + newFlowId, + newFromStepId, + newToStepId, + label: conn.label || "", + }); + } + } + + if (connectionsToInsert.length > 0) { + const connValues = connectionsToInsert + .map( + (_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ) + .join(", "); + + const connParams = connectionsToInsert.flatMap((c) => [ + c.newFlowId, + c.newFromStepId, + c.newToStepId, + c.label, + ]); + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ${connValues}`, + connParams ); - for (const conn of connectionsResult.rows) { - const newFromStepId = stepIdMap.get(conn.from_step_id); - const newToStepId = stepIdMap.get(conn.to_step_id); - - if (!newFromStepId || !newToStepId) { - logger.warn( - `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` - ); - continue; - } - - await client.query( - `INSERT INTO flow_step_connection ( - flow_definition_id, from_step_id, to_step_id, label - ) VALUES ($1, $2, $3, $4)`, - [newFlowId, newFromStepId, newToStepId, conn.label] - ); - } - - logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); - } catch (error: any) { - logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); - throw error; + logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } @@ -894,7 +1312,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + numberingRuleIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -905,6 +1324,37 @@ export class MenuCopyService { logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); + // === 0단계: 원본 화면 정의 배치 조회 === + const screenIdArray = Array.from(screenIds); + const allScreenDefsResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`, + [screenIdArray] + ); + const screenDefMap = new Map(); + for (const def of allScreenDefsResult.rows) { + screenDefMap.set(def.screen_id, def); + } + + // 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준) + const existingCopiesResult = await client.query<{ + screen_id: number; + screen_name: string; + source_screen_id: number; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, source_screen_id, updated_date + FROM screen_definitions + WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`, + [screenIdArray, targetCompanyCode] + ); + const existingCopyMap = new Map< + number, + { screen_id: number; screen_name: string; updated_date: Date } + >(); + for (const copy of existingCopiesResult.rows) { + existingCopyMap.set(copy.source_screen_id, copy); + } + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; @@ -915,31 +1365,47 @@ export class MenuCopyService { for (const originalScreenId of screenIds) { try { - // 1) 원본 screen_definitions 조회 - const screenDefResult = await client.query( - `SELECT * FROM screen_definitions WHERE screen_id = $1`, - [originalScreenId] - ); + // 1) 원본 screen_definitions 조회 (캐시에서) + const screenDef = screenDefMap.get(originalScreenId); - if (screenDefResult.rows.length === 0) { + if (!screenDef) { logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); continue; } - const screenDef = screenDefResult.rows[0]; + // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준) + let existingCopy = existingCopyMap.get(originalScreenId); - // 2) 기존 복사본 찾기: source_screen_id로 검색 - const existingCopyResult = await client.query<{ - screen_id: number; - screen_name: string; - updated_date: Date; - }>( - `SELECT screen_id, screen_name, updated_date - FROM screen_definitions - WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL - LIMIT 1`, - [originalScreenId, targetCompanyCode] - ); + // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 + if (!existingCopy && screenDef.screen_name) { + const legacyCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE screen_name = $1 + AND table_name = $2 + AND company_code = $3 + AND source_screen_id IS NULL + AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_name, screenDef.table_name, targetCompanyCode] + ); + + if (legacyCopyResult.rows.length > 0) { + existingCopy = legacyCopyResult.rows[0]; + // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) + await client.query( + `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, + [originalScreenId, existingCopy.screen_id] + ); + logger.info( + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.screen_id} ← ${originalScreenId}` + ); + } + } // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; @@ -957,10 +1423,9 @@ export class MenuCopyService { } } - if (existingCopyResult.rows.length > 0) { + if (existingCopy) { // === 기존 복사본이 있는 경우: 업데이트 === - const existingScreen = existingCopyResult.rows[0]; - const existingScreenId = existingScreen.screen_id; + const existingScreenId = existingCopy.screen_id; // 원본 레이아웃 조회 const sourceLayoutsResult = await client.query( @@ -1074,10 +1539,7 @@ export class MenuCopyService { }); } } catch (error: any) { - logger.error( - `❌ 화면 처리 실패: screen_id=${originalScreenId}`, - error - ); + logger.error(`❌ 화면 처리 실패: screen_id=${originalScreenId}`, error); throw error; } } @@ -1111,35 +1573,39 @@ export class MenuCopyService { // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); - for (const layout of layoutsResult.rows) { - const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const timestamp = Date.now(); + layoutsResult.rows.forEach((layout, idx) => { + const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; componentIdMap.set(layout.component_id, newComponentId); - } + }); - // 레이아웃 삽입 - for (const layout of layoutsResult.rows) { - const newComponentId = componentIdMap.get(layout.component_id)!; + // 레이아웃 배치 삽입 준비 + if (layoutsResult.rows.length > 0) { + const layoutValues: string[] = []; + const layoutParams: any[] = []; + let paramIdx = 1; - const newParentId = layout.parent_id - ? componentIdMap.get(layout.parent_id) || layout.parent_id - : null; - const newZoneId = layout.zone_id - ? componentIdMap.get(layout.zone_id) || layout.zone_id - : null; + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; - const updatedProperties = this.updateReferencesInProperties( - layout.properties, - screenIdMap, - flowIdMap - ); + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; - await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, layout_type, layout_config, zones_config, zone_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - [ + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap, + numberingRuleIdMap + ); + + layoutValues.push( + `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` + ); + layoutParams.push( targetScreenId, layout.component_type, newComponentId, @@ -1153,8 +1619,19 @@ export class MenuCopyService { layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, - ] + newZoneId + ); + paramIdx += 14; + } + + // 배치 INSERT + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ${layoutValues.join(", ")}`, + layoutParams ); } @@ -1277,16 +1754,81 @@ export class MenuCopyService { return screenCode; } + /** + * 대상 회사에서 부모 메뉴 찾기 + * - 원본 메뉴의 parent_obj_id를 source_menu_objid로 가진 메뉴를 대상 회사에서 검색 + * - 2레벨 이하 메뉴 복사 시 기존에 복사된 부모 메뉴와 연결하기 위함 + */ + private async findParentMenuInTargetCompany( + originalParentObjId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + // 1. 대상 회사에서 source_menu_objid가 원본 부모 ID인 메뉴 찾기 + const result = await client.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [originalParentObjId, targetCompanyCode] + ); + + if (result.rows.length > 0) { + return result.rows[0].objid; + } + + // 2. source_menu_objid로 못 찾으면, 동일 원본 회사에서 복사된 메뉴 중 같은 이름으로 찾기 (fallback) + // 원본 부모 메뉴 정보 조회 + const parentMenuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [originalParentObjId] + ); + + if (parentMenuResult.rows.length === 0) { + return null; + } + + const parentMenu = parentMenuResult.rows[0]; + + // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 + const sameNameResult = await client.query<{ objid: number }>( + `SELECT m.objid FROM menu_info m + WHERE m.menu_name_kor = $1 + AND m.company_code = $2 + AND m.source_menu_objid IS NOT NULL + AND EXISTS ( + SELECT 1 FROM menu_info orig + WHERE orig.objid = m.source_menu_objid + AND orig.company_code = $3 + ) + LIMIT 1`, + [parentMenu.menu_name_kor, targetCompanyCode, sourceCompanyCode] + ); + + if (sameNameResult.rows.length > 0) { + logger.info( + ` 📎 이름으로 부모 메뉴 찾음: "${parentMenu.menu_name_kor}" → objid: ${sameNameResult.rows[0].objid}` + ); + return sameNameResult.rows[0].objid; + } + + return null; + } + /** * 메뉴 복사 */ private async copyMenus( menus: Menu[], rootMenuObjid: number, + sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Map, userId: string, - client: PoolClient + client: PoolClient, + preAllocatedMenuIdMap?: Map // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우) ): Promise> { const menuIdMap = new Map(); @@ -1302,27 +1844,115 @@ export class MenuCopyService { for (const menu of sortedMenus) { try { - // 새 objid 생성 - const newObjId = await this.getNextMenuObjid(client); + // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) + // 1차: source_menu_objid로 검색 + let existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [menu.objid, targetCompanyCode] + ); - // parent_obj_id 재매핑 - // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { + existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE menu_name_kor = $1 + AND company_code = $2 + AND menu_type = $3 + AND source_menu_objid IS NULL + LIMIT 1`, + [menu.menu_name_kor, targetCompanyCode, menu.menu_type] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_menu_objid 업데이트 (마이그레이션) + await client.query( + `UPDATE menu_info SET source_menu_objid = $1 WHERE objid = $2`, + [menu.objid, existingCopyResult.rows[0].objid] + ); + logger.info( + ` 📝 기존 메뉴에 source_menu_objid 추가: ${existingCopyResult.rows[0].objid} ← ${menu.objid}` + ); + } + } + + // parent_obj_id 계산 (신규/재연결 모두 필요) let newParentObjId: number | null; if (!menu.parent_obj_id || menu.parent_obj_id === 0) { newParentObjId = 0; // 최상위 메뉴는 항상 0 } else { - newParentObjId = - menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + // 1. 현재 복사 세션에서 부모가 이미 복사되었는지 확인 + newParentObjId = menuIdMap.get(menu.parent_obj_id) || null; + + // 2. 현재 세션에서 못 찾으면, 대상 회사에서 기존에 복사된 부모 찾기 + if (!newParentObjId) { + const existingParent = await this.findParentMenuInTargetCompany( + menu.parent_obj_id, + sourceCompanyCode, + targetCompanyCode, + client + ); + + if (existingParent) { + newParentObjId = existingParent; + logger.info( + ` 🔗 기존 부모 메뉴 연결: 원본 ${menu.parent_obj_id} → 대상 ${existingParent}` + ); + } else { + // 3. 부모를 못 찾으면 최상위로 설정 (경고 로그) + newParentObjId = 0; + logger.warn( + ` ⚠️ 부모 메뉴를 찾을 수 없음: ${menu.parent_obj_id} - 최상위로 생성됨` + ); + } + } } - // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) - // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 - const isRootMenu = String(menu.objid) === String(rootMenuObjid); - const sourceMenuObjid = isRootMenu ? menu.objid : null; + if (existingCopyResult.rows.length > 0) { + // === 이미 복사된 메뉴가 있는 경우: 재연결만 === + const existingMenu = existingCopyResult.rows[0]; + const existingObjId = existingMenu.objid; + const existingParentId = existingMenu.parent_obj_id; - if (sourceMenuObjid) { + // 부모가 다르면 업데이트 (고아 메뉴 재연결) + if (existingParentId !== newParentObjId) { + await client.query( + `UPDATE menu_info SET parent_obj_id = $1, writer = $2 WHERE objid = $3`, + [newParentObjId, userId, existingObjId] + ); + logger.info( + ` ♻️ 메뉴 재연결: ${menu.objid} → ${existingObjId} (${menu.menu_name_kor}), parent: ${existingParentId} → ${newParentObjId}` + ); + } else { + logger.info( + ` ⏭️ 메뉴 이미 존재 (스킵): ${menu.objid} → ${existingObjId} (${menu.menu_name_kor})` + ); + } + + menuIdMap.set(menu.objid, existingObjId); + continue; + } + + // === 신규 메뉴 복사 === + // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 + const newObjId = + preAllocatedMenuIdMap?.get(menu.objid) ?? + (await this.getNextMenuObjid(client)); + + // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) + const sourceMenuObjid = menu.objid; + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + + if (isRootMenu) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (복사 시작 메뉴)` ); } @@ -1371,7 +2001,7 @@ export class MenuCopyService { } /** - * 화면-메뉴 할당 + * 화면-메뉴 할당 (최적화: 배치 조회/삽입) */ private async createScreenMenuAssignments( menus: Menu[], @@ -1382,53 +2012,1126 @@ export class MenuCopyService { ): Promise { logger.info(`🔗 화면-메뉴 할당 중...`); - let assignmentCount = 0; + if (menus.length === 0) { + return; + } - for (const menu of menus) { - const newMenuObjid = menuIdMap.get(menu.objid); - if (!newMenuObjid) continue; + // === 최적화: 배치 조회 === + // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 + const menuObjids = menus.map((m) => m.objid); + const companyCodes = [...new Set(menus.map((m) => m.company_code))]; - // 원본 메뉴에 할당된 화면 조회 - const assignmentsResult = await client.query<{ - screen_id: number; - display_order: number; - is_active: string; - }>( - `SELECT screen_id, display_order, is_active + const allAssignmentsResult = await client.query<{ + menu_objid: number; + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menu.objid, menu.company_code] - ); + WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, + [menuObjids, companyCodes] + ); - for (const assignment of assignmentsResult.rows) { - const newScreenId = screenIdMap.get(assignment.screen_id); + if (allAssignmentsResult.rows.length === 0) { + logger.info(` 📭 화면-메뉴 할당 없음`); + return; + } + + // 2. 유효한 할당만 필터링 + const validAssignments: Array<{ + newScreenId: number; + newMenuObjid: number; + displayOrder: number; + isActive: string; + }> = []; + + for (const assignment of allAssignmentsResult.rows) { + const newMenuObjid = menuIdMap.get(assignment.menu_objid); + const newScreenId = screenIdMap.get(assignment.screen_id); + + if (!newMenuObjid || !newScreenId) { if (!newScreenId) { logger.warn( `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` ); - continue; } + continue; + } - // 새 할당 생성 - await client.query( - `INSERT INTO screen_menu_assignments ( + validAssignments.push({ + newScreenId, + newMenuObjid, + displayOrder: assignment.display_order, + isActive: assignment.is_active, + }); + } + + // 3. 배치 INSERT + if (validAssignments.length > 0) { + const assignmentValues = validAssignments + .map( + (_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ) + .join(", "); + + const assignmentParams = validAssignments.flatMap((a) => [ + a.newScreenId, + a.newMenuObjid, + targetCompanyCode, + a.displayOrder, + a.isActive, + "system", + ]); + + await client.query( + `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - newScreenId, // 재매핑 - newMenuObjid, // 재매핑 - targetCompanyCode, - assignment.display_order, - assignment.is_active, - "system", - ] - ); + ) VALUES ${assignmentValues}`, + assignmentParams + ); + } - assignmentCount++; + logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); + } + + /** + * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) + */ + private async copyCodeCategoriesAndCodes( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCategories: number; copiedCodes: number }> { + let copiedCategories = 0; + let copiedCodes = 0; + + if (menuObjids.length === 0) { + return { copiedCategories, copiedCodes }; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 한 번에 조회 + const allCategoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCategoriesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 카테고리 없음`); + return { copiedCategories, copiedCodes }; + } + + // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 + const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); + const existingCategoriesResult = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = ANY($1) AND company_code = $2`, + [categoryCodes, targetCompanyCode] + ); + const existingCategoryCodes = new Set( + existingCategoriesResult.rows.map((c) => c.category_code) + ); + + // 3. 복사할 카테고리 필터링 + const categoriesToCopy = allCategoriesResult.rows.filter( + (c) => !existingCategoryCodes.has(c.category_code) + ); + + // 4. 배치 INSERT로 카테고리 복사 + if (categoriesToCopy.length > 0) { + const categoryValues = categoriesToCopy + .map( + (_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ) + .join(", "); + + const categoryParams = categoriesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.category_code, + c.category_name, + c.category_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${categoryValues}`, + categoryParams + ); + + copiedCategories = categoriesToCopy.length; + logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); + } + + // 5. 모든 원본 코드 한 번에 조회 + const allCodesResult = await client.query( + `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCodesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 없음`); + return { copiedCategories, copiedCodes }; + } + + // 6. 대상 회사에 이미 존재하는 코드 한 번에 조회 + const existingCodesResult = await client.query( + `SELECT code_category, code_value FROM code_info + WHERE menu_objid = ANY($1) AND company_code = $2`, + [Array.from(menuIdMap.values()), targetCompanyCode] + ); + const existingCodeKeys = new Set( + existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) + ); + + // 7. 복사할 코드 필터링 + const codesToCopy = allCodesResult.rows.filter( + (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + ); + + // 8. 배치 INSERT로 코드 복사 + if (codesToCopy.length > 0) { + const codeValues = codesToCopy + .map( + (_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ) + .join(", "); + + const codeParams = codesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.code_category, + c.code_value, + c.code_name, + c.code_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${codeValues}`, + codeParams + ); + + copiedCodes = codesToCopy.length; + logger.info(` ✅ 코드 ${copiedCodes}개 복사`); + } + + logger.info( + `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` + ); + return { copiedCategories, copiedCodes }; + } + + /** + * 화면에서 참조하는 채번규칙 매핑 보완 + * 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + * 대상 회사에서 같은 이름(rule_name)의 채번규칙으로 매핑 + */ + private async supplementNumberingRuleMapping( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + numberingRuleIdMap: Map, + client: PoolClient + ): Promise { + if (screenIds.length === 0) return; + + // 1. 화면 레이아웃에서 모든 채번규칙 ID 추출 + const layoutsResult = await client.query( + `SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + + const referencedRuleIds = new Set(); + const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g; + + for (const row of layoutsResult.rows) { + if (!row.props) continue; + let match; + while ((match = ruleIdRegex.exec(row.props)) !== null) { + const ruleId = match[1]; + // 이미 매핑된 것은 제외 + if (ruleId && !numberingRuleIdMap.has(ruleId)) { + referencedRuleIds.add(ruleId); + } } } - logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + if (referencedRuleIds.size === 0) { + logger.info(` 📭 추가 매핑 필요 없음`); + return; + } + + logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}개`); + + // 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해) + const sourceRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_id = ANY($1)`, + [Array.from(referencedRuleIds)] + ); + + if (sourceRulesResult.rows.length === 0) { + logger.warn( + ` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}` + ); + return; + } + + // 3. 대상 회사에서 같은 이름의 채번규칙 찾기 + const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name); + const targetRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_name = ANY($1) AND company_code = $2`, + [ruleNames, targetCompanyCode] + ); + + // rule_name -> target_rule_id 매핑 + const targetRulesByName = new Map(); + for (const r of targetRulesResult.rows) { + // 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용 + if (!targetRulesByName.has(r.rule_name)) { + targetRulesByName.set(r.rule_name, r.rule_id); + } + } + + // 4. 매핑 추가 + let mappedCount = 0; + for (const sourceRule of sourceRulesResult.rows) { + const targetRuleId = targetRulesByName.get(sourceRule.rule_name); + if (targetRuleId) { + numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId); + logger.info( + ` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}` + ); + mappedCount++; + } else { + logger.warn( + ` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}` + ); + } + } + + logger.info( + ` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}개` + ); } + /** + * 채번 규칙 복사 (최적화: 배치 조회/삽입) + * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 + */ + private async copyNumberingRulesWithMap( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; ruleIdMap: Map }> { + let copiedCount = 0; + const ruleIdMap = new Map(); + + if (menuObjids.length === 0) { + return { copiedCount, ruleIdMap }; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 채번 규칙 한 번에 조회 + const allRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allRulesResult.rows.length === 0) { + logger.info(` 📭 복사할 채번 규칙 없음`); + return { copiedCount, ruleIdMap }; + } + + // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요) + const existingRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRuleIds = new Set( + existingRulesResult.rows.map((r) => r.rule_id) + ); + + // 3. 복사할 규칙과 스킵할 규칙 분류 + const rulesToCopy: any[] = []; + const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + + // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 + const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; + + for (const rule of allRulesResult.rows) { + // 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가 + // 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드 + let baseName = rule.rule_id; + + // 회사코드 접두사 패턴들을 순서대로 제거 시도 + // 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_) + // 2. 일반 접두사_ 패턴 (예: WACE_) + if (baseName.match(/^COMPANY_\d+_/)) { + baseName = baseName.replace(/^COMPANY_\d+_/, ""); + } else if (baseName.includes("_")) { + baseName = baseName.replace(/^[^_]+_/, ""); + } + + const newRuleId = `${targetCompanyCode}_${baseName}`; + + if (existingRuleIds.has(rule.rule_id)) { + // 원본 ID가 이미 존재 (동일한 ID로 매핑) + ruleIdMap.set(rule.rule_id, rule.rule_id); + + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); + } + logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); + } else if (existingRuleIds.has(newRuleId)) { + // 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑) + ruleIdMap.set(rule.rule_id, newRuleId); + + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid }); + } + logger.info( + ` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}` + ); + } else { + // 새로 복사 필요 + ruleIdMap.set(rule.rule_id, newRuleId); + originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); + rulesToCopy.push({ ...rule, newRuleId }); + logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`); + } + } + + // 4. 배치 INSERT로 채번 규칙 복사 + if (rulesToCopy.length > 0) { + const ruleValues = rulesToCopy + .map( + (_, i) => + `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` + ) + .join(", "); + + const ruleParams = rulesToCopy.flatMap((r) => { + const newMenuObjid = menuIdMap.get(r.menu_objid); + // scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건) + // menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로 + // scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리 + const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null; + // scope_type 결정 로직: + // 1. menu 스코프인데 menu_objid 매핑이 없는 경우 + // - table_name이 있으면 'table' 스코프로 변경 + // - table_name이 없으면 'global' 스코프로 변경 + // 2. 그 외에는 원본 scope_type 유지 + let finalScopeType = r.scope_type; + if (r.scope_type === "menu" && finalMenuObjid === null) { + if (r.table_name) { + finalScopeType = "table"; // table_name이 있으면 table 스코프 + } else { + finalScopeType = "global"; // table_name도 없으면 global 스코프 + } + } + + return [ + r.newRuleId, + r.rule_name, + r.description, + r.separator, + r.reset_period, + 0, + r.table_name, + r.column_name, + targetCompanyCode, + userId, + finalMenuObjid, + finalScopeType, + null, + ]; + }); + + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ${ruleValues}`, + ruleParams + ); + + copiedCount = rulesToCopy.length; + logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + } + + // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (rulesToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + // menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요 + const caseWhen = rulesToUpdate + .map( + (_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric` + ) + .join(" "); + const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); + const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); + + await client.query( + `UPDATE numbering_rules + SET menu_objid = CASE ${caseWhen} END, updated_at = NOW() + WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, ruleIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상) + if (rulesToCopy.length > 0) { + const originalRuleIds = rulesToCopy.map((r) => r.rule_id); + const allPartsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, + [originalRuleIds] + ); + + // 6. 배치 INSERT로 채번 규칙 파트 복사 + if (allPartsResult.rows.length > 0) { + // 원본 rule_id -> 새 rule_id 매핑 + const ruleMapping = new Map( + originalToNewRuleMap.map((m) => [m.original, m.new]) + ); + + const partValues = allPartsResult.rows + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ) + .join(", "); + + const partParams = allPartsResult.rows.flatMap((p) => [ + ruleMapping.get(p.rule_id), + p.part_order, + p.part_type, + p.generation_method, + p.auto_config, + p.manual_config, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ${partValues}`, + partParams + ); + + logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`); + } + } + + logger.info( + `✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개` + ); + return { copiedCount, ruleIdMap }; + } + + /** + * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) + */ + private async copyCategoryMappingsAndValues( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + let copiedCount = 0; + + if (menuObjids.length === 0) { + return copiedCount; + } + + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 매핑 한 번에 조회 + const allMappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allMappingsResult.rows.length === 0) { + logger.info(` 📭 복사할 카테고리 매핑 없음`); + return copiedCount; + } + + // 2. 대상 회사에 이미 존재하는 매핑 한 번에 조회 + const existingMappingsResult = await client.query( + `SELECT mapping_id, table_name, logical_column_name + FROM category_column_mapping WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingMappingKeys = new Map( + existingMappingsResult.rows.map((m) => [ + `${m.table_name}|${m.logical_column_name}`, + m.mapping_id, + ]) + ); + + // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류 + const mappingsToCopy: any[] = []; + const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> = + []; + + for (const m of allMappingsResult.rows) { + const key = `${m.table_name}|${m.logical_column_name}`; + if (existingMappingKeys.has(key)) { + // 기존 매핑은 menu_objid만 업데이트 + const existingMappingId = existingMappingKeys.get(key); + const newMenuObjid = menuIdMap.get(m.menu_objid); + if (existingMappingId && newMenuObjid) { + mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid }); + } + } else { + mappingsToCopy.push(m); + } + } + + // 새 매핑 ID -> 원본 매핑 정보 추적 + const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; + + if (mappingsToCopy.length > 0) { + const mappingValues = mappingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const mappingParams = mappingsToCopy.flatMap((m) => { + const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; + mappingInsertInfo.push({ mapping: m, newMenuObjid }); + return [ + m.table_name, + m.logical_column_name, + m.physical_column_name, + newMenuObjid, + targetCompanyCode, + m.description, + userId, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ${mappingValues} + RETURNING mapping_id`, + mappingParams + ); + + // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 + insertResult.rows.forEach((row, index) => { + const m = mappingsToCopy[index]; + existingMappingKeys.set( + `${m.table_name}|${m.logical_column_name}`, + row.mapping_id + ); + }); + + copiedCount = mappingsToCopy.length; + logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); + } + + // 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (mappingsToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = mappingsToUpdate + .map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId); + const params = mappingsToUpdate.flatMap((m) => [ + m.mappingId, + m.newMenuObjid, + ]); + + await client.query( + `UPDATE category_column_mapping + SET menu_objid = CASE ${caseWhen} END + WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, mappingIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 4. 모든 원본 카테고리 값 한 번에 조회 + const allValuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE menu_objid = ANY($1) + ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, + [menuObjids] + ); + + if (allValuesResult.rows.length === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 + const existingValuesResult = await client.query( + `SELECT value_id, table_name, column_name, value_code + FROM table_column_category_values WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingValueKeys = new Map( + existingValuesResult.rows.map((v) => [ + `${v.table_name}|${v.column_name}|${v.value_code}`, + v.value_id, + ]) + ); + + // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) + const valueIdMap = new Map(); + let copiedValues = 0; + + // 이미 존재하는 값들의 ID 매핑 + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + const existingId = existingValueKeys.get(key); + if (existingId) { + valueIdMap.set(value.value_id, existingId); + } + } + + // depth별로 그룹핑하여 배치 처리 (부모가 먼저 삽입되어야 함) + const valuesByDepth = new Map(); + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + if (existingValueKeys.has(key)) continue; // 이미 존재하면 스킵 + + const depth = value.depth ?? 0; + if (!valuesByDepth.has(depth)) { + valuesByDepth.set(depth, []); + } + valuesByDepth.get(depth)!.push(value); + } + + // depth 순서대로 처리 + const sortedDepths = Array.from(valuesByDepth.keys()).sort((a, b) => a - b); + + for (const depth of sortedDepths) { + const values = valuesByDepth.get(depth)!; + if (values.length === 0) continue; + + const valueStrings = values + .map( + (_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ) + .join(", "); + + const valueParams = values.flatMap((v) => { + const newMenuObjid = menuIdMap.get(v.menu_objid); + const newParentId = v.parent_value_id + ? valueIdMap.get(v.parent_value_id) || null + : null; + return [ + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, + newParentId, + v.depth, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, created_at, created_by, company_code, menu_objid + ) VALUES ${valueStrings} + RETURNING value_id`, + valueParams + ); + + // 새 value_id 매핑 + insertResult.rows.forEach((row, index) => { + valueIdMap.set(values[index].value_id, row.value_id); + }); + + copiedValues += values.length; + } + + if (copiedValues > 0) { + logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`); + } + + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + /** + * 테이블 타입관리 입력타입 설정 복사 (최적화: 배치 조회/삽입) + * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 + */ + private async copyTableTypeColumns( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.length === 0) { + return 0; + } + + logger.info(`📋 테이블 타입 설정 복사 시작`); + + // === 최적화: 배치 조회 === + // 1. 복사된 화면에서 사용하는 테이블 목록 조회 + const tablesResult = await client.query<{ table_name: string }>( + `SELECT DISTINCT table_name FROM screen_definitions + WHERE screen_id = ANY($1) AND table_name IS NOT NULL AND table_name != ''`, + [screenIds] + ); + + if (tablesResult.rows.length === 0) { + logger.info(" ⚠️ 복사된 화면에 테이블이 없음"); + return 0; + } + + const tableNames = tablesResult.rows.map((r) => r.table_name); + logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + + // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 + const sourceSettingsResult = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, sourceCompanyCode] + ); + + if (sourceSettingsResult.rows.length === 0) { + logger.info(` ⚠️ 원본 회사 설정 없음`); + return 0; + } + + // 3. 대상 회사의 기존 설정 한 번에 조회 + const existingSettingsResult = await client.query( + `SELECT table_name, column_name FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, targetCompanyCode] + ); + const existingKeys = new Set( + existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) + ); + + // 4. 복사할 설정 필터링 + const settingsToCopy = sourceSettingsResult.rows.filter( + (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) + ); + + logger.info( + ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` + ); + + // 5. 배치 INSERT + if (settingsToCopy.length > 0) { + const settingValues = settingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const settingParams = settingsToCopy.flatMap((s) => [ + s.table_name, + s.column_name, + s.input_type, + s.detail_settings, + s.is_nullable, + s.display_order, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ${settingValues}`, + settingParams + ); + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${settingsToCopy.length}개`); + return settingsToCopy.length; + } + + /** + * 연쇄관계 복사 (최적화: 배치 조회/삽입) + * - category_value_cascading_group + category_value_cascading_mapping + * - cascading_relation (테이블 기반) + */ + private async copyCascadingRelations( + sourceCompanyCode: string, + targetCompanyCode: string, + menuIdMap: Map, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 연쇄관계 복사 시작`); + let copiedCount = 0; + + // === 1. category_value_cascading_group 복사 === + const groupsResult = await client.query( + `SELECT * FROM category_value_cascading_group + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (groupsResult.rows.length === 0) { + logger.info(` 카테고리 값 연쇄 그룹: 0개`); + } else { + logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + + // 대상 회사의 기존 그룹 한 번에 조회 + const existingGroupsResult = await client.query( + `SELECT group_id, relation_code FROM category_value_cascading_group + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingGroupsByCode = new Map( + existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) + ); + + // group_id 매핑 + const groupIdMap = new Map(); + const groupsToCopy: any[] = []; + + for (const group of groupsResult.rows) { + const existingGroupId = existingGroupsByCode.get(group.relation_code); + if (existingGroupId) { + groupIdMap.set(group.group_id, existingGroupId); + } else { + groupsToCopy.push(group); + } + } + + logger.info( + ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` + ); + + // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) + for (const group of groupsToCopy) { + const newParentMenuObjid = group.parent_menu_objid + ? menuIdMap.get(Number(group.parent_menu_objid)) || null + : null; + const newChildMenuObjid = group.child_menu_objid + ? menuIdMap.get(Number(group.child_menu_objid)) || null + : null; + + const insertResult = await client.query( + `INSERT INTO category_value_cascading_group ( + relation_code, relation_name, description, + parent_table_name, parent_column_name, parent_menu_objid, + child_table_name, child_column_name, child_menu_objid, + clear_on_parent_change, show_group_label, + empty_parent_message, no_options_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) + RETURNING group_id`, + [ + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, + targetCompanyCode, + "Y", + userId, + ] + ); + + const newGroupId = insertResult.rows[0].group_id; + groupIdMap.set(group.group_id, newGroupId); + copiedCount++; + } + + // 모든 매핑 한 번에 조회 (복사할 그룹만) + const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); + if (groupIdsToCopy.length > 0) { + const allMappingsResult = await client.query( + `SELECT * FROM category_value_cascading_mapping + WHERE group_id = ANY($1) AND company_code = $2 + ORDER BY group_id, display_order`, + [groupIdsToCopy, sourceCompanyCode] + ); + + // 배치 INSERT + if (allMappingsResult.rows.length > 0) { + const mappingValues = allMappingsResult.rows + .map( + (_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ) + .join(", "); + + const mappingParams = allMappingsResult.rows.flatMap((m) => { + const newGroupId = groupIdMap.get(m.group_id); + return [ + newGroupId, + m.parent_value_code, + m.parent_value_label, + m.child_value_code, + m.child_value_label, + m.display_order, + targetCompanyCode, + "Y", + ]; + }); + + await client.query( + `INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ${mappingValues}`, + mappingParams + ); + + logger.info(` ↳ 매핑 ${allMappingsResult.rows.length}개 복사`); + } + } + } + + // === 2. cascading_relation 복사 (테이블 기반) === + const relationsResult = await client.query( + `SELECT * FROM cascading_relation + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (relationsResult.rows.length === 0) { + logger.info(` 기본 연쇄관계: 0개`); + } else { + logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + + // 대상 회사의 기존 관계 한 번에 조회 + const existingRelationsResult = await client.query( + `SELECT relation_code FROM cascading_relation + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRelationCodes = new Set( + existingRelationsResult.rows.map((r) => r.relation_code) + ); + + // 복사할 관계 필터링 + const relationsToCopy = relationsResult.rows.filter( + (r) => !existingRelationCodes.has(r.relation_code) + ); + + logger.info( + ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` + ); + + // 배치 INSERT + if (relationsToCopy.length > 0) { + const relationValues = relationsToCopy + .map( + (_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ) + .join(", "); + + const relationParams = relationsToCopy.flatMap((r) => [ + r.relation_code, + r.relation_name, + r.description, + r.parent_table, + r.parent_value_column, + r.parent_label_column, + r.child_table, + r.child_filter_column, + r.child_value_column, + r.child_label_column, + r.child_order_column, + r.child_order_direction, + r.empty_parent_message, + r.no_options_message, + r.loading_message, + r.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, + ]); + + await client.query( + `INSERT INTO cascading_relation ( + relation_code, relation_name, description, + parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, + child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, + clear_on_parent_change, company_code, is_active, created_by, created_date + ) VALUES ${relationValues}`, + relationParams + ); + + copiedCount += relationsToCopy.length; + } + } + + logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); + return copiedCount; + } } diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 7ba5c47e..8208ecc5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -898,9 +898,10 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (현재 순번으로 미리보기, 증가 안 함) + // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시) const length = autoConfig.sequenceLength || 3; - return String(rule.currentSequence || 1).padStart(length, "0"); + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { @@ -958,9 +959,10 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (자동 증가 숫자) + // 순번 (자동 증가 숫자 - 다음 번호 사용) const length = autoConfig.sequenceLength || 3; - return String(rule.currentSequence || 1).padStart(length, "0"); + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index 77087f25..f4991863 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -477,6 +477,12 @@ export class ReportService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; + // components가 이미 문자열이면 그대로, 객체면 JSON.stringify + const componentsData = + typeof originalLayout.components === "string" + ? originalLayout.components + : JSON.stringify(originalLayout.components); + await client.query(copyLayoutQuery, [ newLayoutId, newReportId, @@ -487,7 +493,7 @@ export class ReportService { originalLayout.margin_bottom, originalLayout.margin_left, originalLayout.margin_right, - JSON.stringify(originalLayout.components), + componentsData, userId, ]); } @@ -561,7 +567,7 @@ export class ReportService { } /** - * 레이아웃 저장 (쿼리 포함) + * 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조 */ async saveLayout( reportId: string, @@ -569,6 +575,19 @@ export class ReportService { userId: string ): Promise { return transaction(async (client) => { + // 첫 번째 페이지 정보를 기본 레이아웃으로 사용 + const firstPage = data.layoutConfig.pages[0]; + const canvasWidth = firstPage?.width || 210; + const canvasHeight = firstPage?.height || 297; + const pageOrientation = + canvasWidth > canvasHeight ? "landscape" : "portrait"; + const margins = firstPage?.margins || { + top: 20, + bottom: 20, + left: 20, + right: 20, + }; + // 1. 레이아웃 저장 const existingQuery = ` SELECT layout_id FROM report_layout WHERE report_id = $1 @@ -576,7 +595,7 @@ export class ReportService { const existing = await client.query(existingQuery, [reportId]); if (existing.rows.length > 0) { - // 업데이트 + // 업데이트 - components 컬럼에 전체 layoutConfig 저장 const updateQuery = ` UPDATE report_layout SET @@ -594,14 +613,14 @@ export class ReportService { `; await client.query(updateQuery, [ - data.canvasWidth, - data.canvasHeight, - data.pageOrientation, - data.marginTop, - data.marginBottom, - data.marginLeft, - data.marginRight, - JSON.stringify(data.components), + canvasWidth, + canvasHeight, + pageOrientation, + margins.top, + margins.bottom, + margins.left, + margins.right, + JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 userId, reportId, ]); @@ -627,14 +646,14 @@ export class ReportService { await client.query(insertQuery, [ layoutId, reportId, - data.canvasWidth, - data.canvasHeight, - data.pageOrientation, - data.marginTop, - data.marginBottom, - data.marginLeft, - data.marginRight, - JSON.stringify(data.components), + canvasWidth, + canvasHeight, + pageOrientation, + margins.top, + margins.bottom, + margins.left, + margins.right, + JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 userId, ]); } diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9fc0f079..92a35663 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1751,7 +1751,7 @@ export class ScreenManagementService { // 기타 label: "text-display", code: "select-basic", - entity: "select-basic", + entity: "entity-search-input", // 엔티티는 entity-search-input 사용 category: "select-basic", }; diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index cdf1b838..1638a417 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -79,6 +79,82 @@ class TableCategoryValueService { } } + /** + * 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) + * 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다. + */ + async getAllCategoryColumns( + companyCode: string + ): Promise { + try { + logger.info("전체 카테고리 컬럼 목록 조회", { companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거) + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COALESCE(cv_count.cnt, 0) AS "valueCount" + FROM ( + SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order + FROM table_type_columns + WHERE input_type = 'category' + GROUP BY table_name, column_name + ) tc + LEFT JOIN ( + SELECT table_name, column_name, COUNT(*) as cnt + FROM table_column_category_values + WHERE is_active = true + GROUP BY table_name, column_name + ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name + ORDER BY tc.table_name, tc.display_order, tc.column_name + `; + params = []; + } else { + // 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거) + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COALESCE(cv_count.cnt, 0) AS "valueCount" + FROM ( + SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order + FROM table_type_columns + WHERE input_type = 'category' + GROUP BY table_name, column_name + ) tc + LEFT JOIN ( + SELECT table_name, column_name, COUNT(*) as cnt + FROM table_column_category_values + WHERE is_active = true AND company_code = $1 + GROUP BY table_name, column_name + ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name + ORDER BY tc.table_name, tc.display_order, tc.column_name + `; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, { + companyCode, + }); + + return result.rows; + } catch (error: any) { + logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`); + throw error; + } + } + /** * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프) * diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9a8623a0..b714b186 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1447,7 +1447,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator // operator 전달 (equals면 직접 매칭) ); default: @@ -1676,7 +1677,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭 ): Promise<{ whereClause: string; values: any[]; @@ -1688,7 +1690,7 @@ export class TableManagementService { columnName ); - // 🆕 배열 처리: IN 절 사용 + // 배열 처리: IN 절 사용 if (Array.isArray(value)) { if (value.length === 0) { // 빈 배열이면 항상 false 조건 @@ -1720,13 +1722,35 @@ export class TableManagementService { } if (typeof value === "string" && value.trim() !== "") { - const displayColumn = entityTypeInfo.displayColumn || "name"; + // equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용) + if (operator === "equals") { + logger.info( + `🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}` + ); + return { + whereClause: `${columnName} = $${paramIndex}`, + values: [value], + paramCount: 1, + }; + } + + // contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색 const referenceColumn = entityTypeInfo.referenceColumn || "id"; + const referenceTable = entityTypeInfo.referenceTable; + + // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) + let displayColumn = entityTypeInfo.displayColumn; + if (!displayColumn || displayColumn === "none" || displayColumn === "") { + displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn); + logger.info( + `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` + ); + } // 참조 테이블의 표시 컬럼으로 검색 return { whereClause: `EXISTS ( - SELECT 1 FROM ${entityTypeInfo.referenceTable} ref + SELECT 1 FROM ${referenceTable} ref WHERE ref.${referenceColumn} = ${columnName} AND ref.${displayColumn} ILIKE $${paramIndex} )`, @@ -1754,6 +1778,66 @@ export class TableManagementService { } } + /** + * 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위) + * 우선순위: *_name > name > label/*_label > title > referenceColumn + */ + private async findDisplayColumnForTable( + tableName: string, + referenceColumn?: string + ): Promise { + try { + const result = await query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position`, + [tableName] + ); + + const allColumns = result.map((r) => r.column_name); + + // entityJoinService와 동일한 우선순위 + // 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외 + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + if (nameColumn) { + return nameColumn; + } + + // 2. name 컬럼 + if (allColumns.includes("name")) { + return "name"; + } + + // 3. label 또는 *_label 컬럼 + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + if (labelColumn) { + return labelColumn; + } + + // 4. title 컬럼 + if (allColumns.includes("title")) { + return "title"; + } + + // 5. 참조 컬럼 (referenceColumn) + if (referenceColumn && allColumns.includes(referenceColumn)) { + return referenceColumn; + } + + // 6. 기본값: 첫 번째 비-id 컬럼 또는 id + return allColumns.find((col) => col !== "id") || "id"; + } catch (error) { + logger.error(`표시 컬럼 감지 실패: ${tableName}`, error); + return referenceColumn || "id"; // 오류 시 기본값 + } + } + /** * 불린 검색 조건 구성 */ @@ -2205,6 +2289,13 @@ export class TableManagementService { logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); + // created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가 + const hasCreatedDate = columnTypeMap.has("created_date"); + if (hasCreatedDate && !data.created_date) { + data.created_date = new Date().toISOString(); + logger.info(`created_date 자동 추가: ${data.created_date}`); + } + // 컬럼명과 값을 분리하고 타입에 맞게 변환 const columns = Object.keys(data); const values = Object.values(data).map((value, index) => { @@ -2310,6 +2401,13 @@ export class TableManagementService { logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys); + // updated_date 컬럼이 있으면 자동으로 현재 시간 추가 + const hasUpdatedDate = columnTypeMap.has("updated_date"); + if (hasUpdatedDate && !updatedData.updated_date) { + updatedData.updated_date = new Date().toISOString(); + logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); + } + // SET 절 생성 (수정할 데이터) - 먼저 생성 const setConditions: string[] = []; const setValues: any[] = []; diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 77cc35d7..27254b0d 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -116,22 +116,55 @@ export interface UpdateReportRequest { useYn?: string; } +// 워터마크 설정 +export interface WatermarkConfig { + enabled: boolean; + type: "text" | "image"; + // 텍스트 워터마크 + text?: string; + fontSize?: number; + fontColor?: string; + // 이미지 워터마크 + imageUrl?: string; + // 공통 설정 + opacity: number; // 0~1 + style: "diagonal" | "center" | "tile"; + rotation?: number; // 대각선일 때 각도 (기본 -45) +} + +// 페이지 설정 +export interface PageConfig { + page_id: string; + page_name: string; + page_order: number; + width: number; + height: number; + background_color: string; + margins: { + top: number; + bottom: number; + left: number; + right: number; + }; + components: any[]; +} + +// 레이아웃 설정 +export interface ReportLayoutConfig { + pages: PageConfig[]; + watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크 +} + // 레이아웃 저장 요청 export interface SaveLayoutRequest { - canvasWidth: number; - canvasHeight: number; - pageOrientation: string; - marginTop: number; - marginBottom: number; - marginLeft: number; - marginRight: number; - components: any[]; + layoutConfig: ReportLayoutConfig; queries?: Array<{ id: string; name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; + externalConnectionId?: number; }>; } @@ -150,3 +183,113 @@ export interface CreateTemplateRequest { layoutConfig?: any; defaultQueries?: any; } + +// 컴포넌트 설정 (프론트엔드와 동기화) +export interface ComponentConfig { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fontSize?: number; + fontFamily?: string; + fontWeight?: string; + fontColor?: string; + backgroundColor?: string; + borderWidth?: number; + borderColor?: string; + borderRadius?: number; + textAlign?: string; + padding?: number; + queryId?: string; + fieldName?: string; + defaultValue?: string; + format?: string; + visible?: boolean; + printable?: boolean; + conditional?: string; + locked?: boolean; + groupId?: string; + // 이미지 전용 + imageUrl?: string; + objectFit?: "contain" | "cover" | "fill" | "none"; + // 구분선 전용 + orientation?: "horizontal" | "vertical"; + lineStyle?: "solid" | "dashed" | "dotted" | "double"; + lineWidth?: number; + lineColor?: string; + // 서명/도장 전용 + showLabel?: boolean; + labelText?: string; + labelPosition?: "top" | "left" | "bottom" | "right"; + showUnderline?: boolean; + personName?: string; + // 테이블 전용 + tableColumns?: Array<{ + field: string; + header: string; + width?: number; + align?: "left" | "center" | "right"; + }>; + headerBackgroundColor?: string; + headerTextColor?: string; + showBorder?: boolean; + rowHeight?: number; + // 페이지 번호 전용 + pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; + // 카드 컴포넌트 전용 + cardTitle?: string; + cardItems?: Array<{ + label: string; + value: string; + fieldName?: string; + }>; + labelWidth?: number; + showCardBorder?: boolean; + showCardTitle?: boolean; + titleFontSize?: number; + labelFontSize?: number; + valueFontSize?: number; + titleColor?: string; + labelColor?: string; + valueColor?: string; + // 계산 컴포넌트 전용 + calcItems?: Array<{ + label: string; + value: number | string; + operator: "+" | "-" | "x" | "÷"; + fieldName?: string; + }>; + resultLabel?: string; + resultColor?: string; + resultFontSize?: number; + showCalcBorder?: boolean; + numberFormat?: "none" | "comma" | "currency"; + currencySuffix?: string; + // 바코드 컴포넌트 전용 + barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; + barcodeValue?: string; + barcodeFieldName?: string; + showBarcodeText?: boolean; + barcodeColor?: string; + barcodeBackground?: string; + barcodeMargin?: number; + qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; + // QR코드 다중 필드 (JSON 형식) + qrDataFields?: Array<{ + fieldName: string; + label: string; + }>; + qrUseMultiField?: boolean; + qrIncludeAllRows?: boolean; + // 체크박스 컴포넌트 전용 + checkboxChecked?: boolean; // 체크 상태 (고정값) + checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) + checkboxLabel?: string; // 체크박스 옆 레이블 텍스트 + checkboxSize?: number; // 체크박스 크기 (px) + checkboxColor?: string; // 체크 색상 + checkboxBorderColor?: string; // 테두리 색상 + checkboxLabelPosition?: "left" | "right"; // 레이블 위치 +} diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index e80a1a61..c2c44be0 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -582,3 +582,7 @@ const result = await executeNodeFlow(flowId, { + + + + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 2ef68524..4ffb7655 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -355,3 +355,7 @@ - [ ] 부모 화면에서 모달로 데이터가 전달되는가? - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + + + + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md new file mode 100644 index 00000000..1de42fb2 --- /dev/null +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -0,0 +1,347 @@ +# 즉시 저장(quickInsert) 버튼 액션 구현 계획서 + +## 1. 개요 + +### 1.1 목적 +화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현 + +### 1.2 사용 사례 +- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장 + +### 1.3 화면 구성 예시 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [entity 선택박스] [버튼: quickInsert] │ +│ ┌─────────────────────────────┐ ┌──────────────┐ │ +│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │ +│ └─────────────────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 기술 설계 + +### 2.1 버튼 액션 타입 추가 + +```typescript +// types/screen-management.ts +type ButtonActionType = + | "save" + | "cancel" + | "delete" + | "edit" + | "add" + | "search" + | "reset" + | "submit" + | "close" + | "popup" + | "navigate" + | "custom" + | "quickInsert" // 🆕 즉시 저장 +``` + +### 2.2 quickInsert 설정 구조 + +```typescript +interface QuickInsertColumnMapping { + targetColumn: string; // 저장할 테이블의 컬럼명 + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; + + // sourceType별 추가 설정 + sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID + sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명 + fixedValue?: any; // fixed: 고정값 + userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode) +} + +interface QuickInsertConfig { + targetTable: string; // 저장할 테이블명 + columnMappings: QuickInsertColumnMapping[]; + + // 저장 후 동작 + afterInsert?: { + refreshRightPanel?: boolean; // 우측 패널 새로고침 + clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록 + showSuccessMessage?: boolean; // 성공 메시지 표시 + successMessage?: string; // 커스텀 성공 메시지 + }; + + // 중복 체크 (선택사항) + duplicateCheck?: { + enabled: boolean; + columns: string[]; // 중복 체크할 컬럼들 + errorMessage?: string; // 중복 시 에러 메시지 + }; +} + +interface ButtonComponentConfig { + // 기존 설정들... + actionType: ButtonActionType; + + // 🆕 quickInsert 전용 설정 + quickInsertConfig?: QuickInsertConfig; +} +``` + +### 2.3 데이터 흐름 + +``` +1. 사용자가 entity 선택박스에서 설비 선택 + └─ equipment_code = "EQ-001" (내부값) + └─ 표시: "MCT-01 - 머시닝센터 #1" + +2. 사용자가 "설비 추가" 버튼 클릭 + +3. quickInsert 핸들러 실행 + ├─ columnMappings 순회 + │ ├─ equipment_code: component에서 값 가져오기 → "EQ-001" + │ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001" + │ + └─ INSERT 데이터 구성 + { + equipment_code: "EQ-001", + process_code: "PRC-001", + company_code: "COMPANY_7", // 자동 추가 + writer: "wace" // 자동 추가 + } + +4. API 호출: POST /api/table-management/tables/process_equipment/add + +5. 성공 시 + ├─ 성공 메시지 표시 + ├─ 우측 패널(카드/테이블) 새로고침 + └─ 선택박스 초기화 +``` + +--- + +## 3. 구현 계획 + +### 3.1 Phase 1: 타입 정의 및 설정 UI + +| 작업 | 파일 | 설명 | +|------|------|------| +| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 | +| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 | + +### 3.2 Phase 2: 버튼 액션 핸들러 구현 + +| 작업 | 파일 | 설명 | +|------|------|------| +| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 | +| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 | + +### 3.3 Phase 3: 테스트 및 검증 + +| 작업 | 설명 | +|------|------| +| 3-1 | 공정별 설비 화면에서 테스트 | +| 3-2 | 중복 저장 방지 테스트 | +| 3-3 | 에러 처리 테스트 | + +--- + +## 4. 상세 구현 + +### 4.1 ButtonConfigPanel 설정 UI + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 버튼 액션 타입 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 즉시 저장 (quickInsert) ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 즉시 저장 설정 ─────────────── │ +│ │ +│ 대상 테이블 * │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ process_equipment ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 컬럼 매핑 [+ 추가] │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #1 [삭제] │ │ +│ │ 대상 컬럼: equipment_code │ │ +│ │ 값 소스: 컴포넌트 선택 │ │ +│ │ 컴포넌트: [equipment-select ▼] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #2 [삭제] │ │ +│ │ 대상 컬럼: process_code │ │ +│ │ 값 소스: 좌측 패널 데이터 │ │ +│ │ 소스 컬럼: process_code │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 저장 후 동작 ─────────────── │ +│ │ +│ ☑ 우측 패널 새로고침 │ +│ ☑ 선택박스 초기화 │ +│ ☑ 성공 메시지 표시 │ +│ │ +│ ─────────────── 중복 체크 (선택) ─────────────── │ +│ │ +│ ☐ 중복 체크 활성화 │ +│ 체크 컬럼: equipment_code, process_code │ +│ 에러 메시지: 이미 등록된 설비입니다. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 핸들러 구현 (의사 코드) + +```typescript +const handleQuickInsert = async (config: QuickInsertConfig) => { + // 1. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + + for (const mapping of config.columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + value = getComponentValue(mapping.sourceComponentId); + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn]; + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + value = user?.[mapping.userField]; + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 2. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다."); + return; + } + + // 3. 중복 체크 (설정된 경우) + if (config.duplicateCheck?.enabled) { + const isDuplicate = await checkDuplicate( + config.targetTable, + config.duplicateCheck.columns, + insertData + ); + if (isDuplicate) { + toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } + + // 4. API 호출 + try { + await tableTypeApi.addTableData(config.targetTable, insertData); + + // 5. 성공 후 동작 + if (config.afterInsert?.showSuccessMessage) { + toast.success(config.afterInsert.successMessage || "저장되었습니다."); + } + + if (config.afterInsert?.refreshRightPanel) { + // 우측 패널 새로고침 트리거 + onRefresh?.(); + } + + if (config.afterInsert?.clearComponents) { + // 지정된 컴포넌트 초기화 + for (const componentId of config.afterInsert.clearComponents) { + clearComponentValue(componentId); + } + } + + } catch (error) { + toast.error("저장에 실패했습니다."); + } +}; +``` + +--- + +## 5. 컴포넌트 간 통신 방안 + +### 5.1 문제점 +- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함 +- 현재는 각 컴포넌트가 독립적으로 동작 + +### 5.2 해결 방안: formData 활용 + +현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음. + +```typescript +// InteractiveScreenViewerDynamic.tsx +const [localFormData, setLocalFormData] = useState>({}); + +// entity 선택박스에서 값 변경 시 +const handleFormDataChange = (fieldName: string, value: any) => { + setLocalFormData(prev => ({ ...prev, [fieldName]: value })); +}; + +// 버튼 클릭 시 formData에서 값 가져오기 +const getComponentValue = (componentId: string) => { + // componentId로 컴포넌트의 columnName 찾기 + const component = allComponents.find(c => c.id === componentId); + if (component?.columnName) { + return formData[component.columnName]; + } + return undefined; +}; +``` + +--- + +## 6. 테스트 시나리오 + +### 6.1 정상 케이스 +1. 좌측 테이블에서 공정 "PRC-001" 선택 +2. 우측 설비 선택박스에서 "MCT-01" 선택 +3. "설비 추가" 버튼 클릭 +4. `process_equipment` 테이블에 데이터 저장 확인 +5. 우측 카드/테이블에 새 항목 표시 확인 + +### 6.2 에러 케이스 +1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지 +2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지 +3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지 + +### 6.3 엣지 케이스 +1. 동일 설비 연속 추가 시도 +2. 네트워크 오류 시 재시도 +3. 권한 없는 사용자의 저장 시도 + +--- + +## 7. 일정 + +| Phase | 작업 | 예상 시간 | +|-------|------|----------| +| Phase 1 | 타입 정의 및 설정 UI | 1시간 | +| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 | +| Phase 3 | 테스트 및 검증 | 30분 | +| **합계** | | **2시간 30분** | + +--- + +## 8. 향후 확장 가능성 + +1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가 +2. **수정 모드**: 기존 데이터 수정 기능 +3. **조건부 저장**: 특정 조건 만족 시에만 저장 +4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + + + diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 70382dd9..5b5f6b37 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab"; import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; +import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -20,7 +21,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); @@ -46,7 +47,7 @@ export default function CascadingManagementPage() { {/* 탭 네비게이션 */} - + 2단계 연쇄관계 @@ -72,6 +73,11 @@ export default function CascadingManagementPage() { 상호 배제 배제 + + + 카테고리값 + 카테고리 + {/* 탭 컨텐츠 */} @@ -95,6 +101,10 @@ export default function CascadingManagementPage() { + + + + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx new file mode 100644 index 00000000..ccb439e1 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx @@ -0,0 +1,1009 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + categoryValueCascadingApi, + CategoryValueCascadingGroup, + CategoryValueCascadingGroupInput, + CategoryValueCascadingMappingInput, +} from "@/lib/api/categoryValueCascading"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableInfo { + tableName: string; + displayName: string; + description?: string; + columnCount?: number; +} + +interface ColumnInfo { + columnName: string; + displayName: string; + dataType?: string; + inputType?: string; + input_type?: string; +} + +interface CategoryValue { + value: string; + label: string; +} + +export default function CategoryValueCascadingTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 테이블/컬럼 목록 + const [tables, setTables] = useState([]); + const [parentColumns, setParentColumns] = useState([]); + const [childColumns, setChildColumns] = useState([]); + + // Combobox 상태 + const [parentTableOpen, setParentTableOpen] = useState(false); + const [childTableOpen, setChildTableOpen] = useState(false); + + // 모달 상태 + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isMappingModalOpen, setIsMappingModalOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState({ + relationCode: "", + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + + // 매핑 상태 + const [parentValues, setParentValues] = useState([]); + const [childValues, setChildValues] = useState([]); + const [mappings, setMappings] = useState>>({}); + const [savingMappings, setSavingMappings] = useState(false); + + // 직접 입력 매핑 상태 (각 부모값에 대한 하위 옵션 목록) + const [childOptionsMap, setChildOptionsMap] = useState>({}); + const [newOptionInputs, setNewOptionInputs] = useState>({}); + + // 검색 + const [searchText, setSearchText] = useState(""); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await categoryValueCascadingApi.getGroups("Y"); + if (response.success && response.data) { + setGroups(response.data); + } else { + setError(response.error || "그룹 목록 로드 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + console.log("📦 테이블 목록 로드 시작"); + const response = await tableManagementApi.getTableList(); + console.log("📦 테이블 목록 응답:", response); + if (response.success && response.data) { + console.log("✅ 테이블 목록 로드 성공:", response.data.length, "개"); + setTables(response.data); + } else { + console.error("❌ 테이블 목록 로드 실패:", response); + } + } catch (err: any) { + console.error("테이블 목록 로드 실패:", err); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, target: "parent" | "child") => { + if (!tableName) { + if (target === "parent") setParentColumns([]); + else setChildColumns([]); + return; + } + + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data) { + // API 응답 형식: { columns: [...], total, page, ... } + const columns = response.data.columns || response.data; + const columnsArray = Array.isArray(columns) ? columns : []; + + // category 타입 컬럼만 필터링 + const categoryColumns = columnsArray.filter( + (col: any) => col.input_type === "category" || col.inputType === "category" + ); + + // 인터페이스에 맞게 변환 + const mappedColumns: ColumnInfo[] = categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + displayName: col.displayName || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + inputType: col.inputType || col.input_type, + })); + + if (target === "parent") setParentColumns(mappedColumns); + else setChildColumns(mappedColumns); + } + } catch (err: any) { + console.error("컬럼 목록 로드 실패:", err); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + }, [loadGroups, loadTables]); + + // 필터링된 그룹 + const filteredGroups = groups.filter((group) => { + if (!searchText) return true; + const lowerSearch = searchText.toLowerCase(); + return ( + group.relation_code.toLowerCase().includes(lowerSearch) || + group.relation_name.toLowerCase().includes(lowerSearch) || + group.parent_table_name.toLowerCase().includes(lowerSearch) || + group.child_table_name.toLowerCase().includes(lowerSearch) + ); + }); + + // 폼 초기화 + const resetForm = () => { + // 자동 관계코드 생성 + const autoCode = `CVC_${Date.now()}`; + setFormData({ + relationCode: autoCode, + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + setParentColumns([]); + setChildColumns([]); + }; + + // 생성 모달 열기 + const openCreateModal = async () => { + resetForm(); + // 테이블 목록이 없으면 다시 로드 + if (tables.length === 0) { + console.log("📦 테이블 목록이 비어있어서 다시 로드"); + await loadTables(); + } + setIsCreateModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setFormData({ + relationCode: group.relation_code, + relationName: group.relation_name, + description: group.description || "", + parentTableName: group.parent_table_name, + parentColumnName: group.parent_column_name, + childTableName: group.child_table_name, + childColumnName: group.child_column_name, + clearOnParentChange: group.clear_on_parent_change === "Y", + showGroupLabel: group.show_group_label === "Y", + emptyParentMessage: group.empty_parent_message || "", + noOptionsMessage: group.no_options_message || "", + }); + loadColumns(group.parent_table_name, "parent"); + loadColumns(group.child_table_name, "child"); + setIsEditModalOpen(true); + }; + + // 매핑 모달 열기 + const openMappingModal = async (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setMappings({}); + setParentValues([]); + setChildValues([]); + setChildOptionsMap({}); + setNewOptionInputs({}); + + try { + // 부모 카테고리 값과 기존 매핑 로드 + const [parentResponse, groupDetailResponse] = await Promise.all([ + categoryValueCascadingApi.getParentOptions(group.relation_code), + categoryValueCascadingApi.getGroupById(group.group_id), + ]); + + if (parentResponse.success && parentResponse.data) { + setParentValues(parentResponse.data); + + // 부모 값별 입력창 초기화 + const inputs: Record = {}; + for (const pv of parentResponse.data) { + inputs[pv.value] = ""; + } + setNewOptionInputs(inputs); + } + + // 기존 매핑을 직접 입력 형태로 변환 + if (groupDetailResponse.success && groupDetailResponse.data?.mappings) { + const optionsMap: Record = {}; + + for (const mapping of groupDetailResponse.data.mappings) { + const parentCode = mapping.parent_value_code; + if (!optionsMap[parentCode]) { + optionsMap[parentCode] = []; + } + // 중복 체크 + if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) { + optionsMap[parentCode].push({ + code: mapping.child_value_code, + label: mapping.child_value_label || mapping.child_value_code, + }); + } + } + setChildOptionsMap(optionsMap); + } + + setIsMappingModalOpen(true); + } catch (err: any) { + console.error("매핑 데이터 로드 실패:", err); + setError("매핑 데이터 로드 실패"); + } + }; + + // 고유 코드 생성 함수 + const generateUniqueCode = () => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `TARGET_${timestamp}${random}`; + }; + + // 하위 옵션 추가 + const addChildOption = (parentValue: string) => { + const inputValue = newOptionInputs[parentValue]?.trim(); + if (!inputValue) return; + + // 자동 고유 코드 생성 + const code = generateUniqueCode(); + + setChildOptionsMap((prev) => { + const currentOptions = prev[parentValue] || []; + // 중복 체크 (라벨만 체크 - 코드는 항상 고유) + if (currentOptions.some((opt) => opt.label === inputValue)) { + return prev; + } + return { + ...prev, + [parentValue]: [...currentOptions, { code, label: inputValue }], + }; + }); + + // 입력창 초기화 + setNewOptionInputs((prev) => ({ ...prev, [parentValue]: "" })); + }; + + // 하위 옵션 삭제 + const removeChildOption = (parentValue: string, optionCode: string) => { + setChildOptionsMap((prev) => ({ + ...prev, + [parentValue]: (prev[parentValue] || []).filter((opt) => opt.code !== optionCode), + })); + }; + + // 그룹 생성 + const handleCreate = async () => { + try { + const response = await categoryValueCascadingApi.createGroup(formData); + if (response.success) { + setIsCreateModalOpen(false); + loadGroups(); + } else { + setError(response.error || "생성 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 수정 + const handleUpdate = async () => { + if (!selectedGroup) return; + + try { + const response = await categoryValueCascadingApi.updateGroup(selectedGroup.group_id, formData); + if (response.success) { + setIsEditModalOpen(false); + loadGroups(); + } else { + setError(response.error || "수정 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 삭제 + const handleDelete = async (group: CategoryValueCascadingGroup) => { + if (!confirm(`"${group.relation_name}" 연쇄관계를 삭제하시겠습니까?`)) return; + + try { + const response = await categoryValueCascadingApi.deleteGroup(group.group_id); + if (response.success) { + loadGroups(); + } else { + setError(response.error || "삭제 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 매핑 토글 + const toggleMapping = (parentCode: string, childCode: string) => { + setMappings((prev) => { + const newMappings = { ...prev }; + if (!newMappings[parentCode]) { + newMappings[parentCode] = new Set(); + } + + const newSet = new Set(newMappings[parentCode]); + if (newSet.has(childCode)) { + newSet.delete(childCode); + } else { + newSet.add(childCode); + } + newMappings[parentCode] = newSet; + + return newMappings; + }); + }; + + // 매핑 저장 + const handleSaveMappings = async () => { + if (!selectedGroup) return; + + setSavingMappings(true); + try { + // 직접 입력된 옵션으로 매핑 데이터 생성 + const mappingInputs: CategoryValueCascadingMappingInput[] = []; + let displayOrder = 0; + + for (const parentCode of Object.keys(childOptionsMap)) { + const parentValue = parentValues.find((p) => p.value === parentCode); + const childOptions = childOptionsMap[parentCode] || []; + + for (const childOption of childOptions) { + mappingInputs.push({ + parentValueCode: parentCode, + parentValueLabel: parentValue?.label, + childValueCode: childOption.code, + childValueLabel: childOption.label, + displayOrder: displayOrder++, + }); + } + } + + const response = await categoryValueCascadingApi.saveMappings( + selectedGroup.group_id, + mappingInputs + ); + + if (response.success) { + setIsMappingModalOpen(false); + } else { + setError(response.error || "매핑 저장 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setSavingMappings(false); + } + }; + + // 활성/비활성 토글 + const toggleActive = async (group: CategoryValueCascadingGroup) => { + try { + const newActive = group.is_active !== "Y"; + const response = await categoryValueCascadingApi.updateGroup(group.group_id, { + isActive: newActive, + }); + if (response.success) { + loadGroups(); + } + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+ {/* 설명 */} +
+

카테고리 값 연쇄관계

+

+ 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시 +

+
+ + {/* 에러 메시지 */} + {error && ( +
+
+

오류

+ +
+

{error}

+
+ )} + + {/* 검색 및 액션 */} +
+
+
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ +
+
+ 총 {filteredGroups.length} 건 +
+ + +
+
+ + {/* 테이블 */} +
+ + + + 관계코드 + 관계명 + 부모 (테이블.컬럼) + 자식 (테이블.컬럼) + 사용 + 관리 + + + + {loading ? ( + Array.from({ length: 5 }).map((_, idx) => ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + )) + ) : filteredGroups.length === 0 ? ( + + + 등록된 카테고리 값 연쇄관계가 없습니다. + + + ) : ( + filteredGroups.map((group) => ( + + {group.relation_code} + {group.relation_name} + + {group.parent_table_name}. + {group.parent_column_name} + + + {group.child_table_name}. + {group.child_column_name} + + + toggleActive(group)} + aria-label="활성화 토글" + /> + + +
+ + + +
+
+
+ )) + )} + +
+
+ + {/* 생성 모달 */} + + + + 카테고리 값 연쇄관계 등록 + + 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. + + + +
+ {/* 기본 정보 */} +
+ + setFormData({ ...formData, relationName: e.target.value })} + placeholder="예: 검사유형-적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연쇄관계 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모 설정 */} +
+

부모 카테고리 설정

+
+
+ + + + + + + + + + + {tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."} + + + {tables.map((table) => ( + { + setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName }); + loadColumns(table.tableName, "parent"); + setParentTableOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.displayName && table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + +
+
+
+ + {/* 자식 옵션 라벨 설정 */} +
+

하위 옵션 설정

+

+ 부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다. +
+ 실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다. +

+
+ + setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })} + placeholder="예: 적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + 카테고리 값 연쇄관계 수정 + + 연쇄관계 설정을 수정합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + +
+
+ + setFormData({ ...formData, relationName: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모/자식 설정 - 수정 불가 표시 */} +
+

부모/자식 설정 (수정 불가)

+
+
+ 부모: + {formData.parentTableName}.{formData.parentColumnName} +
+
+ 자식: + {formData.childTableName}.{formData.childColumnName} +
+
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 값 매핑 모달 */} + + + + + 하위 옵션 설정 - {selectedGroup?.relation_name} + + + 각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다. + + + +
+ {parentValues.length === 0 ? ( +
+ 부모 카테고리 값이 등록되지 않았습니다. +
+ 먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요. +
+ ) : ( +
+ {parentValues.map((parent) => ( +
+
+

{parent.label}

+ + {(childOptionsMap[parent.value] || []).length}개 옵션 + +
+ + {/* 하위 옵션 입력 */} +
+ + setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value })) + } + onKeyDown={(e) => { + // 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지) + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + addChildOption(parent.value); + } + }} + placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼" + className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" + /> + +
+ + {/* 등록된 하위 옵션 목록 */} +
+ {(childOptionsMap[parent.value] || []).map((option) => ( +
+ {option.label} + +
+ ))} + {(childOptionsMap[parent.value] || []).length === 0 && ( + 등록된 하위 옵션이 없습니다 + )} +
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index cd0c462d..b554dff1 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -93,7 +93,7 @@ export default function TableManagementPage() { const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); - + // 테이블 복제 관련 상태 const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateSourceTable, setDuplicateSourceTable] = useState(null); @@ -109,7 +109,7 @@ export default function TableManagementPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); - + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -459,11 +459,39 @@ export default function TableManagementPage() { if (!selectedTable) return; try { + // 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함 + let finalDetailSettings = column.detailSettings || ""; + + if (column.inputType === "entity" && column.referenceTable) { + // 기존 detailSettings를 파싱하거나 새로 생성 + let existingSettings: Record = {}; + if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(column.detailSettings); + } catch { + existingSettings = {}; + } + } + + // 엔티티 설정 추가 + const entitySettings = { + ...existingSettings, + entityTable: column.referenceTable, + entityCodeColumn: column.referenceColumn || "id", + entityLabelColumn: column.displayColumn || "name", + placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요", + searchable: existingSettings.searchable ?? true, + }; + + finalDetailSettings = JSON.stringify(entitySettings); + console.log("🔧 Entity 설정 JSON 생성:", entitySettings); + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 inputType: column.inputType || "text", - detailSettings: column.detailSettings || "", + detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", @@ -487,7 +515,7 @@ export default function TableManagementPage() { if (response.data.success) { console.log("✅ 컬럼 설정 저장 성공"); - + // 🆕 Category 타입인 경우 컬럼 매핑 처리 console.log("🔍 카테고리 조건 체크:", { isCategory: column.inputType === "category", @@ -547,7 +575,7 @@ export default function TableManagementPage() { } else if (successCount > 0 && failCount > 0) { toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); } else if (failCount > 0) { - toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패."); } } else { toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); @@ -680,9 +708,7 @@ export default function TableManagementPage() { console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); if (totalSuccessCount > 0) { - toast.success( - `테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.` - ); + toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`); } else if (totalFailCount > 0) { toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); } else { @@ -1000,14 +1026,15 @@ export default function TableManagementPage() { .filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + (table.displayName && + table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), ) .every((table) => selectedTableIds.has(table.tableName)) } onCheckedChange={handleSelectAll} aria-label="전체 선택" /> - + {selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`} @@ -1047,9 +1074,9 @@ export default function TableManagementPage() {
e.stopPropagation()} /> )} -
handleTableSelect(table.tableName)} - > +
handleTableSelect(table.tableName)}>

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} @@ -1147,7 +1171,10 @@ export default function TableManagementPage() { ) : (

{/* 컬럼 헤더 (고정) */} -
+
컬럼명
라벨
입력 타입
@@ -1171,7 +1198,7 @@ export default function TableManagementPage() { className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }} > -
+
{column.columnName}
@@ -1226,9 +1253,9 @@ export default function TableManagementPage() { -
+
{secondLevelMenus.length === 0 ? ( -

+

2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.

) : ( @@ -1236,7 +1263,7 @@ export default function TableManagementPage() { // menuObjid를 숫자로 변환하여 비교 const menuObjidNum = Number(menu.menuObjid); const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - + return (
col.columnName === column.columnName ? { ...col, categoryMenus: newMenus } - : col - ) + : col, + ), ); }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2" /> @@ -1282,9 +1309,7 @@ export default function TableManagementPage() { <> {/* 참조 테이블 */}
- + @@ -1361,9 +1379,7 @@ export default function TableManagementPage() { column.referenceColumn && column.referenceColumn !== "none" && (
- + + + - updatePopupConfig({ - additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, - }) - } - placeholder="id" - className="mt-1 h-8 text-xs" - /> -
-
- - - updatePopupConfig({ - additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, - }) - } - placeholder="비워두면 매칭 컬럼과 동일" - className="mt-1 h-8 text-xs" - /> + > + + + + + 테이블 조회 + 커스텀 쿼리 + +
- {/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */} + {/* 테이블 조회 모드 */} + {(popupConfig.additionalQuery?.queryMode || "table") === "table" && ( + <> +
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value }, + }) + } + placeholder="vehicles" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="비워두면 매칭 컬럼과 동일" + className="mt-1 h-8 text-xs" + /> +
+ + )} + + {/* 커스텀 쿼리 모드 */} + {popupConfig.additionalQuery?.queryMode === "custom" && ( + <> +
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +

쿼리에서 사용할 파라미터 컬럼

+
+
+ +