diff --git a/.cursor/rules/multilang-component-guide.mdc b/.cursor/rules/multilang-component-guide.mdc new file mode 100644 index 00000000..60bdc0ec --- /dev/null +++ b/.cursor/rules/multilang-component-guide.mdc @@ -0,0 +1,559 @@ +# 다국어 지원 컴포넌트 개발 가이드 + +새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다. +이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다. + +--- + +## 1. 타입 정의 시 다국어 필드 추가 + +### 기본 원칙 + +텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다. + +### 단일 텍스트 속성 + +```typescript +interface MyComponentConfig { + // 기본 텍스트 + title?: string; + // 다국어 키 (필수 추가) + titleLangKeyId?: number; + titleLangKey?: string; + + // 라벨 + label?: string; + labelLangKeyId?: number; + labelLangKey?: string; + + // 플레이스홀더 + placeholder?: string; + placeholderLangKeyId?: number; + placeholderLangKey?: string; +} +``` + +### 배열/목록 속성 (컬럼, 탭 등) + +```typescript +interface ColumnConfig { + name: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 기타 속성 + width?: number; + align?: "left" | "center" | "right"; +} + +interface TabConfig { + id: string; + label: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; + // 탭 제목도 별도로 + title?: string; + titleLangKeyId?: number; + titleLangKey?: string; +} + +interface MyComponentConfig { + columns?: ColumnConfig[]; + tabs?: TabConfig[]; +} +``` + +### 버튼 컴포넌트 + +```typescript +interface ButtonComponentConfig { + text?: string; + // 다국어 키 (필수 추가) + langKeyId?: number; + langKey?: string; +} +``` + +### 실제 예시: 분할 패널 + +```typescript +interface SplitPanelLayoutConfig { + leftPanel?: { + title?: string; + langKeyId?: number; // 좌측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; // 각 컬럼 다국어 + langKey?: string; + }>; + }; + rightPanel?: { + title?: string; + langKeyId?: number; // 우측 패널 제목 다국어 + langKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + additionalTabs?: Array<{ + label: string; + langKeyId?: number; // 탭 라벨 다국어 + langKey?: string; + title?: string; + titleLangKeyId?: number; // 탭 제목 다국어 + titleLangKey?: string; + columns?: Array<{ + name: string; + label: string; + langKeyId?: number; + langKey?: string; + }>; + }>; + }; +} +``` + +--- + +## 2. 라벨 추출 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractMultilangLabels` 함수에 추가 + +새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다. + +```typescript +// 새 컴포넌트 타입 체크 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 추출 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 2. 컬럼 추출 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col, index) => { + const colLabel = col.label || col.name; + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: colLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } + + // 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우) + if (config?.text) { + addLabel({ + id: `${comp.id}_button`, + componentId: `${comp.id}_button`, + label: config.text, + type: "button", + parentType: "my-new-component", + parentLabel: config.text, + langKeyId: config.langKeyId, + langKey: config.langKey, + }); + } +} +``` + +### 추출해야 할 라벨 타입 + +| 타입 | 설명 | 예시 | +| ------------- | ------------------ | ------------------------ | +| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 | +| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 | +| `button` | 버튼 텍스트 | 저장, 취소, 삭제 | +| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 | +| `tab` | 탭 라벨 | 기본정보, 상세정보 | +| `filter` | 검색 필터 라벨 | 검색어, 기간 | +| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" | +| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 | + +--- + +## 3. 매핑 적용 로직 등록 + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `applyMultilangMappings` 함수에 추가 + +다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다. + +```typescript +// 새 컴포넌트 매핑 적용 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 1. 제목 매핑 + const titleMapping = mappingMap.get(`${comp.id}_title`); + if (titleMapping) { + updated.componentConfig = { + ...updated.componentConfig, + titleLangKeyId: titleMapping.keyId, + titleLangKey: titleMapping.langKey, + }; + } + + // 2. 컬럼 매핑 + if (config?.columns && Array.isArray(config.columns)) { + const updatedColumns = config.columns.map((col, index) => { + const colMapping = mappingMap.get(`${comp.id}_col_${index}`); + if (colMapping) { + return { + ...col, + langKeyId: colMapping.keyId, + langKey: colMapping.langKey, + }; + } + return col; + }); + updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, + }; + } + + // 3. 버튼 매핑 (버튼 컴포넌트인 경우) + const buttonMapping = mappingMap.get(`${comp.id}_button`); + if (buttonMapping) { + updated.componentConfig = { + ...updated.componentConfig, + langKeyId: buttonMapping.keyId, + langKey: buttonMapping.langKey, + }; + } +} +``` + +### 주의사항 + +- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다. +- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다. + +```typescript +// 잘못된 방법 - 이전 업데이트 덮어쓰기 +updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌ +updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐! + +// 올바른 방법 - 이전 업데이트 유지 +updated.componentConfig = { + ...updated.componentConfig, + langKeyId: mapping.keyId, +}; // ✅ +updated.componentConfig = { + ...updated.componentConfig, + columns: updatedColumns, +}; // ✅ +``` + +--- + +## 4. 번역 표시 로직 구현 + +### 파일 위치 + +새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`) + +### Context 사용 + +```typescript +import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; + +const MyComponent = ({ component }: Props) => { + const { getTranslatedText } = useScreenMultiLang(); + const config = component.componentConfig; + + // 제목 번역 + const displayTitle = config?.titleLangKey + ? getTranslatedText(config.titleLangKey, config.title || "") + : config?.title || ""; + + // 컬럼 헤더 번역 + const translatedColumns = config?.columns?.map((col) => ({ + ...col, + displayLabel: col.langKey + ? getTranslatedText(col.langKey, col.label) + : col.label, + })); + + // 버튼 텍스트 번역 + const buttonText = config?.langKey + ? getTranslatedText(config.langKey, config.text || "") + : config?.text || ""; + + return ( +
+

{displayTitle}

+ + + + {translatedColumns?.map((col, idx) => ( + + ))} + + +
{col.displayLabel}
+ +
+ ); +}; +``` + +### getTranslatedText 함수 + +```typescript +// 첫 번째 인자: langKey (다국어 키) +// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값) +const text = getTranslatedText( + "screen.company_1.Sales.OrderList.품목명", + "품목명" +); +``` + +### 주의사항 + +- `langKey`가 없으면 원본 텍스트를 표시합니다. +- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다. +- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다. + +--- + +## 5. ScreenMultiLangContext에 키 수집 로직 추가 + +### 파일 위치 + +`frontend/contexts/ScreenMultiLangContext.tsx` + +### `collectLangKeys` 함수에 추가 + +번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다. + +```typescript +const collectLangKeys = (comps: ComponentData[]): Set => { + const keys = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 langKey 수집 + if (comp.componentType === "my-new-component") { + // 제목 + if (config?.titleLangKey) { + keys.add(config.titleLangKey); + } + + // 컬럼 + if (config?.columns && Array.isArray(config.columns)) { + config.columns.forEach((col: any) => { + if (col.langKey) { + keys.add(col.langKey); + } + }); + } + + // 버튼 + if (config?.langKey) { + keys.add(config.langKey); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return keys; +}; +``` + +--- + +## 6. MultilangSettingsModal에 표시 로직 추가 + +### 파일 위치 + +`frontend/components/screen/modals/MultilangSettingsModal.tsx` + +### `extractLabelsFromComponents` 함수에 추가 + +다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다. + +```typescript +// 새 컴포넌트 라벨 추출 +if (comp.componentType === "my-new-component") { + const config = comp.componentConfig as MyComponentConfig; + + // 제목 + if (config?.title) { + addLabel({ + id: `${comp.id}_title`, + componentId: `${comp.id}_title`, + label: config.title, + type: "title", + parentType: "my-new-component", + parentLabel: config.title, + langKeyId: config.titleLangKeyId, + langKey: config.titleLangKey, + }); + } + + // 컬럼 + if (config?.columns) { + config.columns.forEach((col, index) => { + // columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우) + const tableName = config.tableName; + const displayLabel = + tableName && columnLabelMap[tableName]?.[col.name] + ? columnLabelMap[tableName][col.name] + : col.label || col.name; + + addLabel({ + id: `${comp.id}_col_${index}`, + componentId: `${comp.id}_col_${index}`, + label: displayLabel, + type: "column", + parentType: "my-new-component", + parentLabel: config.title || "새 컴포넌트", + langKeyId: col.langKeyId, + langKey: col.langKey, + }); + }); + } +} +``` + +--- + +## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우) + +### 파일 위치 + +`frontend/lib/utils/multilangLabelExtractor.ts` + +### `extractTableNames` 함수에 추가 + +컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다. + +```typescript +const extractTableNames = (comps: ComponentData[]): Set => { + const tableNames = new Set(); + + const processComponent = (comp: ComponentData) => { + const config = comp.componentConfig; + + // 새 컴포넌트의 테이블명 추출 + if (comp.componentType === "my-new-component") { + if (config?.tableName) { + tableNames.add(config.tableName); + } + if (config?.selectedTable) { + tableNames.add(config.selectedTable); + } + } + + // 자식 컴포넌트 재귀 처리 + if (comp.children && Array.isArray(comp.children)) { + comp.children.forEach(processComponent); + } + }; + + comps.forEach(processComponent); + return tableNames; +}; +``` + +--- + +## 8. 체크리스트 + +새 컴포넌트 개발 시 다음 항목을 확인하세요: + +### 타입 정의 + +- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가 +- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가 + +### 라벨 추출 (multilangLabelExtractor.ts) + +- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가 +- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우) + +### 매핑 적용 (multilangLabelExtractor.ts) + +- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가 + +### 번역 표시 (컴포넌트 파일) + +- [ ] `useScreenMultiLang` 훅 사용 +- [ ] `getTranslatedText`로 텍스트 번역 적용 + +### 키 수집 (ScreenMultiLangContext.tsx) + +- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가 + +### 설정 모달 (MultilangSettingsModal.tsx) + +- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가 + +--- + +## 9. 관련 파일 목록 + +| 파일 | 역할 | +| -------------------------------------------------------------- | ----------------------- | +| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 | +| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 | +| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI | +| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 | +| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 | + +--- + +## 10. 주의사항 + +1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용 + + - 제목: `${comp.id}_title` + - 컬럼: `${comp.id}_col_${index}` + - 버튼: `${comp.id}_button` + +2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정 + + - `${comp.id}_left_title`, `${comp.id}_right_col_${index}` + +3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트 + +4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리 + +5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트 diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png new file mode 100644 index 00000000..0fad6fa6 Binary files /dev/null and b/.playwright-mcp/pivotgrid-demo.png differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png new file mode 100644 index 00000000..79041f47 Binary files /dev/null and b/.playwright-mcp/pivotgrid-table.png differ diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png new file mode 100644 index 00000000..b14666b3 Binary files /dev/null and b/.playwright-mcp/pop-page-initial.png differ diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index d801ddbb..43b698d2 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -3216,6 +3217,16 @@ "@types/node": "*" } }, + "node_modules/@types/bwip-js": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz", + "integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e9ce3729..b1bfa319 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/bwip-js": "^3.2.3", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 97564672..3e8f63f1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) @@ -222,6 +223,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); app.use("/api/batch-configs", batchRoutes); +app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 231a7cdc..ce7b9c7f 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -553,10 +553,24 @@ export const setUserLocale = async ( const { locale } = req.body; - if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) { + if (!locale) { res.status(400).json({ success: false, - message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)", + message: "로케일이 필요합니다.", + }); + return; + } + + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + success: false, + message: `유효하지 않은 로케일입니다: ${locale}`, }); return; } @@ -1165,6 +1179,33 @@ export async function saveMenu( logger.info("메뉴 저장 성공", { savedMenu }); + // 다국어 메뉴 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + + // 회사명 조회 + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode); + + // 메뉴 경로 조회 및 카테고리 생성 + const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString()); + await multilangService.ensureMenuCategory(companyCode, companyName, menuPath); + + logger.info("메뉴 다국어 카테고리 생성 완료", { + menuObjId: savedMenu.objid.toString(), + menuPath, + }); + } catch (categoryError) { + logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", { + menuObjId: savedMenu.objid.toString(), + error: categoryError, + }); + } + const response: ApiResponse = { success: true, message: "메뉴가 성공적으로 저장되었습니다.", @@ -2649,6 +2690,24 @@ export const createCompany = async ( }); } + // 다국어 카테고리 자동 생성 + try { + const { MultiLangService } = await import("../services/multilangService"); + const multilangService = new MultiLangService(); + await multilangService.ensureCompanyCategory( + createdCompany.company_code, + createdCompany.company_name + ); + logger.info("회사 다국어 카테고리 생성 완료", { + companyCode: createdCompany.company_code, + }); + } catch (categoryError) { + logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", { + companyCode: createdCompany.company_code, + error: categoryError, + }); + } + logger.info("회사 등록 성공", { companyCode: createdCompany.company_code, companyName: createdCompany.company_name, @@ -3058,6 +3117,23 @@ export const updateProfile = async ( } if (locale !== undefined) { + // language_master 테이블에서 유효한 언어 코드인지 확인 + const validLang = await queryOne<{ lang_code: string }>( + "SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'", + [locale] + ); + + if (!validLang) { + res.status(400).json({ + result: false, + error: { + code: "INVALID_LOCALE", + details: `유효하지 않은 로케일입니다: ${locale}`, + }, + }); + return; + } + updateFields.push(`locale = $${paramIndex}`); updateValues.push(locale); paramIndex++; diff --git a/backend-node/src/controllers/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts index 29abfa8e..74d9e893 100644 --- a/backend-node/src/controllers/codeMergeController.ts +++ b/backend-node/src/controllers/codeMergeController.ts @@ -282,3 +282,175 @@ export async function previewCodeMerge( } } +/** + * 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경 + * 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경 + */ +export async function mergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 시작", { + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_by_value($1, $2, $3)", + [oldValue, newValue, companyCode] + ); + + // 결과 처리 + const affectedData = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = affectedData.reduce( + (sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0), + 0 + ); + + logger.info("값 기반 코드 병합 완료", { + oldValue, + newValue, + affectedTablesCount: affectedData.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + oldValue, + newValue, + affectedData: affectedData.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + rowsUpdated: parseInt(row.out_rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 실패:", { + error: error.message, + stack: error.stack, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 값 기반 코드 병합 미리보기 + * 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회 + */ +export async function previewMergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM preview_merge_code_by_value($1, $2)", + [oldValue, companyCode] + ); + + const preview = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = preview.reduce( + (sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0), + 0 + ); + + logger.info("값 기반 코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + oldValue, + preview: preview.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + affectedRows: parseInt(row.out_affected_rows), + })), + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 98606f51..48b55d18 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -231,7 +231,7 @@ export const deleteFormData = async ( try { const { id } = req.params; const { companyCode, userId } = req.user as any; - const { tableName } = req.body; + const { tableName, screenId } = req.body; if (!tableName) { return res.status(400).json({ @@ -240,7 +240,16 @@ export const deleteFormData = async ( }); } - await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가 + // screenId를 숫자로 변환 (문자열로 전달될 수 있음) + const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined; + + await dynamicFormService.deleteFormData( + id, + tableName, + companyCode, + userId, + parsedScreenId // screenId 추가 (제어관리 실행용) + ); res.json({ success: true, diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 4a541456..ab9bbc46 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,6 +30,7 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 + deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -49,6 +50,9 @@ export class EntityJoinController { // search가 문자열인 경우 JSON 파싱 searchConditions = typeof search === "string" ? JSON.parse(search) : search; + + // 🔍 디버그: 파싱된 검색 조건 로깅 + logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2)); } catch (error) { logger.warn("검색 조건 파싱 오류:", error); searchConditions = {}; @@ -151,6 +155,24 @@ export class EntityJoinController { } } + // 🆕 중복 제거 설정 처리 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined = undefined; + if (deduplication) { + try { + parsedDeduplication = + typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; + logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); + } catch (error) { + logger.warn("중복 제거 설정 파싱 오류:", error); + parsedDeduplication = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -168,13 +190,26 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 + deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); + // 🆕 중복 제거 처리 (결과 데이터에 적용) + let finalData = result; + if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { + logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); + const originalCount = result.data.length; + finalData = { + ...result, + data: this.deduplicateData(result.data, parsedDeduplication), + }; + logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); + } + res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: result, + data: finalData, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -549,6 +584,98 @@ export class EntityJoinController { }); } } + + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price가 true인 행 선택 + selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; + break; + + case "current_date": + // 오늘 날짜 기준 유효 기간 내 행 선택 + const today = new Date().toISOString().split("T")[0]; + selectedRow = rows.find((r) => { + const startDate = r.start_date; + const endDate = r.end_date; + if (!startDate) return true; + if (startDate <= today && (!endDate || endDate >= today)) return true; + return false; + }) || rows[0]; + break; + + default: + selectedRow = rows[0]; + } + + if (selectedRow) { + result.push(selectedRow); + } + } + + return result; + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 4d911c57..5f198c3f 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { } // 추가 필터 조건 (존재하는 컬럼만) + // 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like + // 특수 키 형식: column__operator (예: division__in, name__like) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - if (existingColumns.has(key)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; - } else { - logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + // 특수 키 형식 파싱: column__operator + let columnName = key; + let operator = "="; + + if (key.includes("__")) { + const parts = key.split("__"); + columnName = parts[0]; + operator = parts[1] || "="; + } + + if (!existingColumns.has(columnName)) { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName }); + continue; + } + + // 연산자별 WHERE 조건 생성 + switch (operator) { + case "=": + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${columnName}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + whereConditions.push(`"${columnName}" > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<": + whereConditions.push(`"${columnName}" < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">=": + whereConditions.push(`"${columnName}" >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<=": + whereConditions.push(`"${columnName}" <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": + // IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열 + const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inValues.length > 0) { + const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" IN (${placeholders})`); + params.push(...inValues); + paramIndex += inValues.length; + } + break; + case "notIn": + // NOT IN 연산자 + const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInValues.length > 0) { + const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" NOT IN (${placeholders})`); + params.push(...notInValues); + paramIndex += notInValues.length; + } + break; + case "like": + whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + default: + // 알 수 없는 연산자는 등호로 처리 + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; } } diff --git a/backend-node/src/controllers/excelMappingController.ts b/backend-node/src/controllers/excelMappingController.ts new file mode 100644 index 00000000..e29d4fe2 --- /dev/null +++ b/backend-node/src/controllers/excelMappingController.ts @@ -0,0 +1,208 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import excelMappingService from "../services/excelMappingService"; +import { logger } from "../utils/logger"; + +/** + * 엑셀 컬럼 구조로 매핑 템플릿 조회 + * POST /api/excel-mapping/find + */ +export async function findMappingByColumns( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !excelColumns || !Array.isArray(excelColumns)) { + res.status(400).json({ + success: false, + message: "tableName과 excelColumns(배열)가 필요합니다.", + }); + return; + } + + logger.info("엑셀 매핑 템플릿 조회 요청", { + tableName, + excelColumns, + companyCode, + userId: req.user?.userId, + }); + + const template = await excelMappingService.findMappingByColumns( + tableName, + excelColumns, + companyCode + ); + + if (template) { + res.json({ + success: true, + data: template, + message: "기존 매핑 템플릿을 찾았습니다.", + }); + } else { + res.json({ + success: true, + data: null, + message: "일치하는 매핑 템플릿이 없습니다.", + }); + } + } catch (error: any) { + logger.error("매핑 템플릿 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 매핑 템플릿 저장 (UPSERT) + * POST /api/excel-mapping/save + */ +export async function saveMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, excelColumns, columnMappings } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!tableName || !excelColumns || !columnMappings) { + res.status(400).json({ + success: false, + message: "tableName, excelColumns, columnMappings가 필요합니다.", + }); + return; + } + + logger.info("엑셀 매핑 템플릿 저장 요청", { + tableName, + excelColumns, + columnMappings, + companyCode, + userId, + }); + + const template = await excelMappingService.saveMappingTemplate( + tableName, + excelColumns, + columnMappings, + companyCode, + userId + ); + + res.json({ + success: true, + data: template, + message: "매핑 템플릿이 저장되었습니다.", + }); + } catch (error: any) { + logger.error("매핑 템플릿 저장 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 저장 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 테이블의 매핑 템플릿 목록 조회 + * GET /api/excel-mapping/list/:tableName + */ +export async function getMappingTemplates( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "tableName이 필요합니다.", + }); + return; + } + + logger.info("매핑 템플릿 목록 조회 요청", { + tableName, + companyCode, + }); + + const templates = await excelMappingService.getMappingTemplates( + tableName, + companyCode + ); + + res.json({ + success: true, + data: templates, + }); + } catch (error: any) { + logger.error("매핑 템플릿 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 매핑 템플릿 삭제 + * DELETE /api/excel-mapping/:id + */ +export async function deleteMappingTemplate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!id) { + res.status(400).json({ + success: false, + message: "id가 필요합니다.", + }); + return; + } + + logger.info("매핑 템플릿 삭제 요청", { + id, + companyCode, + }); + + const deleted = await excelMappingService.deleteMappingTemplate( + parseInt(id), + companyCode + ); + + if (deleted) { + res.json({ + success: true, + message: "매핑 템플릿이 삭제되었습니다.", + }); + } else { + res.status(404).json({ + success: false, + message: "삭제할 매핑 템플릿을 찾을 수 없습니다.", + }); + } + } catch (error: any) { + logger.error("매핑 템플릿 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "매핑 템플릿 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 14155f86..f14fc3b5 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -10,7 +10,10 @@ import { SaveLangTextsRequest, GetUserTextParams, BatchTranslationRequest, + GenerateKeyRequest, + CreateOverrideKeyRequest, ApiResponse, + LangCategory, } from "../types/multilang"; /** @@ -187,7 +190,7 @@ export const getLangKeys = async ( res: Response ): Promise => { try { - const { companyCode, menuCode, keyType, searchText } = req.query; + const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; logger.info("다국어 키 목록 조회 요청", { query: req.query, user: req.user, @@ -199,6 +202,7 @@ export const getLangKeys = async ( menuCode: menuCode as string, keyType: keyType as string, searchText: searchText as string, + categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined, }); const response: ApiResponse = { @@ -630,6 +634,391 @@ export const deleteLanguage = async ( } }; +// ===================================================== +// 카테고리 관련 API +// ===================================================== + +/** + * GET /api/multilang/categories + * 카테고리 목록 조회 API (트리 구조) + */ +export const getCategories = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + logger.info("카테고리 목록 조회 요청", { user: req.user }); + + const multiLangService = new MultiLangService(); + const categories = await multiLangService.getCategories(); + + const response: ApiResponse = { + success: true, + message: "카테고리 목록 조회 성공", + data: categories, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 목록 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/categories/:categoryId + * 카테고리 상세 조회 API + */ +export const getCategoryById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId } = req.params; + logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user }); + + const multiLangService = new MultiLangService(); + const category = await multiLangService.getCategoryById(parseInt(categoryId)); + + if (!category) { + res.status(404).json({ + success: false, + message: "카테고리를 찾을 수 없습니다.", + error: { + code: "CATEGORY_NOT_FOUND", + details: `Category ID ${categoryId} not found`, + }, + }); + return; + } + + const response: ApiResponse = { + success: true, + message: "카테고리 상세 조회 성공", + data: category, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 상세 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 상세 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_DETAIL_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/categories/:categoryId/path + * 카테고리 경로 조회 API (부모 포함) + */ +export const getCategoryPath = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId } = req.params; + logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user }); + + const multiLangService = new MultiLangService(); + const path = await multiLangService.getCategoryPath(parseInt(categoryId)); + + const response: ApiResponse = { + success: true, + message: "카테고리 경로 조회 성공", + data: path, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("카테고리 경로 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 경로 조회 중 오류가 발생했습니다.", + error: { + code: "CATEGORY_PATH_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +// ===================================================== +// 자동 생성 및 오버라이드 관련 API +// ===================================================== + +/** + * POST /api/multilang/keys/generate + * 키 자동 생성 API + */ +export const generateKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const generateData: GenerateKeyRequest = req.body; + logger.info("키 자동 생성 요청", { generateData, user: req.user }); + + // 필수 입력값 검증 + if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) { + res.status(400).json({ + success: false, + message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode, categoryId, and keyMeaning are required", + }, + }); + return; + } + + // 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능 + if (generateData.companyCode === "*" && req.user?.companyCode !== "*") { + res.status(403).json({ + success: false, + message: "공통 키는 최고 관리자만 생성할 수 있습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Only super admin can create common keys", + }, + }); + return; + } + + // 회사 관리자는 자기 회사 키만 생성 가능 + if (generateData.companyCode !== "*" && + req.user?.companyCode !== "*" && + generateData.companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 키를 생성할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot create keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.generateKey({ + ...generateData, + createdBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("키 자동 생성 실패:", error); + res.status(500).json({ + success: false, + message: "키 자동 생성 중 오류가 발생했습니다.", + error: { + code: "KEY_GENERATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/preview + * 키 미리보기 API + */ +export const previewKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { categoryId, keyMeaning, companyCode } = req.body; + logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user }); + + if (!categoryId || !keyMeaning || !companyCode) { + res.status(400).json({ + success: false, + message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "categoryId, keyMeaning, and companyCode are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const preview = await multiLangService.previewGeneratedKey( + parseInt(categoryId), + keyMeaning, + companyCode + ); + + const response: ApiResponse<{ + langKey: string; + exists: boolean; + isOverride: boolean; + baseKeyId?: number; + }> = { + success: true, + message: "키 미리보기 성공", + data: preview, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("키 미리보기 실패:", error); + res.status(500).json({ + success: false, + message: "키 미리보기 중 오류가 발생했습니다.", + error: { + code: "KEY_PREVIEW_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/override + * 오버라이드 키 생성 API + */ +export const createOverrideKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const overrideData: CreateOverrideKeyRequest = req.body; + logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user }); + + // 필수 입력값 검증 + if (!overrideData.companyCode || !overrideData.baseKeyId) { + res.status(400).json({ + success: false, + message: "회사 코드와 원본 키 ID는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode and baseKeyId are required", + }, + }); + return; + } + + // 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키) + if (overrideData.companyCode === "*") { + res.status(400).json({ + success: false, + message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.", + error: { + code: "INVALID_OVERRIDE", + details: "Cannot create override for common keys", + }, + }); + return; + } + + // 회사 관리자는 자기 회사 오버라이드만 생성 가능 + if (req.user?.companyCode !== "*" && + overrideData.companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot create override keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.createOverrideKey({ + ...overrideData, + createdBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "오버라이드 키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("오버라이드 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "오버라이드 키 생성 중 오류가 발생했습니다.", + error: { + code: "OVERRIDE_KEY_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys/overrides/:companyCode + * 회사별 오버라이드 키 목록 조회 API + */ +export const getOverrideKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user }); + + // 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능 + if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.", + error: { + code: "PERMISSION_DENIED", + details: "Cannot view override keys for other companies", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keys = await multiLangService.getOverrideKeys(companyCode); + + const response: ApiResponse = { + success: true, + message: "오버라이드 키 목록 조회 성공", + data: keys, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("오버라이드 키 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.", + error: { + code: "OVERRIDE_KEYS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + /** * POST /api/multilang/batch * 다국어 텍스트 배치 조회 API @@ -710,3 +1099,86 @@ export const getBatchTranslations = async ( }); } }; + +/** + * POST /api/multilang/screen-labels + * 화면 라벨 다국어 키 자동 생성 API + */ +export const generateScreenLabelKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId, menuObjId, labels } = req.body; + + logger.info("화면 라벨 다국어 키 생성 요청", { + screenId, + menuObjId, + labelCount: labels?.length, + user: req.user, + }); + + // 필수 파라미터 검증 + if (!screenId) { + res.status(400).json({ + success: false, + message: "screenId는 필수입니다.", + error: { code: "MISSING_SCREEN_ID" }, + }); + return; + } + + if (!labels || !Array.isArray(labels) || labels.length === 0) { + res.status(400).json({ + success: false, + message: "labels 배열이 필요합니다.", + error: { code: "MISSING_LABELS" }, + }); + return; + } + + // 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준) + const { queryOne } = await import("../database/db"); + const screenInfo = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + const companyCode = screenInfo?.company_code || req.user?.companyCode || "*"; + + // 회사명 조회 + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode); + + logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName }); + + const multiLangService = new MultiLangService(); + const results = await multiLangService.generateScreenLabelKeys({ + screenId: Number(screenId), + companyCode, + companyName, + menuObjId, + labels, + }); + + const response: ApiResponse = { + success: true, + message: `${results.length}개의 다국어 키가 생성되었습니다.`, + data: results, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "SCREEN_LABEL_KEY_GENERATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 031a1506..ab7114a5 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -217,11 +217,14 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq const companyCode = req.user!.companyCode; const { ruleId } = req.params; + logger.info("코드 할당 요청", { ruleId, companyCode }); + try { const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); return res.json({ success: true, data: { generatedCode: allocatedCode } }); } catch (error: any) { - logger.error("코드 할당 실패", { error: error.message }); + logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 569fe793..2d7bc0e1 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,18 +1,23 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { MultiLangService } from "../services/multilangService"; +import { AuthenticatedRequest } from "../types/auth"; // pool 인스턴스 가져오기 const pool = getPool(); +// 다국어 서비스 인스턴스 +const multiLangService = new MultiLangService(); + // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ // 화면 그룹 목록 조회 -export const getScreenGroups = async (req: Request, res: Response) => { +export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { page = 1, size = 20, searchTerm } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); @@ -84,10 +89,10 @@ export const getScreenGroups = async (req: Request, res: Response) => { }; // 화면 그룹 상세 조회 -export const getScreenGroup = async (req: Request, res: Response) => { +export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = ` SELECT sg.*, @@ -130,10 +135,10 @@ export const getScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 생성 -export const createScreenGroup = async (req: Request, res: Response) => { +export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const userCompanyCode = req.user!.companyCode; + const userId = req.user!.userId; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; if (!group_name || !group_code) { @@ -191,6 +196,47 @@ export const createScreenGroup = async (req: Request, res: Response) => { // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); + // 다국어 카테고리 자동 생성 (그룹 경로 기반) + try { + // 그룹 경로 조회 (상위 그룹 → 현재 그룹) + const groupPathResult = await pool.query( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name FROM group_path + ORDER BY depth DESC`, + [newGroupId] + ); + + const groupPath = groupPathResult.rows.map((r: any) => r.group_name); + + // 회사 이름 조회 + let companyName = "공통"; + if (finalCompanyCode !== "*") { + const companyResult = await pool.query( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [finalCompanyCode] + ); + if (companyResult.rows.length > 0) { + companyName = companyResult.rows[0].company_name; + } + } + + // 다국어 카테고리 생성 + await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); + logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); + } catch (multilangError: any) { + // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 + logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); + } + logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); @@ -204,10 +250,10 @@ export const createScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 수정 -export const updateScreenGroup = async (req: Request, res: Response) => { +export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const userCompanyCode = (req.user as any).companyCode; + const userCompanyCode = req.user!.companyCode; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 @@ -293,10 +339,10 @@ export const updateScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: Request, res: Response) => { +export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; @@ -329,10 +375,10 @@ export const deleteScreenGroup = async (req: Request, res: Response) => { // ============================================================ // 그룹에 화면 추가 -export const addScreenToGroup = async (req: Request, res: Response) => { +export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { group_id, screen_id, screen_role, display_order, is_default } = req.body; if (!group_id || !screen_id) { @@ -369,10 +415,10 @@ export const addScreenToGroup = async (req: Request, res: Response) => { }; // 그룹에서 화면 제거 -export const removeScreenFromGroup = async (req: Request, res: Response) => { +export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; @@ -400,10 +446,10 @@ export const removeScreenFromGroup = async (req: Request, res: Response) => { }; // 그룹 내 화면 순서/역할 수정 -export const updateScreenInGroup = async (req: Request, res: Response) => { +export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { screen_role, display_order, is_default } = req.body; let query = ` @@ -439,9 +485,9 @@ export const updateScreenInGroup = async (req: Request, res: Response) => { // ============================================================ // 화면 필드 조인 목록 조회 -export const getFieldJoins = async (req: Request, res: Response) => { +export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { screen_id } = req.query; let query = ` @@ -480,10 +526,10 @@ export const getFieldJoins = async (req: Request, res: Response) => { }; // 화면 필드 조인 생성 -export const createFieldJoin = async (req: Request, res: Response) => { +export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { screen_id, layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -521,10 +567,10 @@ export const createFieldJoin = async (req: Request, res: Response) => { }; // 화면 필드 조인 수정 -export const updateFieldJoin = async (req: Request, res: Response) => { +export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -566,10 +612,10 @@ export const updateFieldJoin = async (req: Request, res: Response) => { }; // 화면 필드 조인 삭제 -export const deleteFieldJoin = async (req: Request, res: Response) => { +export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; @@ -600,9 +646,9 @@ export const deleteFieldJoin = async (req: Request, res: Response) => { // ============================================================ // 데이터 흐름 목록 조회 -export const getDataFlows = async (req: Request, res: Response) => { +export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { group_id, source_screen_id } = req.query; let query = ` @@ -650,10 +696,10 @@ export const getDataFlows = async (req: Request, res: Response) => { }; // 데이터 흐름 생성 -export const createDataFlow = async (req: Request, res: Response) => { +export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -689,10 +735,10 @@ export const createDataFlow = async (req: Request, res: Response) => { }; // 데이터 흐름 수정 -export const updateDataFlow = async (req: Request, res: Response) => { +export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -732,10 +778,10 @@ export const updateDataFlow = async (req: Request, res: Response) => { }; // 데이터 흐름 삭제 -export const deleteDataFlow = async (req: Request, res: Response) => { +export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; @@ -766,9 +812,9 @@ export const deleteDataFlow = async (req: Request, res: Response) => { // ============================================================ // 화면-테이블 관계 목록 조회 -export const getTableRelations = async (req: Request, res: Response) => { +export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { screen_id, group_id } = req.query; let query = ` @@ -815,10 +861,10 @@ export const getTableRelations = async (req: Request, res: Response) => { }; // 화면-테이블 관계 생성 -export const createTableRelation = async (req: Request, res: Response) => { +export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; if (!screen_id || !table_name) { @@ -848,10 +894,10 @@ export const createTableRelation = async (req: Request, res: Response) => { }; // 화면-테이블 관계 수정 -export const updateTableRelation = async (req: Request, res: Response) => { +export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` @@ -883,10 +929,10 @@ export const updateTableRelation = async (req: Request, res: Response) => { }; // 화면-테이블 관계 삭제 -export const deleteTableRelation = async (req: Request, res: Response) => { +export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user!.companyCode; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5bbec536..e8c5a1bb 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -804,6 +804,12 @@ export async function getTableData( } } + // 🆕 최종 검색 조건 로그 + logger.info( + `🔍 최종 검색 조건 (enhancedSearch):`, + JSON.stringify(enhancedSearch) + ); + // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), @@ -887,7 +893,10 @@ export async function addTableData( const companyCode = req.user?.companyCode; if (companyCode && !data.company_code) { // 테이블에 company_code 컬럼이 있는지 확인 - const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); + const hasCompanyCodeColumn = await tableManagementService.hasColumn( + tableName, + "company_code" + ); if (hasCompanyCodeColumn) { data.company_code = companyCode; logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); @@ -897,7 +906,10 @@ export async function addTableData( // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) const userId = req.user?.userId; if (userId && !data.writer) { - const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); + const hasWriterColumn = await tableManagementService.hasColumn( + tableName, + "writer" + ); if (hasWriterColumn) { data.writer = userId; logger.info(`writer 자동 추가 - ${userId}`); @@ -905,13 +917,25 @@ export async function addTableData( } // 데이터 추가 - await tableManagementService.addTableData(tableName, data); + const result = await tableManagementService.addTableData(tableName, data); logger.info(`테이블 데이터 추가 완료: ${tableName}`); - const response: ApiResponse = { + // 무시된 컬럼이 있으면 경고 정보 포함 + const response: ApiResponse<{ + skippedColumns?: string[]; + savedColumns?: string[]; + }> = { success: true, - message: "테이블 데이터를 성공적으로 추가했습니다.", + message: + result.skippedColumns.length > 0 + ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` + : "테이블 데이터를 성공적으로 추가했습니다.", + data: { + skippedColumns: + result.skippedColumns.length > 0 ? result.skippedColumns : undefined, + savedColumns: result.savedColumns, + }, }; res.status(201).json(response); @@ -1639,10 +1663,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1655,7 +1679,10 @@ export async function getCategoryColumnsByMenu( const { menuObjid } = req.params; const companyCode = req.user?.companyCode; - logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { + menuObjid, + companyCode, + }); if (!menuObjid) { res.status(400).json({ @@ -1681,8 +1708,11 @@ export async function getCategoryColumnsByMenu( if (mappingTableExists) { // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); - + logger.info( + "🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", + { menuObjid, companyCode } + ); + // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( @@ -1704,17 +1734,21 @@ export async function getCategoryColumnsByMenu( ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; - - const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); - const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + + const ancestorMenuResult = await pool.query(ancestorMenuQuery, [ + parseInt(menuObjid), + ]); + const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [ + parseInt(menuObjid), + ]; const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; - - logger.info("✅ 상위 메뉴 계층 조회 완료", { - ancestorMenuObjids, + + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, ancestorMenuNames, - hierarchyDepth: ancestorMenuObjids.length + hierarchyDepth: ancestorMenuObjids.length, }); - + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT @@ -1744,20 +1778,31 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - - columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { - rowCount: columnsResult.rows.length, - columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) - }); + + columnsResult = await pool.query(columnsQuery, [ + companyCode, + ancestorMenuObjids, + ]); + logger.info( + "✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", + { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map( + (r: any) => `${r.tableName}.${r.columnName}` + ), + } + ); } else { // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { + menuObjid, + companyCode, + }); + // 형제 메뉴 조회 const { getSiblingMenuObjids } = await import("../services/menuService"); const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - + // 형제 메뉴들이 사용하는 테이블 조회 const tablesQuery = ` SELECT DISTINCT sd.table_name @@ -1767,11 +1812,17 @@ export async function getCategoryColumnsByMenu( AND sma.company_code = $2 AND sd.table_name IS NOT NULL `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + + const tablesResult = await pool.query(tablesQuery, [ + siblingObjids, + companyCode, + ]); const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { + tableNames, + count: tableNames.length, + }); if (tableNames.length === 0) { res.json({ @@ -1781,7 +1832,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1806,13 +1857,15 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ttc.column_name `; - + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); - logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length }); + logger.info("✅ 레거시 방식 조회 완료", { + rowCount: columnsResult.rows.length, + }); } - - logger.info("✅ 카테고리 컬럼 조회 완료", { - columnCount: columnsResult.rows.length + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length, }); res.json({ @@ -1837,9 +1890,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1909,23 +1962,29 @@ export async function multiTableSave( } let mainResult: any; - + if (isUpdate && pkValue) { // UPDATE const updateColumns = Object.keys(mainData) - .filter(col => col !== pkColumn) + .filter((col) => col !== pkColumn) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => mainData[col]); - + .filter((col) => col !== pkColumn) + .map((col) => mainData[col]); + // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateQuery = ` UPDATE "${mainTableName}" @@ -1934,29 +1993,43 @@ export async function multiTableSave( ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} RETURNING * `; - - const updateParams = companyCode !== "*" - ? [...updateValues, pkValue, companyCode] - : [...updateValues, pkValue]; - - logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); + + const updateParams = + companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { + query: updateQuery, + paramsCount: updateParams.length, + }); mainResult = await client.query(updateQuery, updateParams); } else { // INSERT - const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); - const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); + const columns = Object.keys(mainData) + .map((col) => `"${col}"`) + .join(", "); + const placeholders = Object.keys(mainData) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const values = Object.values(mainData); // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateSetClause = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => `"${col}" = EXCLUDED."${col}"`) + .filter((col) => col !== pkColumn) + .map((col) => `"${col}" = EXCLUDED."${col}"`) .join(", "); const insertQuery = ` @@ -1967,7 +2040,10 @@ export async function multiTableSave( RETURNING * `; - logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); + logger.info("메인 테이블 INSERT/UPSERT:", { + query: insertQuery, + paramsCount: values.length, + }); mainResult = await client.query(insertQuery, values); } @@ -1986,12 +2062,15 @@ export async function multiTableSave( const { tableName, linkColumn, items, options } = subTableConfig; // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 - const hasSaveMainAsFirst = options?.saveMainAsFirst && - options?.mainFieldMappings && - options.mainFieldMappings.length > 0; - + const hasSaveMainAsFirst = + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); + logger.info( + `서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})` + ); continue; } @@ -2004,15 +2083,20 @@ export async function multiTableSave( // 기존 데이터 삭제 옵션 if (options?.deleteExistingBefore && linkColumn?.subColumn) { - const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` - : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - - const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? [savedPkValue, options.subMarkerValue ?? false] - : [savedPkValue]; + const deleteQuery = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); + const deleteParams = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; + + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { + deleteQuery, + deleteParams, + }); await client.query(deleteQuery, deleteParams); } @@ -2025,7 +2109,12 @@ export async function multiTableSave( linkColumn, mainDataKeys: Object.keys(mainData), }); - if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { + if ( + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0 && + linkColumn?.subColumn + ) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; @@ -2039,7 +2128,8 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = + options.mainMarkerValue ?? true; } // company_code 추가 @@ -2062,20 +2152,30 @@ export async function multiTableSave( if (companyCode !== "*") { checkParams.push(companyCode); } - + const existingResult = await client.query(checkQuery, checkParams); - + if (existingResult.rows.length > 0) { // UPDATE const updateColumns = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); - + const updateValues = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") - .map(col => mainSubItem[col]); - + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) + .map((col) => mainSubItem[col]); + if (updateColumns) { const updateQuery = ` UPDATE "${tableName}" @@ -2094,14 +2194,26 @@ export async function multiTableSave( } const updateResult = await client.query(updateQuery, updateParams); - subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: updateResult.rows[0], + }); } else { - subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: existingResult.rows[0], + }); } } else { // INSERT - const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); - const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubColumns = Object.keys(mainSubItem) + .map((col) => `"${col}"`) + .join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const mainSubValues = Object.values(mainSubItem); const insertQuery = ` @@ -2111,7 +2223,11 @@ export async function multiTableSave( `; const insertResult = await client.query(insertQuery, mainSubValues); - subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: insertResult.rows[0], + }); } } @@ -2127,8 +2243,12 @@ export async function multiTableSave( item.company_code = companyCode; } - const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); - const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); + const subColumns = Object.keys(item) + .map((col) => `"${col}"`) + .join(", "); + const subPlaceholders = Object.keys(item) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const subValues = Object.values(item); const subInsertQuery = ` @@ -2137,9 +2257,16 @@ export async function multiTableSave( RETURNING * `; - logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { + subInsertQuery, + subValuesCount: subValues.length, + }); const subResult = await client.query(subInsertQuery, subValues); - subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); + subTableResults.push({ + tableName, + type: "sub", + data: subResult.rows[0], + }); } logger.info(`서브 테이블 ${tableName} 저장 완료`); @@ -2179,3 +2306,68 @@ export async function multiTableSave( } } +/** + * 두 테이블 간의 엔티티 관계 자동 감지 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +export async function getTableEntityRelations( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { leftTable, rightTable } = req.query; + + logger.info( + `=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===` + ); + + if (!leftTable || !rightTable) { + const response: ApiResponse = { + success: false, + message: "leftTable과 rightTable 파라미터가 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const relations = await tableManagementService.detectTableEntityRelations( + String(leftTable), + String(rightTable) + ); + + logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + + const response: ApiResponse = { + success: true, + message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, + data: { + leftTable: String(leftTable), + rightTable: String(rightTable), + relations, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", + error: { + code: "ENTITY_RELATIONS_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index a5107448..acb0cbc7 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -55,3 +55,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 22cd2d2b..96ab25be 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -51,3 +51,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 79a1c6e8..f77019be 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -67,3 +67,5 @@ export default router; + + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 352a05b5..6e4094f1 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -55,3 +55,5 @@ export default router; + + diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts index 78cbd3e1..2cb41923 100644 --- a/backend-node/src/routes/codeMergeRoutes.ts +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -3,6 +3,8 @@ import { mergeCodeAllTables, getTablesWithColumn, previewCodeMerge, + mergeCodeByValue, + previewMergeCodeByValue, } from "../controllers/codeMergeController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -13,7 +15,7 @@ router.use(authenticateToken); /** * POST /api/code-merge/merge-all-tables - * 코드 병합 실행 (모든 관련 테이블에 적용) + * 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만) * Body: { columnName, oldValue, newValue } */ router.post("/merge-all-tables", mergeCodeAllTables); @@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn); /** * POST /api/code-merge/preview - * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * 코드 병합 미리보기 (같은 컬럼명 기준) * Body: { columnName, oldValue } */ router.post("/preview", previewCodeMerge); +/** + * POST /api/code-merge/merge-by-value + * 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경) + * Body: { oldValue, newValue } + */ +router.post("/merge-by-value", mergeCodeByValue); + +/** + * POST /api/code-merge/preview-by-value + * 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색) + * Body: { oldValue } + */ +router.post("/preview-by-value", previewMergeCodeByValue); + export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f87aa5d6..574f1cf8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -1,10 +1,262 @@ import express from "express"; import { dataService } from "../services/dataService"; +import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; const router = express.Router(); +// ================================ +// 마스터-디테일 엑셀 API +// ================================ + +/** + * 마스터-디테일 관계 정보 조회 + * GET /api/data/master-detail/relation/:screenId + */ +router.get( + "/master-detail/relation/:screenId", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId } = req.params; + + if (!screenId || isNaN(parseInt(screenId))) { + return res.status(400).json({ + success: false, + message: "유효한 screenId가 필요합니다.", + }); + } + + console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`); + + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.json({ + success: true, + data: null, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + console.log(`✅ 마스터-디테일 관계 발견:`, { + masterTable: relation.masterTable, + detailTable: relation.detailTable, + joinKey: relation.masterKeyColumn, + }); + + return res.json({ + success: true, + data: relation, + }); + } catch (error: any) { + console.error("마스터-디테일 관계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 다운로드 데이터 조회 + * POST /api/data/master-detail/download + */ +router.post( + "/master-detail/download", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, filters } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!screenId) { + return res.status(400).json({ + success: false, + message: "screenId가 필요합니다.", + }); + } + + console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. JOIN 데이터 조회 + const data = await masterDetailExcelService.getJoinedData( + relation, + companyCode, + filters + ); + + console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`); + + return res.json({ + success: true, + data, + }); + } catch (error: any) { + console.error("마스터-디테일 다운로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 다운로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 엑셀 업로드 + * POST /api/data/master-detail/upload + */ +router.post( + "/master-detail/upload", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, data } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + if (!screenId || !data || !Array.isArray(data)) { + return res.status(400).json({ + success: false, + message: "screenId와 data 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`); + + // 1. 마스터-디테일 관계 조회 + const relation = await masterDetailExcelService.getMasterDetailRelation( + parseInt(screenId) + ); + + if (!relation) { + return res.status(400).json({ + success: false, + message: "마스터-디테일 구조가 아닙니다.", + }); + } + + // 2. 데이터 업로드 + const result = await masterDetailExcelService.uploadJoinedData( + relation, + data, + companyCode, + userId + ); + + console.log(`✅ 마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +/** + * 마스터-디테일 간단 모드 엑셀 업로드 + * - 마스터 정보는 UI에서 선택 + * - 디테일 정보만 엑셀에서 업로드 + * - 채번 규칙을 통해 마스터 키 자동 생성 + * + * POST /api/data/master-detail/upload-simple + */ +router.post( + "/master-detail/upload-simple", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + if (!screenId || !detailData || !Array.isArray(detailData)) { + return res.status(400).json({ + success: false, + message: "screenId와 detailData 배열이 필요합니다.", + }); + } + + console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`); + console.log(` 마스터 필드 값:`, masterFieldValues); + console.log(` 채번 규칙 ID:`, numberingRuleId); + console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음"); + + // 업로드 실행 + const result = await masterDetailExcelService.uploadSimple( + parseInt(screenId), + detailData, + masterFieldValues || {}, + numberingRuleId, + companyCode, + userId, + afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성) + afterUploadFlows // 업로드 후 제어 실행 (다중) + ); + + console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + return res.json({ + success: result.success, + data: result, + message: result.success + ? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.` + : "업로드 중 오류가 발생했습니다.", + }); + } catch (error: any) { + console.error("마스터-디테일 간단 모드 업로드 오류:", error); + return res.status(500).json({ + success: false, + message: "마스터-디테일 업로드 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +); + +// ================================ +// 기존 데이터 API +// ================================ + /** * 조인 데이터 조회 API (다른 라우트보다 먼저 정의) * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... @@ -698,6 +950,7 @@ router.post( try { const { tableName } = req.params; const filterConditions = req.body; + const userCompany = req.user?.companyCode; if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { return res.status(400).json({ @@ -706,11 +959,12 @@ router.post( }); } - console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany }); const result = await dataService.deleteGroupRecords( tableName, - filterConditions + filterConditions, + userCompany // 회사 코드 전달 ); if (!result.success) { diff --git a/backend-node/src/routes/excelMappingRoutes.ts b/backend-node/src/routes/excelMappingRoutes.ts new file mode 100644 index 00000000..cbcecc15 --- /dev/null +++ b/backend-node/src/routes/excelMappingRoutes.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + findMappingByColumns, + saveMappingTemplate, + getMappingTemplates, + deleteMappingTemplate, +} from "../controllers/excelMappingController"; + +const router = Router(); + +// 엑셀 컬럼 구조로 매핑 템플릿 조회 +router.post("/find", authenticateToken, findMappingByColumns); + +// 매핑 템플릿 저장 (UPSERT) +router.post("/save", authenticateToken, saveMappingTemplate); + +// 테이블의 매핑 템플릿 목록 조회 +router.get("/list/:tableName", authenticateToken, getMappingTemplates); + +// 매핑 템플릿 삭제 +router.delete("/:id", authenticateToken, deleteMappingTemplate); + +export default router; + diff --git a/backend-node/src/routes/multilangRoutes.ts b/backend-node/src/routes/multilangRoutes.ts index 47137346..00ec04d6 100644 --- a/backend-node/src/routes/multilangRoutes.ts +++ b/backend-node/src/routes/multilangRoutes.ts @@ -21,6 +21,20 @@ import { getUserText, getLangText, getBatchTranslations, + + // 카테고리 관리 API + getCategories, + getCategoryById, + getCategoryPath, + + // 자동 생성 및 오버라이드 API + generateKey, + previewKey, + createOverrideKey, + getOverrideKeys, + + // 화면 라벨 다국어 API + generateScreenLabelKeys, } from "../controllers/multilangController"; const router = express.Router(); @@ -51,4 +65,18 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/ router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 +// 카테고리 관리 API +router.get("/categories", getCategories); // 카테고리 트리 조회 +router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회 +router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회 + +// 자동 생성 및 오버라이드 API +router.post("/keys/generate", generateKey); // 키 자동 생성 +router.post("/keys/preview", previewKey); // 키 미리보기 +router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성 +router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회 + +// 화면 라벨 다국어 자동 생성 API +router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성 + export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d0716d59..fa7832ee 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -38,6 +39,15 @@ router.use(authenticateToken); */ router.get("/tables", getTableList); +/** + * 두 테이블 간 엔티티 관계 조회 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +router.get("/tables/entity-relations", getTableEntityRelations); + /** * 테이블 컬럼 정보 조회 * GET /api/table-management/tables/:tableName/columns diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 1b9280db..95d8befa 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -65,6 +65,13 @@ export class AdminService { } ); + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + + /* [원본 코드 - 권한 그룹 체크] if (userType === "COMPANY_ADMIN") { // 회사 관리자: 권한 그룹 기반 필터링 적용 if (userRoleGroups.length > 0) { @@ -141,6 +148,7 @@ export class AdminService { return []; } } + */ } else if ( menuType !== undefined && userType === "SUPER_ADMIN" && @@ -412,6 +420,15 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + authFilter = ""; + unionFilter = ""; + + /* [원본 코드 - getUserMenuList 권한 그룹 체크] if (userType === "SUPER_ADMIN") { // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); @@ -471,6 +488,7 @@ export class AdminService { return []; } } + */ // 2. 회사별 필터링 조건 생성 let companyFilter = ""; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index a1a494f2..75c57673 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1189,6 +1189,13 @@ class DataService { [tableName] ); + console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, { + pkColumns: pkResult.map((r) => r.attname), + pkCount: pkResult.length, + inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id, + inputIdType: typeof id, + }); + let whereClauses: string[] = []; let params: any[] = []; @@ -1216,17 +1223,31 @@ class DataService { params.push(typeof id === "object" ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); const result = await query(queryText, params); + // 삭제된 행이 없으면 실패 처리 + if (result.length === 0) { + console.warn( + `⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`, + { whereClauses, params } + ); + return { + success: false, + message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + console.log( `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` ); return { success: true, + data: result[0], // 삭제된 레코드 정보 반환 }; } catch (error) { console.error(`레코드 삭제 오류 (${tableName}):`, error); @@ -1240,10 +1261,14 @@ class DataService { /** * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 + * @param userCompany 사용자 회사 코드 (멀티테넌시 필터링) */ async deleteGroupRecords( tableName: string, - filterConditions: Record + filterConditions: Record, + userCompany?: string ): Promise> { try { const validation = await this.validateTableAccess(tableName); @@ -1255,6 +1280,7 @@ class DataService { const whereValues: any[] = []; let paramIndex = 1; + // 사용자 필터 조건 추가 for (const [key, value] of Object.entries(filterConditions)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); @@ -1269,10 +1295,24 @@ class DataService { }; } + // 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외) + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode && userCompany && userCompany !== "*") { + whereConditions.push(`"company_code" = $${paramIndex}`); + whereValues.push(userCompany); + paramIndex++; + console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`); + } + const whereClause = whereConditions.join(" AND "); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; - console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { + tableName, + conditions: filterConditions, + userCompany, + whereClause, + }); const result = await pool.query(deleteQuery, whereValues); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 68c30252..89d96859 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,6 +1,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; +import tableCategoryValueService from "./tableCategoryValueService"; export interface FormDataResult { id: number; @@ -427,6 +428,24 @@ export class DynamicFormService { dataToInsert, }); + // 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원) + console.log("🏷️ 카테고리 라벨→코드 변환 시작..."); + const companyCodeForCategory = company_code || "*"; + const { convertedData: categoryConvertedData, conversions } = + await tableCategoryValueService.convertCategoryLabelsToCodesForData( + tableName, + companyCodeForCategory, + dataToInsert + ); + + if (conversions.length > 0) { + console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions); + // 변환된 데이터로 교체 + Object.assign(dataToInsert, categoryConvertedData); + } else { + console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)"); + } + // 테이블 컬럼 정보 조회하여 타입 변환 적용 console.log("🔍 테이블 컬럼 정보 조회 중..."); const columnInfo = await this.getTableColumnInfo(tableName); @@ -1173,12 +1192,18 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) + * @param id 삭제할 레코드 ID + * @param tableName 테이블명 + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param screenId 화면 ID (제어관리 실행용, 선택사항) */ async deleteFormData( id: string | number, tableName: string, companyCode?: string, - userId?: string + userId?: string, + screenId?: number ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { @@ -1291,14 +1316,19 @@ export class DynamicFormService { const recordCompanyCode = deletedRecord?.company_code || companyCode || "*"; - await this.executeDataflowControlIfConfigured( - 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) - tableName, - deletedRecord, - "delete", - userId || "system", - recordCompanyCode - ); + // screenId가 전달되지 않으면 제어관리를 실행하지 않음 + if (screenId && screenId > 0) { + await this.executeDataflowControlIfConfigured( + screenId, + tableName, + deletedRecord, + "delete", + userId || "system", + recordCompanyCode + ); + } else { + console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")"); + } } } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1643,10 +1673,16 @@ export class DynamicFormService { !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); - // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 + // 버튼 컴포넌트이고 제어관리가 활성화된 경우 + // triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete + const buttonActionType = properties?.componentConfig?.action?.type; + const isMatchingAction = + (triggerType === "delete" && buttonActionType === "delete") || + ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); + if ( properties?.componentType === "button-primary" && - properties?.componentConfig?.action?.type === "save" && + isMatchingAction && properties?.webTypeConfig?.enableDataflowControl === true ) { const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; diff --git a/backend-node/src/services/excelMappingService.ts b/backend-node/src/services/excelMappingService.ts new file mode 100644 index 00000000..a63a027b --- /dev/null +++ b/backend-node/src/services/excelMappingService.ts @@ -0,0 +1,283 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import crypto from "crypto"; + +export interface ExcelMappingTemplate { + id?: number; + tableName: string; + excelColumns: string[]; + excelColumnsHash: string; + columnMappings: Record; // { "엑셀컬럼": "시스템컬럼" } + companyCode: string; + createdDate?: Date; + updatedDate?: Date; +} + +class ExcelMappingService { + /** + * 엑셀 컬럼 목록으로 해시 생성 + * 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별 + */ + generateColumnsHash(columns: string[]): string { + // 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성 + const sortedColumns = [...columns].sort(); + const columnsString = sortedColumns.join("|"); + return crypto.createHash("md5").update(columnsString).digest("hex"); + } + + /** + * 엑셀 컬럼 구조로 매핑 템플릿 조회 + * 동일한 컬럼 구조가 있으면 기존 매핑 반환 + */ + async findMappingByColumns( + tableName: string, + excelColumns: string[], + companyCode: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("엑셀 매핑 템플릿 조회", { + tableName, + excelColumns, + hash, + companyCode, + }); + + const pool = getPool(); + + // 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회 + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + ORDER BY updated_date DESC + LIMIT 1 + `; + params = [tableName, hash]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND excel_columns_hash = $2 + AND (company_code = $3 OR company_code = '*') + ORDER BY + CASE WHEN company_code = $3 THEN 0 ELSE 1 END, + updated_date DESC + LIMIT 1 + `; + params = [tableName, hash, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rows.length > 0) { + logger.info("기존 매핑 템플릿 발견", { + id: result.rows[0].id, + tableName, + }); + return result.rows[0]; + } + + logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash }); + return null; + } catch (error: any) { + logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 매핑 템플릿 저장 (UPSERT) + * 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입 + */ + async saveMappingTemplate( + tableName: string, + excelColumns: string[], + columnMappings: Record, + companyCode: string, + userId?: string + ): Promise { + try { + const hash = this.generateColumnsHash(excelColumns); + + logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", { + tableName, + excelColumns, + hash, + columnMappings, + companyCode, + }); + + const pool = getPool(); + + const query = ` + INSERT INTO excel_mapping_template ( + table_name, + excel_columns, + excel_columns_hash, + column_mappings, + company_code, + created_date, + updated_date + ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (table_name, excel_columns_hash, company_code) + DO UPDATE SET + column_mappings = EXCLUDED.column_mappings, + updated_date = NOW() + RETURNING + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + `; + + const result = await pool.query(query, [ + tableName, + excelColumns, + hash, + JSON.stringify(columnMappings), + companyCode, + ]); + + logger.info("매핑 템플릿 저장 완료", { + id: result.rows[0].id, + tableName, + hash, + }); + + return result.rows[0]; + } catch (error: any) { + logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 테이블의 모든 매핑 템플릿 조회 + */ + async getMappingTemplates( + tableName: string, + companyCode: string + ): Promise { + try { + logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + ORDER BY updated_date DESC + `; + params = [tableName]; + } else { + query = ` + SELECT + id, + table_name as "tableName", + excel_columns as "excelColumns", + excel_columns_hash as "excelColumnsHash", + column_mappings as "columnMappings", + company_code as "companyCode", + created_date as "createdDate", + updated_date as "updatedDate" + FROM excel_mapping_template + WHERE table_name = $1 + AND (company_code = $2 OR company_code = '*') + ORDER BY updated_date DESC + `; + params = [tableName, companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName }); + + return result.rows; + } catch (error: any) { + logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 매핑 템플릿 삭제 + */ + async deleteMappingTemplate( + id: number, + companyCode: string + ): Promise { + try { + logger.info("매핑 템플릿 삭제", { id, companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `DELETE FROM excel_mapping_template WHERE id = $1`; + params = [id]; + } else { + query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount && result.rowCount > 0) { + logger.info("매핑 템플릿 삭제 완료", { id }); + return true; + } + + logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode }); + return false; + } catch (error: any) { + logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error }); + throw error; + } + } +} + +export default new ExcelMappingService(); + diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts new file mode 100644 index 00000000..44cc42b1 --- /dev/null +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -0,0 +1,908 @@ +/** + * 마스터-디테일 엑셀 처리 서비스 + * + * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 + * 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다. + */ + +import { query, queryOne, transaction, getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ================================ +// 인터페이스 정의 +// ================================ + +/** + * 마스터-디테일 관계 정보 + */ +export interface MasterDetailRelation { + masterTable: string; + detailTable: string; + masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no) + detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no) + masterColumns: ColumnInfo[]; + detailColumns: ColumnInfo[]; +} + +/** + * 컬럼 정보 + */ +export interface ColumnInfo { + name: string; + label: string; + inputType: string; + isFromMaster: boolean; +} + +/** + * 분할 패널 설정 + */ +export interface SplitPanelConfig { + leftPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + }; + rightPanel: { + tableName: string; + columns: Array<{ name: string; label: string; width?: number }>; + relation?: { + type: string; + foreignKey?: string; + leftColumn?: string; + // 복합키 지원 (새로운 방식) + keys?: Array<{ + leftColumn: string; + rightColumn: string; + }>; + }; + }; +} + +/** + * 엑셀 다운로드 결과 + */ +export interface ExcelDownloadData { + headers: string[]; // 컬럼 라벨들 + columns: string[]; // 컬럼명들 + data: Record[]; + masterColumns: string[]; // 마스터 컬럼 목록 + detailColumns: string[]; // 디테일 컬럼 목록 + joinKey: string; // 조인 키 +} + +/** + * 엑셀 업로드 결과 + */ +export interface ExcelUploadResult { + success: boolean; + masterInserted: number; + masterUpdated: number; + detailInserted: number; + detailDeleted: number; + errors: string[]; +} + +// ================================ +// 서비스 클래스 +// ================================ + +class MasterDetailExcelService { + + /** + * 화면 ID로 분할 패널 설정 조회 + */ + async getSplitPanelConfig(screenId: number): Promise { + try { + logger.info(`분할 패널 설정 조회: screenId=${screenId}`); + + // screen_layouts에서 split-panel-layout 컴포넌트 찾기 + const result = await queryOne( + `SELECT properties->>'componentConfig' as config + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + AND properties->>'componentType' = 'split-panel-layout' + LIMIT 1`, + [screenId] + ); + + if (!result || !result.config) { + logger.info(`분할 패널 없음: screenId=${screenId}`); + return null; + } + + const config = typeof result.config === "string" + ? JSON.parse(result.config) + : result.config; + + logger.info(`분할 패널 설정 발견:`, { + leftTable: config.leftPanel?.tableName, + rightTable: config.rightPanel?.tableName, + relation: config.rightPanel?.relation, + }); + + return { + leftPanel: config.leftPanel, + rightPanel: config.rightPanel, + }; + } catch (error: any) { + logger.error(`분할 패널 설정 조회 실패: ${error.message}`); + return null; + } + } + + /** + * column_labels에서 Entity 관계 정보 조회 + * 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기 + */ + async getEntityRelation( + detailTable: string, + masterTable: string + ): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> { + try { + logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`); + + const result = await queryOne( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [detailTable, masterTable] + ); + + if (!result) { + logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`); + return null; + } + + logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`); + + return { + detailFkColumn: result.column_name, + masterKeyColumn: result.reference_column, + }; + } catch (error: any) { + logger.error(`Entity 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 테이블의 컬럼 라벨 정보 조회 + */ + async getColumnLabels(tableName: string): Promise> { + try { + const result = await query( + `SELECT column_name, column_label + FROM column_labels + WHERE table_name = $1`, + [tableName] + ); + + const labelMap = new Map(); + for (const row of result) { + labelMap.set(row.column_name, row.column_label || row.column_name); + } + + return labelMap; + } catch (error: any) { + logger.error(`컬럼 라벨 조회 실패: ${error.message}`); + return new Map(); + } + } + + /** + * 마스터-디테일 관계 정보 조합 + */ + async getMasterDetailRelation( + screenId: number + ): Promise { + try { + // 1. 분할 패널 설정 조회 + const splitPanel = await this.getSplitPanelConfig(screenId); + if (!splitPanel) { + return null; + } + + const masterTable = splitPanel.leftPanel.tableName; + const detailTable = splitPanel.rightPanel.tableName; + + if (!masterTable || !detailTable) { + logger.warn("마스터 또는 디테일 테이블명 없음"); + return null; + } + + // 2. 분할 패널의 relation 정보가 있으면 우선 사용 + // 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식) + let masterKeyColumn: string | undefined; + let detailFkColumn: string | undefined; + + const relationKeys = splitPanel.rightPanel.relation?.keys; + if (relationKeys && relationKeys.length > 0) { + // keys 배열에서 첫 번째 키 사용 + masterKeyColumn = relationKeys[0].leftColumn; + detailFkColumn = relationKeys[0].rightColumn; + logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`); + } else { + // 하위 호환성: 기존 leftColumn/foreignKey 사용 + masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; + detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; + } + + // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회 + if (!masterKeyColumn || !detailFkColumn) { + const entityRelation = await this.getEntityRelation(detailTable, masterTable); + if (entityRelation) { + masterKeyColumn = entityRelation.masterKeyColumn; + detailFkColumn = entityRelation.detailFkColumn; + } + } + + if (!masterKeyColumn || !detailFkColumn) { + logger.warn("조인 키 정보를 찾을 수 없음"); + return null; + } + + // 4. 컬럼 라벨 정보 조회 + const masterLabels = await this.getColumnLabels(masterTable); + const detailLabels = await this.getColumnLabels(detailTable); + + // 5. 마스터 컬럼 정보 구성 + const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({ + name: col.name, + label: masterLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: true, + })); + + // 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외) + const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns + .filter(col => col.name !== detailFkColumn) // FK 컬럼 제외 + .map(col => ({ + name: col.name, + label: detailLabels.get(col.name) || col.label || col.name, + inputType: "text", + isFromMaster: false, + })); + + logger.info(`마스터-디테일 관계 구성 완료:`, { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumnCount: masterColumns.length, + detailColumnCount: detailColumns.length, + }); + + return { + masterTable, + detailTable, + masterKeyColumn, + detailFkColumn, + masterColumns, + detailColumns, + }; + } catch (error: any) { + logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`); + return null; + } + } + + /** + * 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용) + */ + async getJoinedData( + relation: MasterDetailRelation, + companyCode: string, + filters?: Record + ): Promise { + try { + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 조인 컬럼과 일반 컬럼 분리 + // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) + const entityJoins: Array<{ + refTable: string; + refColumn: string; + sourceColumn: string; + alias: string; + displayColumn: string; + }> = []; + + // SELECT 절 구성 + const selectParts: string[] = []; + let aliasIndex = 0; + + // 마스터 컬럼 처리 + for (const col of masterColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + // FK를 못 찾으면 NULL로 처리 + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`m."${col.name}"`); + } + } + + // 디테일 컬럼 처리 + for (const col of detailColumns) { + if (col.name.includes(".")) { + // 조인 컬럼: 테이블명.컬럼명 + const [refTable, displayColumn] = col.name.split("."); + const alias = `ej${aliasIndex++}`; + + // column_labels에서 FK 컬럼 찾기 + const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); + if (fkColumn) { + entityJoins.push({ + refTable, + refColumn: fkColumn.referenceColumn, + sourceColumn: fkColumn.sourceColumn, + alias, + displayColumn, + }); + selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); + } else { + selectParts.push(`NULL AS "${col.name}"`); + } + } else { + // 일반 컬럼 + selectParts.push(`d."${col.name}"`); + } + } + + const selectClause = selectParts.join(", "); + + // 엔티티 조인 절 구성 + const entityJoinClauses = entityJoins.map(ej => + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + ).join("\n "); + + // WHERE 절 구성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (최고 관리자 제외) + if (companyCode && companyCode !== "*") { + whereConditions.push(`m.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 추가 필터 적용 + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null && value !== "") { + // 조인 컬럼인지 확인 + if (key.includes(".")) continue; + // 마스터 테이블 컬럼인지 확인 + const isMasterCol = masterColumns.some(c => c.name === key); + const tableAlias = isMasterCol ? "m" : "d"; + whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`); + params.push(value); + paramIndex++; + } + } + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // JOIN 쿼리 실행 + const sql = ` + SELECT ${selectClause} + FROM "${masterTable}" m + LEFT JOIN "${detailTable}" d + ON m."${masterKeyColumn}" = d."${detailFkColumn}" + AND m.company_code = d.company_code + ${entityJoinClauses} + ${whereClause} + ORDER BY m."${masterKeyColumn}", d.id + `; + + logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); + + const data = await query(sql, params); + + // 헤더 및 컬럼 정보 구성 + const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)]; + const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)]; + + logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`); + + return { + headers, + columns, + data, + masterColumns: masterColumns.map(c => c.name), + detailColumns: detailColumns.map(c => c.name), + joinKey: masterKeyColumn, + }; + } catch (error: any) { + logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`); + throw error; + } + } + + /** + * 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기 + */ + private async findForeignKeyColumn( + sourceTable: string, + referenceTable: string + ): Promise<{ sourceColumn: string; referenceColumn: string } | null> { + try { + const result = await query<{ column_name: string; reference_column: string }>( + `SELECT column_name, reference_column + FROM column_labels + WHERE table_name = $1 + AND reference_table = $2 + AND input_type = 'entity' + LIMIT 1`, + [sourceTable, referenceTable] + ); + + if (result.length > 0) { + return { + sourceColumn: result[0].column_name, + referenceColumn: result[0].reference_column, + }; + } + return null; + } catch (error) { + logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error); + return null; + } + } + + /** + * 마스터-디테일 데이터 업로드 (엑셀 업로드용) + * + * 처리 로직: + * 1. 엑셀 데이터를 마스터 키로 그룹화 + * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT + * 3. 해당 마스터 키의 기존 디테일 삭제 + * 4. 새 디테일 데이터 INSERT + */ + async uploadJoinedData( + relation: MasterDetailRelation, + data: Record[], + companyCode: string, + userId?: string + ): Promise { + const result: ExcelUploadResult = { + success: false, + masterInserted: 0, + masterUpdated: 0, + detailInserted: 0, + detailDeleted: 0, + errors: [], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; + + // 1. 데이터를 마스터 키로 그룹화 + const groupedData = new Map[]>(); + + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + + // 2. 각 그룹 처리 + for (const [masterKey, rows] of groupedData.entries()) { + try { + // 2a. 마스터 데이터 추출 (첫 번째 행에서) + const masterData: Record = {}; + for (const col of masterColumns) { + if (rows[0][col.name] !== undefined) { + masterData[col.name] = rows[0][col.name]; + } + } + + // 회사 코드, 작성자 추가 + masterData.company_code = companyCode; + if (userId) { + masterData.writer = userId; + } + + // 2b. 마스터 UPSERT + const existingMaster = await client.query( + `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + // UPDATE + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}, updated_date = NOW() + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // INSERT + const insertCols = Object.keys(masterData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.masterInserted++; + } + + // 2c. 기존 디테일 삭제 + const deleteResult = await client.query( + `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + result.detailDeleted += deleteResult.rowCount || 0; + + // 2d. 새 디테일 INSERT + for (const row of rows) { + const detailData: Record = {}; + + // FK 컬럼 추가 + detailData[detailFkColumn] = masterKey; + detailData.company_code = companyCode; + if (userId) { + detailData.writer = userId; + } + + // 디테일 컬럼 데이터 추출 + for (const col of detailColumns) { + if (row[col.name] !== undefined) { + detailData[col.name] = row[col.name]; + } + } + + const insertCols = Object.keys(detailData); + const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); + const insertValues = insertCols.map(k => detailData[k]); + + await client.query( + `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${insertPlaceholders.join(", ")}, NOW())`, + insertValues + ); + result.detailInserted++; + } + } catch (error: any) { + result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); + logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + } + } + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0; + + logger.info(`마스터-디테일 업로드 완료:`, { + masterInserted: result.masterInserted, + masterUpdated: result.masterUpdated, + detailInserted: result.detailInserted, + detailDeleted: result.detailDeleted, + errors: result.errors.length, + }); + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 마스터-디테일 간단 모드 업로드 + * + * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 + * 채번 규칙을 통해 마스터 키 자동 생성 + * + * @param screenId 화면 ID + * @param detailData 디테일 데이터 배열 + * @param masterFieldValues UI에서 선택한 마스터 필드 값 + * @param numberingRuleId 채번 규칙 ID (optional) + * @param companyCode 회사 코드 + * @param userId 사용자 ID + * @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성) + * @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional) + */ + async uploadSimple( + screenId: number, + detailData: Record[], + masterFieldValues: Record, + numberingRuleId: string | undefined, + companyCode: string, + userId: string, + afterUploadFlowId?: string, + afterUploadFlows?: Array<{ flowId: string; order: number }> + ): Promise<{ + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + }> { + const result: { + success: boolean; + masterInserted: number; + detailInserted: number; + generatedKey: string; + errors: string[]; + controlResult?: any; + } = { + success: false, + masterInserted: 0, + detailInserted: 0, + generatedKey: "", + errors: [] as string[], + }; + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. 마스터-디테일 관계 정보 조회 + const relation = await this.getMasterDetailRelation(screenId); + if (!relation) { + throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다."); + } + + const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation; + + // 2. 채번 처리 + let generatedKey: string; + + if (numberingRuleId) { + // 채번 규칙으로 키 생성 + generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode); + } else { + // 채번 규칙 없으면 마스터 필드에서 키 값 사용 + generatedKey = masterFieldValues[masterKeyColumn]; + if (!generatedKey) { + throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`); + } + } + + result.generatedKey = generatedKey; + logger.info(`채번 결과: ${generatedKey}`); + + // 3. 마스터 레코드 생성 + const masterData: Record = { + ...masterFieldValues, + [masterKeyColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 마스터 컬럼명 목록 구성 + const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined); + const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`); + const masterValues = masterCols.map(k => masterData[k]); + + await client.query( + `INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${masterPlaceholders.join(", ")}, NOW())`, + masterValues + ); + result.masterInserted = 1; + logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`); + + // 4. 디테일 레코드들 생성 (삽입된 데이터 수집) + const insertedDetailRows: Record[] = []; + + for (const row of detailData) { + try { + const detailRowData: Record = { + ...row, + [detailFkColumn]: generatedKey, + company_code: companyCode, + writer: userId, + }; + + // 빈 값 필터링 및 id 제외 + const detailCols = Object.keys(detailRowData).filter(k => + k !== "id" && + detailRowData[k] !== undefined && + detailRowData[k] !== null && + detailRowData[k] !== "" + ); + const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`); + const detailValues = detailCols.map(k => detailRowData[k]); + + // RETURNING *로 삽입된 데이터 반환받기 + const insertResult = await client.query( + `INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date) + VALUES (${detailPlaceholders.join(", ")}, NOW()) + RETURNING *`, + detailValues + ); + + if (insertResult.rows && insertResult.rows[0]) { + insertedDetailRows.push(insertResult.rows[0]); + } + + result.detailInserted++; + } catch (error: any) { + result.errors.push(`디테일 행 처리 실패: ${error.message}`); + logger.error(`디테일 행 처리 실패:`, error); + } + } + + logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`); + + await client.query("COMMIT"); + result.success = result.errors.length === 0 || result.detailInserted > 0; + + logger.info(`마스터-디테일 간단 모드 업로드 완료:`, { + masterInserted: result.masterInserted, + detailInserted: result.detailInserted, + generatedKey: result.generatedKey, + errors: result.errors.length, + }); + + // 업로드 후 제어 실행 (단일 또는 다중) + const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0 + ? afterUploadFlows // 다중 제어 + : afterUploadFlowId + ? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성) + : []; + + if (flowsToExecute.length > 0 && result.success) { + try { + const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); + + // 마스터 데이터 구성 + const masterData = { + ...masterFieldValues, + [relation!.masterKeyColumn]: result.generatedKey, + company_code: companyCode, + }; + + const controlResults: any[] = []; + + // 순서대로 제어 실행 + for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) { + logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`); + logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`); + + // 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화) + // - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리 + // - tableSource 노드가 context-data 모드일 때 이 데이터를 사용 + const controlResult = await NodeFlowExecutionService.executeFlow( + parseInt(flow.flowId), + { + sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData], + dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시 + buttonId: "excel-upload-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: masterData, + // 추가 컨텍스트: 마스터/디테일 정보 + masterData: masterData, + detailData: insertedDetailRows, + masterTable: relation!.masterTable, + detailTable: relation!.detailTable, + masterKeyColumn: relation!.masterKeyColumn, + detailFkColumn: relation!.detailFkColumn, + } + ); + + controlResults.push({ + flowId: flow.flowId, + order: flow.order, + success: controlResult.success, + message: controlResult.message, + executedNodes: controlResult.nodes?.length || 0, + }); + } + + result.controlResult = { + success: controlResults.every(r => r.success), + executedFlows: controlResults.length, + results: controlResults, + }; + + logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult); + } catch (controlError: any) { + logger.error(`업로드 후 제어 실행 실패:`, controlError); + result.controlResult = { + success: false, + message: `제어 실행 실패: ${controlError.message}`, + }; + } + } + + } catch (error: any) { + await client.query("ROLLBACK"); + result.errors.push(`트랜잭션 실패: ${error.message}`); + logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error); + } finally { + client.release(); + } + + return result; + } + + /** + * 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용) + */ + private async generateNumberWithRule( + client: any, + ruleId: string, + companyCode: string + ): Promise { + try { + // 기존 numberingRuleService를 사용하여 코드 할당 + const { numberingRuleService } = await import("./numberingRuleService"); + const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + + logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`); + + return generatedCode; + } catch (error: any) { + logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`); + throw error; + } + } +} + +export const masterDetailExcelService = new MasterDetailExcelService(); + diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index 090065a3..fc765d89 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -4,6 +4,7 @@ import { Language, LangKey, LangText, + LangCategory, CreateLanguageRequest, UpdateLanguageRequest, CreateLangKeyRequest, @@ -12,12 +13,428 @@ import { GetLangKeysParams, GetUserTextParams, BatchTranslationRequest, + GenerateKeyRequest, + CreateOverrideKeyRequest, ApiResponse, } from "../types/multilang"; export class MultiLangService { constructor() {} + // ===================================================== + // 카테고리 관련 메서드 + // ===================================================== + + /** + * 카테고리 목록 조회 (트리 구조) + */ + async getCategories(): Promise { + try { + logger.info("카테고리 목록 조회 시작"); + + const categories = await query<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE is_active = 'Y' + ORDER BY level ASC, sort_order ASC, category_name ASC` + ); + + // 트리 구조로 변환 + const categoryMap = new Map(); + const rootCategories: LangCategory[] = []; + + // 모든 카테고리를 맵에 저장 + categories.forEach((cat) => { + const category: LangCategory = { + categoryId: cat.category_id, + categoryCode: cat.category_code, + categoryName: cat.category_name, + parentId: cat.parent_id, + level: cat.level, + keyPrefix: cat.key_prefix, + description: cat.description || undefined, + sortOrder: cat.sort_order, + isActive: cat.is_active, + children: [], + }; + categoryMap.set(cat.category_id, category); + }); + + // 부모-자식 관계 설정 + categoryMap.forEach((category) => { + if (category.parentId && categoryMap.has(category.parentId)) { + const parent = categoryMap.get(category.parentId)!; + parent.children = parent.children || []; + parent.children.push(category); + } else if (!category.parentId) { + rootCategories.push(category); + } + }); + + logger.info(`카테고리 목록 조회 완료: ${categories.length}개`); + return rootCategories; + } catch (error) { + logger.error("카테고리 목록 조회 중 오류 발생:", error); + throw new Error( + `카테고리 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 카테고리 단일 조회 + */ + async getCategoryById(categoryId: number): Promise { + try { + const category = await queryOne<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE category_id = $1`, + [categoryId] + ); + + if (!category) { + return null; + } + + return { + categoryId: category.category_id, + categoryCode: category.category_code, + categoryName: category.category_name, + parentId: category.parent_id, + level: category.level, + keyPrefix: category.key_prefix, + description: category.description || undefined, + sortOrder: category.sort_order, + isActive: category.is_active, + }; + } catch (error) { + logger.error("카테고리 조회 중 오류 발생:", error); + throw new Error( + `카테고리 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 카테고리 경로 조회 (부모 포함) + */ + async getCategoryPath(categoryId: number): Promise { + try { + const categories = await query<{ + category_id: number; + category_code: string; + category_name: string; + parent_id: number | null; + level: number; + key_prefix: string; + description: string | null; + sort_order: number; + is_active: string; + }>( + `WITH RECURSIVE category_path AS ( + SELECT category_id, category_code, category_name, parent_id, + level, key_prefix, description, sort_order, is_active + FROM multi_lang_category + WHERE category_id = $1 + UNION ALL + SELECT c.category_id, c.category_code, c.category_name, c.parent_id, + c.level, c.key_prefix, c.description, c.sort_order, c.is_active + FROM multi_lang_category c + INNER JOIN category_path cp ON c.category_id = cp.parent_id + ) + SELECT * FROM category_path ORDER BY level ASC`, + [categoryId] + ); + + return categories.map((cat) => ({ + categoryId: cat.category_id, + categoryCode: cat.category_code, + categoryName: cat.category_name, + parentId: cat.parent_id, + level: cat.level, + keyPrefix: cat.key_prefix, + description: cat.description || undefined, + sortOrder: cat.sort_order, + isActive: cat.is_active, + })); + } catch (error) { + logger.error("카테고리 경로 조회 중 오류 발생:", error); + throw new Error( + `카테고리 경로 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 키 자동 생성 + */ + async generateKey(params: GenerateKeyRequest): Promise { + try { + logger.info("키 자동 생성 시작", { params }); + + // 카테고리 경로 조회 + const categoryPath = await this.getCategoryPath(params.categoryId); + if (categoryPath.length === 0) { + throw new Error("존재하지 않는 카테고리입니다"); + } + + // lang_key 자동 생성 (prefix.meaning 형식) + const prefixes = categoryPath.map((c) => c.keyPrefix); + const langKey = [...prefixes, params.keyMeaning].join("."); + + // 중복 체크 + const existingKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [params.companyCode, langKey] + ); + + if (existingKey) { + throw new Error(`이미 존재하는 키입니다: ${langKey}`); + } + + // 트랜잭션으로 키와 텍스트 생성 + let keyId: number = 0; + + await transaction(async (client) => { + // 키 생성 + const keyResult = await client.query( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, category_id, key_meaning, usage_note, description, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $7) + RETURNING key_id`, + [ + params.companyCode, + langKey, + params.categoryId, + params.keyMeaning, + params.usageNote || null, + params.usageNote || null, + params.createdBy || "system", + ] + ); + + keyId = keyResult.rows[0].key_id; + + // 텍스트 생성 + for (const text of params.texts) { + await client.query( + `INSERT INTO multi_lang_text + (key_id, lang_code, lang_text, is_active, created_by, updated_by) + VALUES ($1, $2, $3, 'Y', $4, $4)`, + [keyId, text.langCode, text.langText, params.createdBy || "system"] + ); + } + }); + + logger.info("키 자동 생성 완료", { keyId, langKey }); + return keyId; + } catch (error) { + logger.error("키 자동 생성 중 오류 발생:", error); + throw new Error( + `키 자동 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 회사별 오버라이드 키 생성 + */ + async createOverrideKey(params: CreateOverrideKeyRequest): Promise { + try { + logger.info("오버라이드 키 생성 시작", { params }); + + // 원본 키 조회 + const baseKey = await queryOne<{ + key_id: number; + company_code: string; + lang_key: string; + category_id: number | null; + key_meaning: string | null; + }>( + `SELECT key_id, company_code, lang_key, category_id, key_meaning + FROM multi_lang_key_master WHERE key_id = $1`, + [params.baseKeyId] + ); + + if (!baseKey) { + throw new Error("원본 키를 찾을 수 없습니다"); + } + + // 공통 키(*)만 오버라이드 가능 + if (baseKey.company_code !== "*") { + throw new Error("공통 키(*)만 오버라이드 할 수 있습니다"); + } + + // 이미 오버라이드 키가 있는지 확인 + const existingOverride = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [params.companyCode, baseKey.lang_key] + ); + + if (existingOverride) { + throw new Error("이미 해당 회사의 오버라이드 키가 존재합니다"); + } + + let keyId: number = 0; + + await transaction(async (client) => { + // 오버라이드 키 생성 + const keyResult = await client.query( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, category_id, key_meaning, base_key_id, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, 'Y', $6, $6) + RETURNING key_id`, + [ + params.companyCode, + baseKey.lang_key, + baseKey.category_id, + baseKey.key_meaning, + params.baseKeyId, + params.createdBy || "system", + ] + ); + + keyId = keyResult.rows[0].key_id; + + // 텍스트 생성 + for (const text of params.texts) { + await client.query( + `INSERT INTO multi_lang_text + (key_id, lang_code, lang_text, is_active, created_by, updated_by) + VALUES ($1, $2, $3, 'Y', $4, $4)`, + [keyId, text.langCode, text.langText, params.createdBy || "system"] + ); + } + }); + + logger.info("오버라이드 키 생성 완료", { keyId, langKey: baseKey.lang_key }); + return keyId; + } catch (error) { + logger.error("오버라이드 키 생성 중 오류 발생:", error); + throw new Error( + `오버라이드 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 회사의 오버라이드 키 목록 조회 + */ + async getOverrideKeys(companyCode: string): Promise { + try { + logger.info("오버라이드 키 목록 조회 시작", { companyCode }); + + const keys = await query<{ + key_id: number; + company_code: string; + lang_key: string; + category_id: number | null; + key_meaning: string | null; + usage_note: string | null; + base_key_id: number | null; + is_active: string; + created_date: Date | null; + }>( + `SELECT key_id, company_code, lang_key, category_id, key_meaning, + usage_note, base_key_id, is_active, created_date + FROM multi_lang_key_master + WHERE company_code = $1 AND base_key_id IS NOT NULL + ORDER BY lang_key ASC`, + [companyCode] + ); + + return keys.map((k) => ({ + keyId: k.key_id, + companyCode: k.company_code, + langKey: k.lang_key, + categoryId: k.category_id ?? undefined, + keyMeaning: k.key_meaning ?? undefined, + usageNote: k.usage_note ?? undefined, + baseKeyId: k.base_key_id ?? undefined, + isActive: k.is_active, + createdDate: k.created_date ?? undefined, + })); + } catch (error) { + logger.error("오버라이드 키 목록 조회 중 오류 발생:", error); + throw new Error( + `오버라이드 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 키 존재 여부 및 미리보기 확인 + */ + async previewGeneratedKey(categoryId: number, keyMeaning: string, companyCode: string): Promise<{ + langKey: string; + exists: boolean; + isOverride: boolean; + baseKeyId?: number; + }> { + try { + // 카테고리 경로 조회 + const categoryPath = await this.getCategoryPath(categoryId); + if (categoryPath.length === 0) { + throw new Error("존재하지 않는 카테고리입니다"); + } + + // lang_key 생성 + const prefixes = categoryPath.map((c) => c.keyPrefix); + const langKey = [...prefixes, keyMeaning].join("."); + + // 공통 키 확인 + const commonKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = '*' AND lang_key = $1`, + [langKey] + ); + + // 회사별 키 확인 + const companyKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE company_code = $1 AND lang_key = $2`, + [companyCode, langKey] + ); + + return { + langKey, + exists: !!companyKey, + isOverride: !!commonKey && !companyKey, + baseKeyId: commonKey?.key_id, + }; + } catch (error) { + logger.error("키 미리보기 중 오류 발생:", error); + throw new Error( + `키 미리보기 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + /** * 언어 목록 조회 */ @@ -275,14 +692,29 @@ export class MultiLangService { // 메뉴 코드 필터 if (params.menuCode) { - whereConditions.push(`menu_name = $${paramIndex++}`); + whereConditions.push(`usage_note = $${paramIndex++}`); values.push(params.menuCode); } + // 카테고리 필터 (하위 카테고리 포함) + if (params.categoryId) { + whereConditions.push(`category_id IN ( + WITH RECURSIVE category_tree AS ( + SELECT category_id FROM multi_lang_category WHERE category_id = $${paramIndex} + UNION ALL + SELECT c.category_id FROM multi_lang_category c + INNER JOIN category_tree ct ON c.parent_id = ct.category_id + ) + SELECT category_id FROM category_tree + )`); + values.push(params.categoryId); + paramIndex++; + } + // 검색 조건 (OR) if (params.searchText) { whereConditions.push( - `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR menu_name ILIKE $${paramIndex})` + `(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR usage_note ILIKE $${paramIndex})` ); values.push(`%${params.searchText}%`); paramIndex++; @@ -296,30 +728,32 @@ export class MultiLangService { const langKeys = await query<{ key_id: number; company_code: string; - menu_name: string | null; + usage_note: string | null; lang_key: string; description: string | null; is_active: string | null; + category_id: number | null; created_date: Date | null; created_by: string | null; updated_date: Date | null; updated_by: string | null; }>( - `SELECT key_id, company_code, menu_name, lang_key, description, is_active, + `SELECT key_id, company_code, usage_note, lang_key, description, is_active, category_id, created_date, created_by, updated_date, updated_by FROM multi_lang_key_master ${whereClause} - ORDER BY company_code ASC, menu_name ASC, lang_key ASC`, + ORDER BY company_code ASC, usage_note ASC, lang_key ASC`, values ); const mappedKeys: LangKey[] = langKeys.map((key) => ({ keyId: key.key_id, companyCode: key.company_code, - menuName: key.menu_name || undefined, + menuName: key.usage_note || undefined, langKey: key.lang_key, description: key.description || undefined, isActive: key.is_active || "Y", + categoryId: key.category_id || undefined, createdDate: key.created_date || undefined, createdBy: key.created_by || undefined, updatedDate: key.updated_date || undefined, @@ -407,7 +841,7 @@ export class MultiLangService { // 다국어 키 생성 const createdKey = await queryOne<{ key_id: number }>( `INSERT INTO multi_lang_key_master - (company_code, menu_name, lang_key, description, is_active, created_by, updated_by) + (company_code, usage_note, lang_key, description, is_active, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING key_id`, [ @@ -480,7 +914,7 @@ export class MultiLangService { values.push(keyData.companyCode); } if (keyData.menuName !== undefined) { - updates.push(`menu_name = $${paramIndex++}`); + updates.push(`usage_note = $${paramIndex++}`); values.push(keyData.menuName); } if (keyData.langKey) { @@ -668,7 +1102,7 @@ export class MultiLangService { WHERE mlt.lang_code = $1 AND mlt.is_active = $2 AND mlkm.company_code = $3 - AND mlkm.menu_name = $4 + AND mlkm.usage_note = $4 AND mlkm.lang_key = $5 AND mlkm.is_active = $6`, [ @@ -753,7 +1187,9 @@ export class MultiLangService { } /** - * 배치 번역 조회 + * 배치 번역 조회 (회사별 우선순위 적용) + * 우선순위: 회사별 키 > 공통 키(*) + * 폴백: 요청 언어 번역이 없으면 KR 번역 사용 */ async getBatchTranslations( params: BatchTranslationRequest @@ -775,12 +1211,17 @@ export class MultiLangService { .map((_, i) => `$${i + 4}`) .join(", "); + // 회사별 우선순위를 적용하기 위해 정렬 수정 + // 회사별 키가 먼저 오도록 DESC 정렬 (company_code가 '*'보다 특정 회사 코드가 알파벳 순으로 앞) + // 또는 CASE WHEN을 사용하여 명시적으로 우선순위 설정 const translations = await query<{ lang_text: string; lang_key: string; company_code: string; + priority: number; }>( - `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code + `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, + CASE WHEN mlkm.company_code = $3 THEN 1 ELSE 2 END as priority FROM multi_lang_text mlt INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id WHERE mlt.lang_code = $1 @@ -788,28 +1229,76 @@ export class MultiLangService { AND mlkm.lang_key IN (${placeholders}) AND mlkm.company_code IN ($3, '*') AND mlkm.is_active = $2 - ORDER BY mlkm.company_code ASC`, + ORDER BY mlkm.lang_key ASC, priority ASC`, [params.userLang, "Y", params.companyCode, ...params.langKeys] ); const result: Record = {}; + const processedKeys = new Set(); - // 기본값으로 모든 키 설정 - params.langKeys.forEach((key) => { - result[key] = key; - }); - - // 실제 번역으로 덮어쓰기 (회사별 우선) + // 우선순위 기반으로 번역 적용 + // priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음 translations.forEach((translation) => { const langKey = translation.lang_key; - if (params.langKeys.includes(langKey)) { + if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) { result[langKey] = translation.lang_text; + processedKeys.add(langKey); + } + }); + + // 번역이 없는 키들에 대해 KR 폴백 조회 (요청 언어가 KR이 아닌 경우) + const missingKeys = params.langKeys.filter((key) => !processedKeys.has(key)); + + if (missingKeys.length > 0 && params.userLang !== "KR") { + logger.info("KR 폴백 번역 조회 시작", { missingCount: missingKeys.length }); + + const fallbackPlaceholders = missingKeys.map((_, i) => `$${i + 3}`).join(", "); + const fallbackTranslations = await query<{ + lang_text: string; + lang_key: string; + company_code: string; + priority: number; + }>( + `SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code, + CASE WHEN mlkm.company_code = $2 THEN 1 ELSE 2 END as priority + FROM multi_lang_text mlt + INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id + WHERE mlt.lang_code = 'KR' + AND mlt.is_active = $1 + AND mlkm.lang_key IN (${fallbackPlaceholders}) + AND mlkm.company_code IN ($2, '*') + AND mlkm.is_active = $1 + ORDER BY mlkm.lang_key ASC, priority ASC`, + ["Y", params.companyCode, ...missingKeys] + ); + + // KR 폴백 적용 + const fallbackProcessed = new Set(); + fallbackTranslations.forEach((translation) => { + const langKey = translation.lang_key; + if (!result[langKey] && !fallbackProcessed.has(langKey)) { + result[langKey] = translation.lang_text; + fallbackProcessed.add(langKey); + } + }); + + logger.info("KR 폴백 번역 조회 완료", { + missingCount: missingKeys.length, + foundFallback: fallbackTranslations.length, + }); + } + + // 여전히 없는 키는 키 자체를 반환 (최후의 폴백) + params.langKeys.forEach((key) => { + if (!result[key]) { + result[key] = key; } }); logger.info("배치 번역 조회 완료", { totalKeys: params.langKeys.length, foundTranslations: translations.length, + companyOverrides: translations.filter(t => t.company_code !== '*').length, resultKeys: Object.keys(result).length, }); @@ -865,4 +1354,391 @@ export class MultiLangService { ); } } + + // ===================================================== + // 회사/메뉴 기반 카테고리 자동 생성 메서드 + // ===================================================== + + /** + * 화면(screen) 루트 카테고리 확인 또는 생성 + */ + async ensureScreenRootCategory(): Promise { + try { + // 기존 screen 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = 'screen' AND parent_id IS NULL`, + [] + ); + + if (existing) { + return existing.category_id; + } + + // 없으면 생성 + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ('screen', '화면', NULL, 1, 'screen', '화면 디자이너에서 자동 생성된 다국어 키', 100, 'Y', NOW()) + RETURNING category_id`, + [] + ); + + logger.info("화면 루트 카테고리 생성", { categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("화면 루트 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 회사 카테고리 확인 또는 생성 + */ + async ensureCompanyCategory(companyCode: string, companyName: string): Promise { + try { + const screenRootId = await this.ensureScreenRootCategory(); + + // 기존 회사 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_code = $1 AND parent_id = $2`, + [companyCode, screenRootId] + ); + + if (existing) { + return existing.category_id; + } + + // 회사 카테고리 생성 + const displayName = companyCode === "*" ? "공통" : companyName; + const keyPrefix = companyCode === "*" ? "common" : companyCode.toLowerCase(); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, 2, $4, $5, $6, 'Y', NOW()) + RETURNING category_id`, + [ + companyCode, + displayName, + screenRootId, + keyPrefix, + `${displayName} 회사의 화면 다국어`, + companyCode === "*" ? 0 : 10, + ] + ); + + logger.info("회사 카테고리 생성", { companyCode, categoryId: result?.category_id }); + return result!.category_id; + } catch (error) { + logger.error("회사 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 카테고리 확인 또는 생성 (메뉴 경로 전체) + */ + async ensureMenuCategory( + companyCode: string, + companyName: string, + menuPath: string[] // ["영업관리", "수주관리"] + ): Promise { + try { + if (menuPath.length === 0) { + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; + + for (const menuName of menuPath) { + // 현재 메뉴 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [menuName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 메뉴 카테고리 생성 + const menuCode = `${companyCode}_${menuName}`.replace(/\s+/g, "_"); + const keyPrefix = menuName.toLowerCase().replace(/\s+/g, "_"); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) + RETURNING category_id`, + [menuCode, menuName, parentId, currentLevel, keyPrefix, `${menuName} 메뉴의 다국어`] + ); + + logger.info("메뉴 카테고리 생성", { menuName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("메뉴 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 메뉴 경로 조회 (menu_info에서 부모 메뉴까지) + */ + async getMenuPath(menuObjId: string): Promise { + try { + const menus = await query<{ menu_name_kor: string; level: number }>( + `WITH RECURSIVE menu_path AS ( + SELECT objid, parent_obj_id, menu_name_kor, 1 as level + FROM menu_info + WHERE objid = $1 + UNION ALL + SELECT m.objid, m.parent_obj_id, m.menu_name_kor, mp.level + 1 + FROM menu_info m + INNER JOIN menu_path mp ON m.objid = mp.parent_obj_id + WHERE m.parent_obj_id IS NOT NULL AND m.parent_obj_id != 0 + ) + SELECT menu_name_kor, level FROM menu_path + WHERE menu_name_kor IS NOT NULL + ORDER BY level DESC`, + [menuObjId] + ); + + return menus.map((m) => m.menu_name_kor); + } catch (error) { + logger.error("메뉴 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 그룹 경로 조회 (screen_groups에서 계층 구조 조회) + * @param screenId 화면 ID + * @returns 그룹 경로 배열 (최상위 → 현재 그룹 순서) + */ + async getScreenGroupPath(screenId: number): Promise { + try { + // 화면이 속한 그룹 조회 + const screenGroup = await queryOne<{ group_id: number }>( + `SELECT group_id FROM screen_group_screens WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (!screenGroup) { + logger.info("화면이 그룹에 속하지 않음", { screenId }); + return []; + } + + // 그룹의 계층 구조 경로 조회 (최상위 → 현재 그룹 순서) + const groups = await query<{ group_name: string; group_level: number }>( + `WITH RECURSIVE group_path AS ( + SELECT id, parent_group_id, group_name, group_level, 1 as depth + FROM screen_groups + WHERE id = $1 + UNION ALL + SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 + FROM screen_groups g + INNER JOIN group_path gp ON g.id = gp.parent_group_id + WHERE g.parent_group_id IS NOT NULL + ) + SELECT group_name, group_level FROM group_path + ORDER BY depth DESC`, + [screenGroup.group_id] + ); + + return groups.map((g) => g.group_name); + } catch (error) { + logger.error("화면 그룹 경로 조회 실패:", error); + return []; + } + } + + /** + * 화면 그룹 기반 카테고리 확인 또는 생성 + * @param companyCode 회사 코드 + * @param companyName 회사 이름 + * @param groupPath 그룹 경로 (상위 → 하위 순서) + * @returns 최종 카테고리 ID + */ + async ensureScreenGroupCategory( + companyCode: string, + companyName: string, + groupPath: string[] + ): Promise { + try { + if (groupPath.length === 0) { + // 그룹이 없으면 회사 카테고리만 반환 + return await this.ensureCompanyCategory(companyCode, companyName); + } + + let parentId = await this.ensureCompanyCategory(companyCode, companyName); + let currentLevel = 3; // SCREEN(1) > Company(2) > Group(3) + + for (const groupName of groupPath) { + // 현재 그룹 카테고리 확인 + const existing = await queryOne<{ category_id: number }>( + `SELECT category_id FROM multi_lang_category + WHERE category_name = $1 AND parent_id = $2`, + [groupName, parentId] + ); + + if (existing) { + parentId = existing.category_id; + } else { + // 그룹 카테고리 생성 + const groupCode = `${companyCode}_GROUP_${groupName}`.replace(/\s+/g, "_"); + const keyPrefix = groupName.toLowerCase().replace(/\s+/g, "_"); + + const result = await queryOne<{ category_id: number }>( + `INSERT INTO multi_lang_category + (category_code, category_name, parent_id, level, key_prefix, description, sort_order, is_active, created_date) + VALUES ($1, $2, $3, $4, $5, $6, 0, 'Y', NOW()) + RETURNING category_id`, + [groupCode, groupName, parentId, currentLevel, keyPrefix, `${groupName} 화면 그룹의 다국어`] + ); + + logger.info("화면 그룹 카테고리 생성", { groupName, categoryId: result?.category_id }); + parentId = result!.category_id; + } + + currentLevel++; + } + + return parentId; + } catch (error) { + logger.error("화면 그룹 카테고리 생성 실패:", error); + throw error; + } + } + + /** + * 화면 라벨 다국어 키 자동 생성 + * 화면 그룹 기반으로 카테고리 생성 (기존 메뉴 기반에서 변경) + */ + async generateScreenLabelKeys(params: { + screenId: number; + companyCode: string; + companyName: string; + menuObjId?: string; // 하위 호환성 유지 (미사용, 화면 그룹 기반으로 변경) + labels: Array<{ componentId: string; label: string; type?: string }>; + }): Promise> { + try { + logger.info("화면 라벨 다국어 키 자동 생성 시작", { + screenId: params.screenId, + companyCode: params.companyCode, + labelCount: params.labels.length, + }); + + // 화면 그룹 경로 조회 (화면이 속한 그룹의 계층 구조) + const groupPath = await this.getScreenGroupPath(params.screenId); + logger.info("화면 그룹 경로 조회 완료", { screenId: params.screenId, groupPath }); + + // 화면 그룹 기반 카테고리 확보 + const categoryId = await this.ensureScreenGroupCategory( + params.companyCode, + params.companyName, + groupPath + ); + + // 카테고리 경로 조회 (키 생성용) + const categoryPath = await this.getCategoryPath(categoryId); + const keyPrefixParts = categoryPath.map((c) => c.keyPrefix); + + const results: Array<{ componentId: string; keyId: number; langKey: string }> = []; + + for (const labelInfo of params.labels) { + // 라벨을 키 형태로 변환 (한글 → 스네이크케이스) + const keyMeaning = this.labelToKeyMeaning(labelInfo.label); + const langKey = [...keyPrefixParts, keyMeaning].join("."); + + // 기존 키 확인 + const existingKey = await queryOne<{ key_id: number }>( + `SELECT key_id FROM multi_lang_key_master + WHERE lang_key = $1 AND company_code = $2`, + [langKey, params.companyCode] + ); + + let keyId: number; + + if (existingKey) { + keyId = existingKey.key_id; + logger.info("기존 키 사용", { langKey, keyId }); + } else { + // 새 키 생성 + const keyResult = await queryOne<{ key_id: number }>( + `INSERT INTO multi_lang_key_master + (company_code, lang_key, description, is_active, category_id, key_meaning, created_date, created_by) + VALUES ($1, $2, $3, 'Y', $4, $5, NOW(), 'system') + RETURNING key_id`, + [ + params.companyCode, + langKey, + `화면 ${params.screenId}의 ${labelInfo.type || "라벨"}: ${labelInfo.label}`, + categoryId, + keyMeaning, + ] + ); + + keyId = keyResult!.key_id; + + // 한국어 텍스트 저장 (원문) + await query( + `INSERT INTO multi_lang_text (key_id, lang_code, lang_text, is_active, created_date, created_by) + VALUES ($1, 'KR', $2, 'Y', NOW(), 'system') + ON CONFLICT (key_id, lang_code) DO UPDATE SET lang_text = $2, updated_date = NOW()`, + [keyId, labelInfo.label] + ); + + logger.info("새 키 생성", { langKey, keyId }); + } + + results.push({ + componentId: labelInfo.componentId, + keyId, + langKey, + }); + } + + logger.info("화면 라벨 다국어 키 생성 완료", { + screenId: params.screenId, + generatedCount: results.length, + }); + + return results; + } catch (error) { + logger.error("화면 라벨 다국어 키 생성 실패:", error); + throw new Error( + `화면 라벨 다국어 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 라벨을 키 의미로 변환 (한글 → 스네이크케이스 또는 영문 유지) + */ + private labelToKeyMeaning(label: string): string { + // 이미 영문 스네이크케이스면 그대로 사용 + if (/^[a-z][a-z0-9_]*$/.test(label)) { + return label; + } + + // 영문 일반이면 스네이크케이스로 변환 + if (/^[A-Za-z][A-Za-z0-9 ]*$/.test(label)) { + return label.toLowerCase().replace(/\s+/g, "_"); + } + + // 한글이면 간단한 변환 (특수문자 제거, 공백을 _로) + return label + .replace(/[^\w가-힣\s]/g, "") + .replace(/\s+/g, "_") + .toLowerCase(); + } } diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 6f481198..b5237f0b 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -969,21 +969,56 @@ export class NodeFlowExecutionService { const insertedData = { ...data }; console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { + + // 🔥 채번 규칙 서비스 동적 import + const { numberingRuleService } = await import("./numberingRuleService"); + + for (const mapping of fieldMappings) { fields.push(mapping.targetField); - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : data[mapping.sourceField]; - - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` - ); + let value: any; + + // 🔥 값 생성 유형에 따른 처리 + const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source"); + + if (valueType === "autoGenerate" && mapping.numberingRuleId) { + // 자동 생성 (채번 규칙) + const companyCode = context.buttonContext?.companyCode || "*"; + try { + value = await numberingRuleService.allocateCode( + mapping.numberingRuleId, + companyCode + ); + console.log( + ` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})` + ); + } catch (error: any) { + logger.error(`채번 규칙 적용 실패: ${error.message}`); + console.error( + ` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}` + ); + throw new Error( + `채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}` + ); + } + } else if (valueType === "static" || mapping.staticValue !== undefined) { + // 고정값 + value = mapping.staticValue; + console.log( + ` 📌 고정값: ${mapping.targetField} = ${value}` + ); + } else { + // 소스 필드 + value = data[mapping.sourceField]; + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + } + values.push(value); // 🔥 삽입된 값을 데이터에 반영 insertedData[mapping.targetField] = value; - }); + } // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) const hasWriterMapping = fieldMappings.some( @@ -1528,16 +1563,24 @@ export class NodeFlowExecutionService { } }); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, paramIndex ); @@ -1907,22 +1950,30 @@ export class NodeFlowExecutionService { return deletedDataArray; } - // 🆕 context-data 모드: 개별 삭제 (PK 자동 추가) + // 🆕 context-data 모드: 개별 삭제 console.log("🎯 context-data 모드: 개별 삭제 시작"); for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - // 🔑 Primary Key 자동 추가 (context-data 모드) - console.log("🔑 context-data 모드: Primary Key 자동 추가"); - const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( - whereConditions, - data, - targetTable - ); + // 🔑 Primary Key 자동 추가 여부 결정: + // whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 + // (사용자가 직접 조건을 설정한 경우 의도를 존중) + let finalWhereConditions: any[]; + if (whereConditions && whereConditions.length > 0) { + console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); + finalWhereConditions = whereConditions; + } else { + console.log("🔑 context-data 모드: Primary Key 자동 추가"); + finalWhereConditions = await this.enhanceWhereConditionsWithPK( + whereConditions, + data, + targetTable + ); + } const whereResult = this.buildWhereClause( - enhancedWhereConditions, + finalWhereConditions, data, 1 ); @@ -2282,6 +2333,7 @@ export class NodeFlowExecutionService { UPDATE ${targetTable} SET ${setClauses.join(", ")} WHERE ${updateWhereConditions} + RETURNING * `; logger.info(`🔄 UPDATE 실행:`, { @@ -2292,8 +2344,14 @@ export class NodeFlowExecutionService { values: updateValues, }); - await txClient.query(updateSql, updateValues); + const updateResult = await txClient.query(updateSql, updateValues); updatedCount++; + + // 🆕 UPDATE 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (updateResult.rows && updateResult.rows[0]) { + Object.assign(data, updateResult.rows[0]); + logger.info(` 📦 UPDATE 결과 병합: id=${updateResult.rows[0].id}`); + } } else { // 3-B. 없으면 INSERT const columns: string[] = []; @@ -2340,6 +2398,7 @@ export class NodeFlowExecutionService { const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) VALUES (${placeholders}) + RETURNING * `; logger.info(`➕ INSERT 실행:`, { @@ -2348,8 +2407,14 @@ export class NodeFlowExecutionService { conflictKeyValues, }); - await txClient.query(insertSql, values); + const insertResult = await txClient.query(insertSql, values); insertedCount++; + + // 🆕 INSERT 결과를 입력 데이터에 병합 (다음 노드에서 id 등 사용 가능) + if (insertResult.rows && insertResult.rows[0]) { + Object.assign(data, insertResult.rows[0]); + logger.info(` 📦 INSERT 결과 병합: id=${insertResult.rows[0].id}`); + } } } @@ -2357,11 +2422,10 @@ export class NodeFlowExecutionService { `✅ UPSERT 완료 (내부 DB): ${targetTable}, INSERT ${insertedCount}건, UPDATE ${updatedCount}건` ); - return { - insertedCount, - updatedCount, - totalCount: insertedCount + updatedCount, - }; + // 🔥 다음 노드에 전달할 데이터 반환 + // dataArray에는 Object.assign으로 UPSERT 결과(id 등)가 이미 병합되어 있음 + // 카운트 정보도 함께 반환하여 기존 호환성 유지 + return dataArray; }; // 🔥 클라이언트가 전달되었으면 사용, 아니면 독립 트랜잭션 생성 @@ -2707,28 +2771,48 @@ export class NodeFlowExecutionService { const trueData: any[] = []; const falseData: any[] = []; - inputData.forEach((item: any) => { - const results = conditions.map((condition: any) => { + // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) + for (const item of inputData) { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = item[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = item[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = item[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2740,7 +2824,7 @@ export class NodeFlowExecutionService { } else { falseData.push(item); } - }); + } logger.info( `🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)` @@ -2755,27 +2839,46 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 - const results = conditions.map((condition: any) => { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = inputData[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = inputData[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = inputData[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2784,7 +2887,7 @@ export class NodeFlowExecutionService { logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`); - // ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 + // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 // 조건 결과를 저장하고, 원본 데이터는 항상 반환 // 다음 노드에서 sourceHandle을 기반으로 필터링됨 return { @@ -2795,6 +2898,69 @@ export class NodeFlowExecutionService { }; } + /** + * EXISTS_IN / NOT_EXISTS_IN 조건 평가 + * 다른 테이블에 값이 존재하는지 확인 + */ + private static async evaluateExistsCondition( + fieldValue: any, + operator: string, + lookupTable: string, + lookupField: string, + companyCode?: string + ): Promise { + if (!lookupTable || !lookupField) { + logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다"); + return false; + } + + if (fieldValue === null || fieldValue === undefined || fieldValue === "") { + logger.info( + `⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)` + ); + // 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환 + // 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨 + return false; + } + + try { + // 멀티테넌시: company_code 필터 적용 여부 확인 + // company_mng 테이블은 제외 + const hasCompanyCode = lookupTable !== "company_mng" && companyCode; + + let sql: string; + let params: any[]; + + if (hasCompanyCode) { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`; + params = [fieldValue, companyCode]; + } else { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`; + params = [fieldValue]; + } + + logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`); + + const result = await query(sql, params); + const existsInTable = result[0]?.exists_result === true; + + logger.info( + `🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}` + ); + + // EXISTS_IN: 존재하면 true + // NOT_EXISTS_IN: 존재하지 않으면 true + if (operator === "EXISTS_IN") { + return existsInTable; + } else { + return !existsInTable; + } + } catch (error: any) { + logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`); + return false; + } + } + /** * WHERE 절 생성 */ @@ -4280,6 +4446,8 @@ export class NodeFlowExecutionService { /** * 산술 연산 계산 + * 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용 + * 예: (width * height) / 1000000 * qty */ private static evaluateArithmetic( arithmetic: any, @@ -4306,27 +4474,67 @@ export class NodeFlowExecutionService { const leftNum = Number(left) || 0; const rightNum = Number(right) || 0; - switch (arithmetic.operator) { + // 기본 연산 수행 + let result = this.applyOperator(leftNum, arithmetic.operator, rightNum); + + if (result === null) { + return null; + } + + // 추가 연산 처리 (다중 연산 지원) + if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) { + for (const addOp of arithmetic.additionalOperations) { + const operandValue = this.getOperandValue( + addOp.operand, + sourceRow, + targetRow, + resultValues + ); + const operandNum = Number(operandValue) || 0; + + result = this.applyOperator(result, addOp.operator, operandNum); + + if (result === null) { + logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`); + return null; + } + + logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`); + } + } + + return result; + } + + /** + * 단일 연산자 적용 + */ + private static applyOperator( + left: number, + operator: string, + right: number + ): number | null { + switch (operator) { case "+": - return leftNum + rightNum; + return left + right; case "-": - return leftNum - rightNum; + return left - right; case "*": - return leftNum * rightNum; + return left * right; case "/": - if (rightNum === 0) { + if (right === 0) { logger.warn(`⚠️ 0으로 나누기 시도`); return null; } - return leftNum / rightNum; + return left / right; case "%": - if (rightNum === 0) { + if (right === 0) { logger.warn(`⚠️ 0으로 나머지 연산 시도`); return null; } - return leftNum % rightNum; + return left % right; default: - throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`); + throw new Error(`지원하지 않는 연산자: ${operator}`); } } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1638a417..9cbbc521 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -187,71 +187,68 @@ class TableCategoryValueService { logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); } - // 2. 카테고리 값 조회 (형제 메뉴 포함) + // 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함) let query: string; let params: any[]; + const baseSelect = ` + SELECT + value_id AS "valueId", + table_name AS "tableName", + column_name AS "columnName", + value_code AS "valueCode", + value_label AS "valueLabel", + value_order AS "valueOrder", + parent_value_id AS "parentValueId", + depth, + description, + color, + icon, + is_active AS "isActive", + is_default AS "isDefault", + company_code AS "companyCode", + menu_objid AS "menuObjid", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy", + updated_by AS "updatedBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + `; + if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 조회 - // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 - query = ` - SELECT - value_id AS "valueId", - table_name AS "tableName", - column_name AS "columnName", - value_code AS "valueCode", - value_label AS "valueLabel", - value_order AS "valueOrder", - parent_value_id AS "parentValueId", - depth, - description, - color, - icon, - is_active AS "isActive", - is_default AS "isDefault", - company_code AS "companyCode", - menu_objid AS "menuObjid", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy", - updated_by AS "updatedBy" - FROM table_column_category_values - WHERE table_name = $1 - AND column_name = $2 - `; - params = [tableName, columnName]; - logger.info("최고 관리자 카테고리 값 조회"); + // 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회 + if (menuObjid && siblingObjids.length > 0) { + query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`; + params = [tableName, columnName, siblingObjids]; + logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids }); + } else if (menuObjid) { + query = baseSelect + ` AND menu_objid = $3`; + params = [tableName, columnName, menuObjid]; + logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid }); + } else { + // menuObjid 없으면 모든 값 조회 (중복 가능) + query = baseSelect; + params = [tableName, columnName]; + logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)"); + } } else { - // 일반 회사: 자신의 카테고리 값만 조회 - // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유 - query = ` - SELECT - value_id AS "valueId", - table_name AS "tableName", - column_name AS "columnName", - value_code AS "valueCode", - value_label AS "valueLabel", - value_order AS "valueOrder", - parent_value_id AS "parentValueId", - depth, - description, - color, - icon, - is_active AS "isActive", - is_default AS "isDefault", - company_code AS "companyCode", - menu_objid AS "menuObjid", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy", - updated_by AS "updatedBy" - FROM table_column_category_values - WHERE table_name = $1 - AND column_name = $2 - AND company_code = $3 - `; - params = [tableName, columnName, companyCode]; - logger.info("회사별 카테고리 값 조회", { companyCode }); + // 일반 회사: 자신의 회사 + menuObjid로 필터링 + if (menuObjid && siblingObjids.length > 0) { + query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`; + params = [tableName, columnName, companyCode, siblingObjids]; + logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids }); + } else if (menuObjid) { + query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`; + params = [tableName, columnName, companyCode, menuObjid]; + logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid }); + } else { + // menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한) + query = baseSelect + ` AND company_code = $3`; + params = [tableName, columnName, companyCode]; + logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode }); + } } if (!includeInactive) { @@ -1398,6 +1395,220 @@ class TableCategoryValueService { throw error; } } + + /** + * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용) + * + * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체 + */ + async getCategoryLabelToCodeMapping( + tableName: string, + companyCode: string + ): Promise>> { + try { + logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode }); + + const pool = getPool(); + + // 1. 해당 테이블의 카테고리 타입 컬럼 조회 + const categoryColumnsQuery = ` + SELECT column_name + FROM table_type_columns + WHERE table_name = $1 + AND input_type = 'category' + `; + const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]); + + if (categoryColumnsResult.rows.length === 0) { + logger.info("카테고리 타입 컬럼 없음", { tableName }); + return {}; + } + + const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name); + logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns }); + + // 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회 + const result: Record> = {}; + + for (const columnName of categoryColumns) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + params = [tableName, columnName]; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + AND (company_code = $3 OR company_code = '*') + `; + params = [tableName, columnName, companyCode]; + } + + const valuesResult = await pool.query(query, params); + + // { [label]: code } 형태로 변환 + const labelToCodeMap: Record = {}; + for (const row of valuesResult.rows) { + // 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑 + labelToCodeMap[row.value_label] = row.value_code; + // 소문자 키도 추가 (대소문자 무시 검색용) + labelToCodeMap[row.value_label.toLowerCase()] = row.value_code; + } + + if (Object.keys(labelToCodeMap).length > 0) { + result[columnName] = labelToCodeMap; + logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`); + } + } + + logger.info(`카테고리 라벨→코드 매핑 조회 완료`, { + tableName, + columnCount: Object.keys(result).length + }); + + return result; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error }); + throw error; + } + } + + /** + * 데이터의 카테고리 라벨 값을 코드 값으로 변환 + * + * 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @param data - 변환할 데이터 객체 + * @returns 라벨이 코드로 변환된 데이터 객체 + */ + async convertCategoryLabelsToCodesForData( + tableName: string, + companyCode: string, + data: Record + ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> { + try { + // 라벨→코드 매핑 조회 + const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode); + + if (Object.keys(labelToCodeMapping).length === 0) { + // 카테고리 컬럼 없음 + return { convertedData: data, conversions: [] }; + } + + const convertedData = { ...data }; + const conversions: Array<{ column: string; label: string; code: string }> = []; + + for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) { + const value = data[columnName]; + + if (value !== undefined && value !== null && value !== "") { + const stringValue = String(value).trim(); + + // 다중 값 확인 (쉼표로 구분된 경우) + if (stringValue.includes(",")) { + // 다중 카테고리 값 처리 + const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== ""); + const convertedCodes: string[] = []; + let allConverted = true; + + for (const label of labels) { + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[label]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[label.toLowerCase()]; + } + + if (matchedCode) { + convertedCodes.push(matchedCode); + conversions.push({ + column: columnName, + label: label, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 + const isAlreadyCode = Object.values(labelCodeMap).includes(label); + if (isAlreadyCode) { + // 이미 코드값이면 그대로 사용 + convertedCodes.push(label); + } else { + // 라벨도 코드도 아니면 원래 값 유지 + convertedCodes.push(label); + allConverted = false; + logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`); + } + } + } + + // 변환된 코드들을 쉼표로 합쳐서 저장 + convertedData[columnName] = convertedCodes.join(","); + logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`); + } else { + // 단일 값 처리 + // 정확한 라벨 매칭 시도 + let matchedCode = labelCodeMap[stringValue]; + + // 대소문자 무시 매칭 + if (!matchedCode) { + matchedCode = labelCodeMap[stringValue.toLowerCase()]; + } + + if (matchedCode) { + // 라벨 값을 코드 값으로 변환 + convertedData[columnName] = matchedCode; + conversions.push({ + column: columnName, + label: stringValue, + code: matchedCode, + }); + logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`); + } else { + // 이미 코드값인지 확인 (역방향 확인) + const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue); + if (!isAlreadyCode) { + logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`); + } + // 변환 없이 원래 값 유지 + } + } + } + } + + logger.info(`카테고리 라벨→코드 변환 완료`, { + tableName, + conversionCount: conversions.length, + conversions, + }); + + return { convertedData, conversions }; + } catch (error: any) { + logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error }); + // 실패 시 원본 데이터 반환 + return { convertedData: data, conversions: [] }; + } + } } export default new TableCategoryValueService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b714b186..2e67040a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1306,6 +1306,41 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원) + // 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함 + if (Array.isArray(value) && value.length > 0) { + // 배열의 각 값에 대해 OR 조건으로 검색 + // 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로 + // 각 값을 LIKE 또는 = 조건으로 처리 + const conditions: string[] = []; + const values: any[] = []; + + value.forEach((v: any, idx: number) => { + const safeValue = String(v).trim(); + // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 + // 예: "2,3" 컬럼에서 "2"를 찾으려면: + // - 정확히 "2" + // - "2," 로 시작 + // - ",2" 로 끝남 + // - ",2," 중간에 포함 + const paramBase = paramIndex + (idx * 4); + conditions.push(`( + ${columnName}::text = $${paramBase} OR + ${columnName}::text LIKE $${paramBase + 1} OR + ${columnName}::text LIKE $${paramBase + 2} OR + ${columnName}::text LIKE $${paramBase + 3} + )`); + values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + }); + + logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + return { + whereClause: `(${conditions.join(" OR ")})`, + values, + paramCount: values.length, + }; + } + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo( @@ -2261,11 +2296,12 @@ export class TableManagementService { /** * 테이블에 데이터 추가 + * @returns 무시된 컬럼 정보 (디버깅용) */ async addTableData( tableName: string, data: Record - ): Promise { + ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2296,10 +2332,41 @@ export class TableManagementService { logger.info(`created_date 자동 추가: ${data.created_date}`); } - // 컬럼명과 값을 분리하고 타입에 맞게 변환 - const columns = Object.keys(data); - const values = Object.values(data).map((value, index) => { - const columnName = columns[index]; + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) + const skippedColumns: string[] = []; + const existingColumns = Object.keys(data).filter((col) => { + const exists = columnTypeMap.has(col); + if (!exists) { + skippedColumns.push(col); + } + return exists; + }); + + // 무시된 컬럼이 있으면 경고 로그 출력 + if (skippedColumns.length > 0) { + logger.warn( + `⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}` + ); + logger.warn( + `⚠️ [${tableName}] 무시된 컬럼 상세:`, + skippedColumns.map((col) => ({ column: col, value: data[col] })) + ); + } + + if (existingColumns.length === 0) { + throw new Error( + `저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}` + ); + } + + logger.info( + `✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}` + ); + + // 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만) + const columns = existingColumns; + const values = columns.map((columnName) => { + const value = data[columnName]; const dataType = columnTypeMap.get(columnName) || "text"; const convertedValue = this.convertValueForPostgreSQL(value, dataType); logger.info( @@ -2355,6 +2422,12 @@ export class TableManagementService { await query(insertQuery, values); logger.info(`테이블 데이터 추가 완료: ${tableName}`); + + // 무시된 컬럼과 저장된 컬럼 정보 반환 + return { + skippedColumns, + savedColumns: existingColumns, + }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); throw error; @@ -2409,11 +2482,19 @@ export class TableManagementService { } // SET 절 생성 (수정할 데이터) - 먼저 생성 + // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; const setValues: any[] = []; let paramIndex = 1; + const skippedColumns: string[] = []; Object.keys(updatedData).forEach((column) => { + // 테이블에 존재하지 않는 컬럼은 스킵 + if (!columnTypeMap.has(column)) { + skippedColumns.push(column); + return; + } + const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` @@ -2424,6 +2505,10 @@ export class TableManagementService { paramIndex++; }); + if (skippedColumns.length > 0) { + logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`); + } + // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) let whereConditions: string[] = []; let whereValues: any[] = []; @@ -2626,6 +2711,12 @@ export class TableManagementService { filterColumn?: string; filterValue?: any; }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; // 🆕 중복 제거 설정 } ): Promise { const startTime = Date.now(); @@ -2676,33 +2767,64 @@ export class TableManagementService { ); for (const additionalColumn of options.additionalJoinColumns) { - // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기) - const baseJoinConfig = joinConfigs.find( + // 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기 + let baseJoinConfig = joinConfigs.find( (config) => config.sourceColumn === additionalColumn.sourceColumn ); + // 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때) + // 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응 + if (!baseJoinConfig && (additionalColumn as any).referenceTable) { + baseJoinConfig = joinConfigs.find( + (config) => config.referenceTable === (additionalColumn as any).referenceTable + ); + if (baseJoinConfig) { + logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`); + } + } + if (baseJoinConfig) { - // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) - // sourceColumn을 제거한 나머지 부분이 실제 컬럼명 - const sourceColumn = baseJoinConfig.sourceColumn; // dept_code - const joinAlias = additionalColumn.joinAlias; // dept_code_company_name - const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name + // joinAlias에서 실제 컬럼명 추출 + const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) + const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) + + // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 + // customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거) + // 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거) + let actualColumnName: string; + + // 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출 + const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id) + if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) { + // 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, ""); + } else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) { + // 실제 소스 컬럼으로 시작하면 그 부분 제거 + actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, ""); + } else { + // 어느 것도 아니면 원본 사용 + actualColumnName = originalJoinAlias; + } + + // 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반) + const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`; logger.info(`🔍 조인 컬럼 상세 분석:`, { sourceColumn, - joinAlias, + frontendSourceColumn, + originalJoinAlias, + correctedJoinAlias, actualColumnName, - referenceTable: additionalColumn.sourceTable, + referenceTable: (additionalColumn as any).referenceTable, }); // 🚨 기본 Entity 조인과 중복되지 않도록 체크 const isBasicEntityJoin = - additionalColumn.joinAlias === - `${baseJoinConfig.sourceColumn}_name`; + correctedJoinAlias === `${sourceColumn}_name`; if (isBasicEntityJoin) { logger.info( - `⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀` + `⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀` ); continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } @@ -2710,14 +2832,14 @@ export class TableManagementService { // 추가 조인 컬럼 설정 생성 const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, - sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) referenceTable: (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (dept_info) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name) + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name) + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) separator: " - ", // 기본 구분자 }; @@ -3684,6 +3806,15 @@ export class TableManagementService { const cacheableJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = []; + // 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요) + const companySpecificTables = [ + "supplier_mng", + "customer_mng", + "item_info", + "dept_info", + // 필요시 추가 + ]; + for (const config of joinConfigs) { // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 if (config.referenceTable === "table_column_category_values") { @@ -3692,6 +3823,13 @@ export class TableManagementService { continue; } + // 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시) + if (companySpecificTables.includes(config.referenceTable)) { + dbJoins.push(config); + console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, @@ -3930,9 +4068,10 @@ export class TableManagementService { `컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}` ); - // table_type_columns에서 입력타입 정보 조회 (company_code 필터링) + // table_type_columns에서 입력타입 정보 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback const rawInputTypes = await query( - `SELECT + `SELECT DISTINCT ON (ttc.column_name) ttc.column_name as "columnName", COALESCE(cl.column_label, ttc.column_name) as "displayName", ttc.input_type as "inputType", @@ -3946,8 +4085,10 @@ export class TableManagementService { LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name WHERE ttc.table_name = $1 - AND ttc.company_code = $2 - ORDER BY ttc.display_order, ttc.column_name`, + AND ttc.company_code IN ($2, '*') + ORDER BY ttc.column_name, + CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END, + ttc.display_order`, [tableName, companyCode] ); @@ -3961,17 +4102,20 @@ export class TableManagementService { const mappingTableExists = tableExistsResult[0]?.table_exists === true; // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback let categoryMappings: Map = new Map(); if (mappingTableExists) { logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); const mappings = await query( - `SELECT + `SELECT DISTINCT ON (logical_column_name, menu_objid) logical_column_name as "columnName", menu_objid as "menuObjid" FROM category_column_mapping WHERE table_name = $1 - AND company_code = $2`, + AND company_code IN ($2, '*') + ORDER BY logical_column_name, menu_objid, + CASE WHEN company_code = $2 THEN 0 ELSE 1 END`, [tableName, companyCode] ); @@ -4574,4 +4718,101 @@ export class TableManagementService { return false; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다. + * + * @param leftTable 좌측 테이블명 + * @param rightTable 우측 테이블명 + * @returns 감지된 엔티티 관계 배열 + */ + async detectTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise> { + try { + logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); + + const relations: Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> = []; + + // 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code + const rightToLeftRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [rightTable, leftTable] + ); + + for (const rel of rightToLeftRels) { + relations.push({ + leftColumn: rel.reference_column, + rightColumn: rel.column_name, + direction: "right_to_left", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + // 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: left_table의 item_id -> right_table(item_info)의 item_number + const leftToRightRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [leftTable, rightTable] + ); + + for (const rel of leftToRightRels) { + relations.push({ + leftColumn: rel.column_name, + rightColumn: rel.reference_column, + direction: "left_to_right", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); + relations.forEach((rel, idx) => { + logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + }); + + return relations; + } catch (error) { + logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + return []; + } + } } diff --git a/backend-node/src/types/multilang.ts b/backend-node/src/types/multilang.ts index 8ad8adb6..c30fdfaa 100644 --- a/backend-node/src/types/multilang.ts +++ b/backend-node/src/types/multilang.ts @@ -17,12 +17,30 @@ export interface LangKey { langKey: string; description?: string; isActive: string; + categoryId?: number; + keyMeaning?: string; + usageNote?: string; + baseKeyId?: number; createdDate?: Date; createdBy?: string; updatedDate?: Date; updatedBy?: string; } +// 카테고리 인터페이스 +export interface LangCategory { + categoryId: number; + categoryCode: string; + categoryName: string; + parentId?: number | null; + level: number; + keyPrefix: string; + description?: string; + sortOrder: number; + isActive: string; + children?: LangCategory[]; +} + export interface LangText { textId?: number; keyId: number; @@ -63,10 +81,38 @@ export interface CreateLangKeyRequest { langKey: string; description?: string; isActive?: string; + categoryId?: number; + keyMeaning?: string; + usageNote?: string; + baseKeyId?: number; createdBy?: string; updatedBy?: string; } +// 자동 키 생성 요청 +export interface GenerateKeyRequest { + companyCode: string; + categoryId: number; + keyMeaning: string; + usageNote?: string; + texts: Array<{ + langCode: string; + langText: string; + }>; + createdBy?: string; +} + +// 오버라이드 키 생성 요청 +export interface CreateOverrideKeyRequest { + companyCode: string; + baseKeyId: number; + texts: Array<{ + langCode: string; + langText: string; + }>; + createdBy?: string; +} + export interface UpdateLangKeyRequest { companyCode?: string; menuName?: string; @@ -90,6 +136,8 @@ export interface GetLangKeysParams { menuCode?: string; keyType?: string; searchText?: string; + categoryId?: number; + includeOverrides?: boolean; page?: number; limit?: number; } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c9349b94..a59f4499 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -587,3 +587,5 @@ const result = await executeNodeFlow(flowId, { + + diff --git a/docs/다국어_관리_시스템_개선_계획서.md b/docs/다국어_관리_시스템_개선_계획서.md new file mode 100644 index 00000000..24ca5850 --- /dev/null +++ b/docs/다국어_관리_시스템_개선_계획서.md @@ -0,0 +1,597 @@ +# 다국어 관리 시스템 개선 계획서 + +## 1. 개요 + +### 1.1 현재 시스템 분석 + +현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다. + +| 항목 | 현재 상태 | 문제점 | +|------|----------|--------| +| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 | +| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 | +| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 | +| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 | +| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 | + +### 1.2 개선 목표 + +1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원 +2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정 +3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류 +4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성 +5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인 + +--- + +## 2. 데이터베이스 스키마 설계 + +### 2.1 신규 테이블: multi_lang_category (카테고리 마스터) + +```sql +CREATE TABLE multi_lang_category ( + category_id SERIAL PRIMARY KEY, + category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등 + category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등 + parent_id INT4 REFERENCES multi_lang_category(category_id), + level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류 + key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix + description TEXT, + sort_order INT4 DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(50), + UNIQUE(category_code, COALESCE(parent_id, 0)) +); + +-- 인덱스 +CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id); +CREATE INDEX idx_lang_category_level ON multi_lang_category(level); +``` + +### 2.2 기존 테이블 수정: multi_lang_key_master + +```sql +-- 카테고리 연결 컬럼 추가 +ALTER TABLE multi_lang_key_master +ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id); + +-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값) +ALTER TABLE multi_lang_key_master +ADD COLUMN key_meaning VARCHAR(100); + +-- 원본 키 참조 (오버라이드 시 원본 추적) +ALTER TABLE multi_lang_key_master +ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id); + +-- menu_name을 usage_note로 변경 (사용 위치 메모) +ALTER TABLE multi_lang_key_master +RENAME COLUMN menu_name TO usage_note; + +-- 인덱스 추가 +CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id); +CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id); +CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id); +``` + +### 2.3 테이블 관계도 + +``` +multi_lang_category (1) ◀────────┐ + ├── category_id (PK) │ + ├── category_code │ + ├── parent_id (자기참조) │ + └── key_prefix │ + │ +multi_lang_key_master (N) ────────┘ + ├── key_id (PK) + ├── company_code ('*' = 공통) + ├── category_id (FK) + ├── lang_key (자동 생성) + ├── key_meaning (사용자 입력) + ├── base_key_id (오버라이드 시 원본) + └── usage_note (사용 위치 메모) + │ + ▼ +multi_lang_text (N) + ├── text_id (PK) + ├── key_id (FK) + ├── lang_code (FK → language_master) + └── lang_text +``` + +--- + +## 3. 카테고리 체계 + +### 3.1 대분류 (Level 1) + +| category_code | category_name | key_prefix | 설명 | +|---------------|---------------|------------|------| +| COMMON | 공통 | common | 범용 텍스트 | +| BUTTON | 버튼 | button | 버튼 텍스트 | +| FORM | 폼 | form | 폼 라벨, 플레이스홀더 | +| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 | +| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 | +| MENU | 메뉴 | menu | 메뉴명, 네비게이션 | +| MODAL | 모달 | modal | 모달/다이얼로그 | +| VALIDATION | 검증 | validation | 유효성 검사 메시지 | +| STATUS | 상태 | status | 상태 표시 텍스트 | +| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 | + +### 3.2 세부분류 (Level 2) + +#### BUTTON 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| ACTION | 액션 | action | +| NAVIGATION | 네비게이션 | nav | +| TOGGLE | 토글 | toggle | + +#### FORM 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| LABEL | 라벨 | label | +| PLACEHOLDER | 플레이스홀더 | placeholder | +| HELPER | 도움말 | helper | + +#### MESSAGE 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| SUCCESS | 성공 | success | +| ERROR | 에러 | error | +| WARNING | 경고 | warning | +| INFO | 안내 | info | +| CONFIRM | 확인 | confirm | + +#### TABLE 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| HEADER | 헤더 | header | +| EMPTY | 빈 상태 | empty | +| PAGINATION | 페이지네이션 | pagination | + +#### MENU 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| ADMIN | 관리자 | admin | +| USER | 사용자 | user | + +#### MODAL 하위 +| category_code | category_name | key_prefix | +|---------------|---------------|------------| +| TITLE | 제목 | title | +| DESCRIPTION | 설명 | description | + +### 3.3 키 자동 생성 규칙 + +**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}` + +**예시**: +| 대분류 | 세부분류 | 의미 입력 | 생성 키 | +|--------|----------|----------|---------| +| BUTTON | ACTION | save | `button.action.save` | +| BUTTON | ACTION | delete_selected | `button.action.delete_selected` | +| FORM | LABEL | user_name | `form.label.user_name` | +| FORM | PLACEHOLDER | search | `form.placeholder.search` | +| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` | +| MESSAGE | ERROR | network_fail | `message.error.network_fail` | +| TABLE | HEADER | created_date | `table.header.created_date` | +| MENU | ADMIN | user_management | `menu.admin.user_management` | + +--- + +## 4. 회사별 다국어 시스템 + +### 4.1 조회 우선순위 + +다국어 텍스트 조회 시 다음 우선순위를 적용합니다: + +1. **회사 전용 키** (`company_code = 'COMPANY_A'`) +2. **공통 키** (`company_code = '*'`) + +```sql +-- 조회 쿼리 예시 +WITH ranked_keys AS ( + SELECT + km.lang_key, + mt.lang_text, + km.company_code, + ROW_NUMBER() OVER ( + PARTITION BY km.lang_key + ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END + ) as priority + FROM multi_lang_key_master km + JOIN multi_lang_text mt ON km.key_id = mt.key_id + WHERE km.lang_key = ANY($2) + AND mt.lang_code = $3 + AND km.is_active = 'Y' + AND km.company_code IN ($1, '*') +) +SELECT lang_key, lang_text +FROM ranked_keys +WHERE priority = 1; +``` + +### 4.2 오버라이드 프로세스 + +1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭 +2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성 +3. 기존 번역 텍스트 복사 +4. 회사 관리자가 번역 수정 +5. 이후 해당 회사 사용자는 회사 전용 번역 사용 + +### 4.3 권한 매트릭스 + +| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 | +|------|------------------|-------------|-------------| +| 공통 키 조회 | O | O | O | +| 공통 키 생성 | O | X | X | +| 공통 키 수정 | O | X | X | +| 공통 키 삭제 | O | X | X | +| 회사 키 조회 | O | 자사만 | 자사만 | +| 회사 키 생성 (오버라이드) | O | O | X | +| 회사 키 수정 | O | 자사만 | X | +| 회사 키 삭제 | O | 자사만 | X | +| 카테고리 관리 | O | X | X | + +--- + +## 5. API 설계 + +### 5.1 카테고리 API + +| 엔드포인트 | 메서드 | 설명 | 권한 | +|-----------|--------|------|------| +| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 | +| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 | +| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 | +| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 | +| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 | + +### 5.2 다국어 키 API (개선) + +| 엔드포인트 | 메서드 | 설명 | 권한 | +|-----------|--------|------|------| +| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 | +| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 | +| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 | +| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 | +| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 | +| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 | +| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 | + +### 5.3 API 요청/응답 예시 + +#### 키 생성 요청 +```json +POST /multilang/keys +{ + "categoryId": 11, // 세부분류 ID (BUTTON > ACTION) + "keyMeaning": "save_changes", + "description": "변경사항 저장 버튼", + "usageNote": "사용자 관리, 설정 화면", + "texts": [ + { "langCode": "KR", "langText": "저장하기" }, + { "langCode": "US", "langText": "Save Changes" }, + { "langCode": "JP", "langText": "保存する" } + ] +} +``` + +#### 키 생성 응답 +```json +{ + "success": true, + "message": "다국어 키가 생성되었습니다.", + "data": { + "keyId": 175, + "langKey": "button.action.save_changes", + "companyCode": "*", + "categoryId": 11 + } +} +``` + +#### 오버라이드 요청 +```json +POST /multilang/keys/123/override +{ + "texts": [ + { "langCode": "KR", "langText": "등록하기" }, + { "langCode": "US", "langText": "Register" } + ] +} +``` + +--- + +## 6. 프론트엔드 UI 설계 + +### 6.1 다국어 관리 페이지 리뉴얼 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 다국어 관리 │ +│ 다국어 키와 번역 텍스트를 관리합니다 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ [언어 관리] [다국어 키 관리] [카테고리 관리] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤ +│ │ 카테고리 필터 │ │ │ +│ │ │ │ 검색: [________________] 회사: [전체 ▼] │ +│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │ +│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│ +│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │ +│ │ └ 토글 (5) │ │───────────────────────────────────────────────│ +│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │ +│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │ +│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │ +│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │ +│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│ +│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │ +│ │ ▶ 메뉴 (9) │ │ │ +│ └────────────────────┘ └───────────────────────────────────────────────┤ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 키 등록 모달 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 다국어 키 등록 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ① 카테고리 선택 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 대분류 * │ 세부 분류 * │ +│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │ +│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │ +│ │ │ ● 버튼 │ │ │ ● 액션 │ │ +│ │ │ 폼 │ │ │ 네비게이션 │ │ +│ │ │ 테이블 │ │ │ 토글 │ │ +│ │ │ 메시지 │ │ │ │ │ +│ │ └─────────────────────────┘ │ └─────────────────────────┘ │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +│ ② 키 정보 입력 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 키 의미 (영문) * │ +│ │ [ save_changes ] │ +│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │ +│ │ │ +│ │ ───────────────────────────────────────────────────────── │ +│ │ 자동 생성 키: │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ button.action.save_changes │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ +│ │ ✓ 사용 가능한 키입니다 │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +│ ③ 설명 및 번역 │ +│ ┌───────────────────────────────────────────────────────────────┤ +│ │ 설명 (선택) │ +│ │ [ 변경사항을 저장하는 버튼 ] │ +│ │ │ +│ │ 사용 위치 메모 (선택) │ +│ │ [ 사용자 관리, 설정 화면 ] │ +│ │ │ +│ │ ───────────────────────────────────────────────────────── │ +│ │ 번역 텍스트 │ +│ │ │ +│ │ 한국어 (KR) * [ 저장하기 ] │ +│ │ English (US) [ Save Changes ] │ +│ │ 日本語 (JP) [ 保存する ] │ +│ └───────────────────────────────────────────────────────────────┤ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ [취소] [등록] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 공통 키 편집 모달 (회사 관리자용) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 다국어 키 상세 │ +│ button.action.save (공통) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 카테고리: 버튼 > 액션 │ +│ 설명: 저장 버튼 │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ 번역 텍스트 (읽기 전용) │ +│ │ +│ 한국어 (KR) 저장 │ +│ English (US) Save │ +│ 日本語 (JP) 保存 │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ 공통 키는 수정할 수 없습니다. │ +│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │ +│ │ +│ [이 회사 전용으로 복사] │ +├─────────────────────────────────────────────────────────────────┤ +│ [닫기] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.4 회사 전용 키 생성 모달 (오버라이드) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 회사 전용 키 생성 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 원본 키: button.action.save (공통) │ +│ │ +│ 원본 번역: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 한국어: 저장 │ │ +│ │ English: Save │ │ +│ │ 日本語: 保存 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ 이 회사 전용 번역 텍스트: │ +│ │ +│ 한국어 (KR) * [ 등록하기 ] │ +│ English (US) [ Register ] │ +│ 日本語 (JP) [ 登録 ] │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │ +│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │ +├─────────────────────────────────────────────────────────────────┤ +│ [취소] [생성] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 계획 + +### 7.1 Phase 1: 데이터베이스 마이그레이션 + +**예상 소요 시간: 2시간** + +1. 카테고리 테이블 생성 +2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개) +3. multi_lang_key_master 스키마 변경 +4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭) + +**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql` + +### 7.2 Phase 2: 백엔드 API 개발 + +**예상 소요 시간: 4시간** + +1. 카테고리 CRUD API +2. 키 조회 로직 수정 (우선순위 적용) +3. 권한 검사 미들웨어 +4. 오버라이드 API +5. 키 중복 체크 API +6. 키 자동 생성 미리보기 API + +**관련 파일**: +- `backend-node/src/controllers/multilangController.ts` +- `backend-node/src/services/multilangService.ts` +- `backend-node/src/routes/multilangRoutes.ts` + +### 7.3 Phase 3: 프론트엔드 UI 개발 + +**예상 소요 시간: 6시간** + +1. 카테고리 트리 컴포넌트 +2. 키 등록 모달 리뉴얼 (단계별 입력) +3. 키 편집 모달 (권한별 UI 분기) +4. 오버라이드 모달 +5. 카테고리 관리 탭 추가 + +**관련 파일**: +- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` +- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼) +- `frontend/components/multilang/CategoryTree.tsx` (신규) +- `frontend/components/multilang/OverrideModal.tsx` (신규) + +### 7.4 Phase 4: 테스트 및 마이그레이션 + +**예상 소요 시간: 2시간** + +1. API 테스트 +2. UI 테스트 +3. 기존 데이터 마이그레이션 검증 +4. 권한 테스트 (최고 관리자, 회사 관리자) + +--- + +## 8. 상세 구현 일정 + +| 단계 | 작업 | 예상 시간 | 의존성 | +|------|------|----------|--------| +| 1.1 | 마이그레이션 SQL 작성 | 30분 | - | +| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 | +| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 | +| 1.4 | 스키마 변경 검증 | 30분 | 1.3 | +| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 | +| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 | +| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 | +| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 | +| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 | +| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 | +| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 | +| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 | +| 3.4 | 오버라이드 모달 | 1시간 | 3.3 | +| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 | +| 4.1 | 통합 테스트 | 1시간 | 3.5 | +| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 | + +**총 예상 시간: 약 14시간** + +--- + +## 9. 기대 효과 + +### 9.1 개선 전후 비교 + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) | +| 카테고리 분류 | 없음 | 2단계 계층 구조 | +| 회사별 다국어 | 미활용 | 오버라이드 지원 | +| 조회 우선순위 | 없음 | 회사 전용 > 공통 | +| 권한 관리 | 없음 | 역할별 접근 제어 | +| 중복 체크 | 저장 시에만 | 실시간 검증 | +| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 | + +### 9.2 사용자 경험 개선 + +1. **일관된 키 명명**: 자동 생성으로 규칙 준수 +2. **빠른 검색**: 카테고리 기반 필터링 +3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용 +4. **안전한 수정**: 권한 기반 보호 + +### 9.3 유지보수 개선 + +1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확 +2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인 +3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장 + +--- + +## 10. 참고 자료 + +### 10.1 관련 파일 + +| 파일 | 설명 | +|------|------| +| `frontend/hooks/useMultiLang.ts` | 다국어 훅 | +| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 | +| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 | +| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 | +| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 | +| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 | + +### 10.2 데이터베이스 테이블 + +| 테이블 | 설명 | +|--------|------| +| `language_master` | 언어 마스터 (KR, US, JP) | +| `multi_lang_key_master` | 다국어 키 마스터 | +| `multi_lang_text` | 다국어 번역 텍스트 | +| `multi_lang_category` | 다국어 카테고리 (신규) | + +--- + +## 11. 변경 이력 + +| 버전 | 날짜 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 1.0 | 2026-01-13 | AI | 최초 작성 | + + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 42900211..ef62a60a 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -360,3 +360,5 @@ + + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index c392eece..806e480d 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -346,3 +346,5 @@ const getComponentValue = (componentId: string) => { + + diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 106870eb..4e2878eb 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -241,3 +241,5 @@ export default function ScreenManagementPage() { ); } + + diff --git a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx index 3acce6fb..79264d72 100644 --- a/frontend/app/(main)/admin/systemMng/i18nList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/i18nList/page.tsx @@ -7,13 +7,19 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus } from "lucide-react"; import { DataTable } from "@/components/common/DataTable"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { useAuth } from "@/hooks/useAuth"; import LangKeyModal from "@/components/admin/LangKeyModal"; import LanguageModal from "@/components/admin/LanguageModal"; +import { CategoryTree } from "@/components/admin/multilang/CategoryTree"; +import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal"; import { apiClient } from "@/lib/api/client"; +import { LangCategory } from "@/lib/api/multilang"; interface Language { langCode: string; @@ -29,6 +35,7 @@ interface LangKey { langKey: string; description: string; isActive: string; + categoryId?: number; } interface LangText { @@ -59,6 +66,10 @@ export default function I18nPage() { const [selectedLanguages, setSelectedLanguages] = useState>(new Set()); const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); + // 카테고리 관련 상태 + const [selectedCategory, setSelectedCategory] = useState(null); + const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false); + const [companies, setCompanies] = useState>([]); // 회사 목록 조회 @@ -92,9 +103,14 @@ export default function I18nPage() { }; // 다국어 키 목록 조회 - const fetchLangKeys = async () => { + const fetchLangKeys = async (categoryId?: number | null) => { try { - const response = await apiClient.get("/multilang/keys"); + const params = new URLSearchParams(); + if (categoryId) { + params.append("categoryId", categoryId.toString()); + } + const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`; + const response = await apiClient.get(url); const data = response.data; if (data.success) { setLangKeys(data.data); @@ -471,6 +487,13 @@ export default function I18nPage() { initializeData(); }, []); + // 카테고리 변경 시 키 목록 다시 조회 + useEffect(() => { + if (!loading) { + fetchLangKeys(selectedCategory?.categoryId); + } + }, [selectedCategory?.categoryId]); + const columns = [ { id: "select", @@ -678,27 +701,70 @@ export default function I18nPage() { {/* 다국어 키 관리 탭 */} {activeTab === "keys" && ( -
- {/* 좌측: 언어 키 목록 (7/10) */} - - +
+ {/* 좌측: 카테고리 트리 (2/12) */} + +
- 언어 키 목록 + 카테고리 +
+
+ + + setSelectedCategory(cat)} + onDoubleClickCategory={(cat) => { + setSelectedCategory(cat); + setIsGenerateModalOpen(true); + }} + /> + + +
+ + {/* 중앙: 언어 키 목록 (6/12) */} + + +
+ + 언어 키 목록 + {selectedCategory && ( + + {selectedCategory.categoryName} + + )} +
- - + +
- + {/* 검색 필터 영역 */}
- + setSearchText(e.target.value)} + className="h-8 text-xs" />
-
검색 결과: {getFilteredLangKeys().length}건
+
결과: {getFilteredLangKeys().length}건
{/* 테이블 영역 */}
-
전체: {getFilteredLangKeys().length}건
- {/* 우측: 선택된 키의 다국어 관리 (3/10) */} - + {/* 우측: 선택된 키의 다국어 관리 (4/12) */} + {selectedKey ? ( @@ -817,6 +883,18 @@ export default function I18nPage() { onSave={handleSaveLanguage} languageData={editingLanguage} /> + + {/* 키 자동 생성 모달 */} + setIsGenerateModalOpen(false)} + selectedCategory={selectedCategory} + companyCode={user?.companyCode || ""} + isSuperAdmin={user?.companyCode === "*"} + onSuccess={() => { + fetchLangKeys(selectedCategory?.categoryId); + }} + />
diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 0b5ff573..4ba1e6c0 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; @@ -90,6 +93,13 @@ export default function TableManagementPage() { // 🎯 Entity 조인 관련 상태 const [referenceTableColumns, setReferenceTableColumns] = useState>({}); + // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리) + const [entityComboboxOpen, setEntityComboboxOpen] = useState>({}); + // DDL 기능 관련 상태 const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); @@ -1388,113 +1398,266 @@ export default function TableManagementPage() { {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> - {/* 참조 테이블 */} -
+ {/* 참조 테이블 - 검색 가능한 Combobox */} +
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {referenceTableOptions.map((option) => ( + { + handleDetailSettingsChange(column.columnName, "entity", option.value); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], table: false }, + })); + }} + className="text-xs" + > + +
+ {option.label} + {option.value !== "none" && ( + {option.value} + )} +
+
+ ))} +
+
+
+
+
- {/* 조인 컬럼 */} + {/* 조인 컬럼 - 검색 가능한 Combobox */} {column.referenceTable && column.referenceTable !== "none" && ( -
+
- + 로딩중... + + ) : column.referenceColumn && column.referenceColumn !== "none" ? ( + column.referenceColumn + ) : ( + "컬럼 선택..." + )} + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + + -- 선택 안함 -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} - {/* 표시 컬럼 */} + {/* 표시 컬럼 - 검색 가능한 Combobox */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && column.referenceColumn !== "none" && ( -
+
- + 로딩중... + + ) : column.displayColumn && column.displayColumn !== "none" ? ( + column.displayColumn + ) : ( + "컬럼 선택..." + )} + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + { + handleDetailSettingsChange(column.columnName, "entity_display_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + + -- 선택 안함 -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} @@ -1505,8 +1668,8 @@ export default function TableManagementPage() { column.referenceColumn !== "none" && column.displayColumn && column.displayColumn !== "none" && ( -
- +
+ 설정 완료
)} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index b61d5dae..4923ded7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -23,6 +23,7 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 +import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 function ScreenViewPage() { const params = useParams(); @@ -113,7 +114,7 @@ function ScreenViewPage() { // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); setEditModalConfig({ screenId: event.detail.screenId, @@ -345,9 +346,10 @@ function ScreenViewPage() { {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( -
+
); })()} -
+
+ ) : ( // 빈 화면일 때
diff --git a/frontend/app/(pop)/layout.tsx b/frontend/app/(pop)/layout.tsx new file mode 100644 index 00000000..1c41d1c0 --- /dev/null +++ b/frontend/app/(pop)/layout.tsx @@ -0,0 +1,10 @@ +import "@/app/globals.css"; + +export const metadata = { + title: "POP - 생산실적관리", + description: "생산 현장 실적 관리 시스템", +}; + +export default function PopLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/frontend/app/(pop)/pop/page.tsx b/frontend/app/(pop)/pop/page.tsx new file mode 100644 index 00000000..3cf5de33 --- /dev/null +++ b/frontend/app/(pop)/pop/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PopDashboard } from "@/components/pop/dashboard"; + +export default function PopPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/work/page.tsx b/frontend/app/(pop)/pop/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/pop/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/(pop)/work/page.tsx b/frontend/app/(pop)/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a252eaff..1614c9b8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -388,6 +388,237 @@ select { border-spacing: 0 !important; } +/* ===== POP (Production Operation Panel) Styles ===== */ + +/* POP 전용 다크 테마 변수 */ +.pop-dark { + /* 배경 색상 */ + --pop-bg-deepest: 8 12 21; + --pop-bg-deep: 10 15 28; + --pop-bg-primary: 13 19 35; + --pop-bg-secondary: 18 26 47; + --pop-bg-tertiary: 25 35 60; + --pop-bg-elevated: 32 45 75; + + /* 네온 강조색 */ + --pop-neon-cyan: 0 212 255; + --pop-neon-cyan-bright: 0 240 255; + --pop-neon-cyan-dim: 0 150 190; + --pop-neon-pink: 255 0 102; + --pop-neon-purple: 138 43 226; + + /* 상태 색상 */ + --pop-success: 0 255 136; + --pop-success-dim: 0 180 100; + --pop-warning: 255 170 0; + --pop-warning-dim: 200 130 0; + --pop-danger: 255 51 51; + --pop-danger-dim: 200 40 40; + + /* 텍스트 색상 */ + --pop-text-primary: 255 255 255; + --pop-text-secondary: 180 195 220; + --pop-text-muted: 100 120 150; + + /* 테두리 색상 */ + --pop-border: 40 55 85; + --pop-border-light: 55 75 110; +} + +/* POP 전용 라이트 테마 변수 */ +.pop-light { + --pop-bg-deepest: 245 247 250; + --pop-bg-deep: 240 243 248; + --pop-bg-primary: 250 251 253; + --pop-bg-secondary: 255 255 255; + --pop-bg-tertiary: 245 247 250; + --pop-bg-elevated: 235 238 245; + + --pop-neon-cyan: 0 122 204; + --pop-neon-cyan-bright: 0 140 230; + --pop-neon-cyan-dim: 0 100 170; + --pop-neon-pink: 220 38 127; + --pop-neon-purple: 118 38 200; + + --pop-success: 22 163 74; + --pop-success-dim: 21 128 61; + --pop-warning: 245 158 11; + --pop-warning-dim: 217 119 6; + --pop-danger: 220 38 38; + --pop-danger-dim: 185 28 28; + + --pop-text-primary: 15 23 42; + --pop-text-secondary: 71 85 105; + --pop-text-muted: 148 163 184; + + --pop-border: 226 232 240; + --pop-border-light: 203 213 225; +} + +/* POP 배경 그리드 패턴 */ +.pop-bg-pattern::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +.pop-light .pop-bg-pattern::before { + background: + repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); +} + +/* POP 글로우 효과 */ +.pop-glow-cyan { + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.5), + 0 0 40px rgba(0, 212, 255, 0.3); +} + +.pop-glow-cyan-strong { + box-shadow: + 0 0 10px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.5), + 0 0 50px rgba(0, 212, 255, 0.3); +} + +.pop-glow-success { + box-shadow: 0 0 15px rgba(0, 255, 136, 0.5); +} + +.pop-glow-warning { + box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); +} + +.pop-glow-danger { + box-shadow: 0 0 15px rgba(255, 51, 51, 0.5); +} + +/* POP 펄스 글로우 애니메이션 */ +@keyframes pop-pulse-glow { + 0%, + 100% { + box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); + } + 50% { + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.4); + } +} + +.pop-animate-pulse-glow { + animation: pop-pulse-glow 2s ease-in-out infinite; +} + +/* POP 프로그레스 바 샤인 애니메이션 */ +@keyframes pop-progress-shine { + 0% { + opacity: 0; + transform: translateX(-20px); + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(20px); + } +} + +.pop-progress-shine::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 20px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); + animation: pop-progress-shine 1.5s ease-in-out infinite; +} + +/* POP 스크롤바 스타일 */ +.pop-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.pop-scrollbar::-webkit-scrollbar-track { + background: rgb(var(--pop-bg-secondary)); +} + +.pop-scrollbar::-webkit-scrollbar-thumb { + background: rgb(var(--pop-border-light)); + border-radius: 9999px; +} + +.pop-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgb(var(--pop-neon-cyan-dim)); +} + +/* POP 스크롤바 숨기기 */ +.pop-hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.pop-hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* ===== Marching Ants Animation (Excel Copy Border) ===== */ +@keyframes marching-ants-h { + 0% { + background-position: 0 0; + } + 100% { + background-position: 16px 0; + } +} + +@keyframes marching-ants-v { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 16px; + } +} + +.animate-marching-ants-h { + background: repeating-linear-gradient( + 90deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 16px 2px; + animation: marching-ants-h 0.4s linear infinite; +} + +.animate-marching-ants-v { + background: repeating-linear-gradient( + 180deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 2px 16px; + animation: marching-ants-v 0.4s linear infinite; +} + /* ===== 저장 테이블 막대기 애니메이션 ===== */ @keyframes saveBarDrop { 0% { diff --git a/frontend/components/admin/multilang/CategoryTree.tsx b/frontend/components/admin/multilang/CategoryTree.tsx new file mode 100644 index 00000000..2e1238cf --- /dev/null +++ b/frontend/components/admin/multilang/CategoryTree.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { LangCategory, getCategories } from "@/lib/api/multilang"; + +interface CategoryTreeProps { + selectedCategoryId: number | null; + onSelectCategory: (category: LangCategory | null) => void; + onDoubleClickCategory?: (category: LangCategory) => void; +} + +interface CategoryNodeProps { + category: LangCategory; + level: number; + selectedCategoryId: number | null; + onSelectCategory: (category: LangCategory) => void; + onDoubleClickCategory?: (category: LangCategory) => void; +} + +function CategoryNode({ + category, + level, + selectedCategoryId, + onSelectCategory, + onDoubleClickCategory, +}: CategoryNodeProps) { + // 기본값: 접힌 상태로 시작 + const [isExpanded, setIsExpanded] = useState(false); + const hasChildren = category.children && category.children.length > 0; + const isSelected = selectedCategoryId === category.categoryId; + + return ( +
+
onSelectCategory(category)} + onDoubleClick={() => onDoubleClickCategory?.(category)} + > + {/* 확장/축소 아이콘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 폴더/태그 아이콘 */} + {hasChildren || level === 0 ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + {/* 카테고리 이름 */} + {category.categoryName} + + {/* prefix 표시 */} + + {category.keyPrefix} + +
+ + {/* 자식 카테고리 */} + {hasChildren && isExpanded && ( +
+ {category.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function CategoryTree({ + selectedCategoryId, + onSelectCategory, + onDoubleClickCategory, +}: CategoryTreeProps) { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadCategories(); + }, []); + + const loadCategories = async () => { + try { + setLoading(true); + const response = await getCategories(); + if (response.success && response.data) { + setCategories(response.data); + } else { + setError(response.error?.details || "카테고리 로드 실패"); + } + } catch (err) { + setError("카테고리 로드 중 오류 발생"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+ 카테고리 로딩 중... +
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (categories.length === 0) { + return ( +
+
+ 카테고리가 없습니다 +
+
+ ); + } + + return ( +
+ {/* 전체 선택 옵션 */} +
onSelectCategory(null)} + > + + 전체 +
+ + {/* 카테고리 트리 */} + {categories.map((category) => ( + + ))} +
+ ); +} + +export default CategoryTree; + + diff --git a/frontend/components/admin/multilang/KeyGenerateModal.tsx b/frontend/components/admin/multilang/KeyGenerateModal.tsx new file mode 100644 index 00000000..c595adbc --- /dev/null +++ b/frontend/components/admin/multilang/KeyGenerateModal.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + LangCategory, + Language, + generateKey, + previewKey, + createOverrideKey, + getLanguages, + getCategoryPath, + KeyPreview, +} from "@/lib/api/multilang"; +import { apiClient } from "@/lib/api/client"; + +interface Company { + companyCode: string; + companyName: string; +} + +interface KeyGenerateModalProps { + isOpen: boolean; + onClose: () => void; + selectedCategory: LangCategory | null; + companyCode: string; + isSuperAdmin: boolean; + onSuccess: () => void; +} + +export function KeyGenerateModal({ + isOpen, + onClose, + selectedCategory, + companyCode, + isSuperAdmin, + onSuccess, +}: KeyGenerateModalProps) { + // 상태 + const [keyMeaning, setKeyMeaning] = useState(""); + const [usageNote, setUsageNote] = useState(""); + const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode); + const [languages, setLanguages] = useState([]); + const [texts, setTexts] = useState>({}); + const [categoryPath, setCategoryPath] = useState([]); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [error, setError] = useState(null); + const [companies, setCompanies] = useState([]); + const [companySearchOpen, setCompanySearchOpen] = useState(false); + + // 초기화 + useEffect(() => { + if (isOpen) { + setKeyMeaning(""); + setUsageNote(""); + setTargetCompanyCode(isSuperAdmin ? "*" : companyCode); + setTexts({}); + setPreview(null); + setError(null); + loadLanguages(); + if (isSuperAdmin) { + loadCompanies(); + } + if (selectedCategory) { + loadCategoryPath(selectedCategory.categoryId); + } else { + setCategoryPath([]); + } + } + }, [isOpen, selectedCategory, companyCode, isSuperAdmin]); + + // 회사 목록 로드 (최고관리자 전용) + const loadCompanies = async () => { + try { + const response = await apiClient.get("/admin/companies"); + if (response.data.success && response.data.data) { + // snake_case를 camelCase로 변환하고 공통(*)은 제외 + const companyList = response.data.data + .filter((c: any) => c.company_code !== "*") + .map((c: any) => ({ + companyCode: c.company_code, + companyName: c.company_name, + })); + setCompanies(companyList); + } + } catch (err) { + console.error("회사 목록 로드 실패:", err); + } + }; + + // 언어 목록 로드 + const loadLanguages = async () => { + const response = await getLanguages(); + if (response.success && response.data) { + const activeLanguages = response.data.filter((l) => l.isActive === "Y"); + setLanguages(activeLanguages); + // 초기 텍스트 상태 설정 + const initialTexts: Record = {}; + activeLanguages.forEach((lang) => { + initialTexts[lang.langCode] = ""; + }); + setTexts(initialTexts); + } + }; + + // 카테고리 경로 로드 + const loadCategoryPath = async (categoryId: number) => { + const response = await getCategoryPath(categoryId); + if (response.success && response.data) { + setCategoryPath(response.data); + } + }; + + // 키 미리보기 (디바운스) + const loadPreview = useCallback(async () => { + if (!selectedCategory || !keyMeaning.trim()) { + setPreview(null); + return; + } + + setPreviewLoading(true); + try { + const response = await previewKey( + selectedCategory.categoryId, + keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"), + targetCompanyCode + ); + if (response.success && response.data) { + setPreview(response.data); + } + } catch (err) { + console.error("키 미리보기 실패:", err); + } finally { + setPreviewLoading(false); + } + }, [selectedCategory, keyMeaning, targetCompanyCode]); + + // keyMeaning 변경 시 디바운스로 미리보기 로드 + useEffect(() => { + const timer = setTimeout(loadPreview, 500); + return () => clearTimeout(timer); + }, [loadPreview]); + + // 텍스트 변경 핸들러 + const handleTextChange = (langCode: string, value: string) => { + setTexts((prev) => ({ ...prev, [langCode]: value })); + }; + + // 저장 핸들러 + const handleSave = async () => { + if (!selectedCategory) { + setError("카테고리를 선택해주세요"); + return; + } + + if (!keyMeaning.trim()) { + setError("키 의미를 입력해주세요"); + return; + } + + // 최소 하나의 텍스트 입력 검증 + const hasText = Object.values(texts).some((t) => t.trim()); + if (!hasText) { + setError("최소 하나의 언어에 대한 텍스트를 입력해주세요"); + return; + } + + setLoading(true); + setError(null); + + try { + // 오버라이드 모드인지 확인 + if (preview?.isOverride && preview.baseKeyId) { + // 오버라이드 키 생성 + const response = await createOverrideKey({ + companyCode: targetCompanyCode, + baseKeyId: preview.baseKeyId, + texts: Object.entries(texts) + .filter(([_, text]) => text.trim()) + .map(([langCode, langText]) => ({ langCode, langText })), + }); + + if (response.success) { + onSuccess(); + onClose(); + } else { + setError(response.error?.details || "오버라이드 키 생성 실패"); + } + } else { + // 새 키 생성 + const response = await generateKey({ + companyCode: targetCompanyCode, + categoryId: selectedCategory.categoryId, + keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"), + usageNote: usageNote.trim() || undefined, + texts: Object.entries(texts) + .filter(([_, text]) => text.trim()) + .map(([langCode, langText]) => ({ langCode, langText })), + }); + + if (response.success) { + onSuccess(); + onClose(); + } else { + setError(response.error?.details || "키 생성 실패"); + } + } + } catch (err: any) { + setError(err.message || "키 생성 중 오류 발생"); + } finally { + setLoading(false); + } + }; + + // 생성될 키 미리보기 + const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim() + ? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".") + : ""; + + return ( + !open && onClose()}> + + + + {preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"} + + + {preview?.isOverride + ? "공통 키에 대한 회사별 오버라이드를 생성합니다" + : "새로운 다국어 키를 자동으로 생성합니다"} + + + +
+ {/* 카테고리 경로 표시 */} +
+ +
+ {categoryPath.length > 0 ? ( + categoryPath.map((cat, idx) => ( + + + {cat.categoryName} + + {idx < categoryPath.length - 1 && ( + / + )} + + )) + ) : ( + + 카테고리를 선택해주세요 + + )} +
+
+ + {/* 키 의미 입력 */} +
+ + setKeyMeaning(e.target.value)} + placeholder="예: add_new_item, search_button, save_success" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 영문 소문자와 밑줄(_)을 사용하세요 +

+
+ + {/* 생성될 키 미리보기 */} + {generatedKeyPreview && ( +
+
+ {previewLoading ? ( + + ) : preview?.exists ? ( + + ) : preview?.isOverride ? ( + + ) : ( + + )} + + {generatedKeyPreview} + +
+ {preview?.exists && ( +

+ 이미 존재하는 키입니다 +

+ )} + {preview?.isOverride && !preview?.exists && ( +

+ 공통 키가 존재합니다. 회사별 오버라이드로 생성됩니다. +

+ )} +
+ )} + + {/* 대상 회사 선택 (최고 관리자만) */} + {isSuperAdmin && ( +
+ +
+ + + + + + + + + + 검색 결과가 없습니다 + + + { + setTargetCompanyCode("*"); + setCompanySearchOpen(false); + }} + className="text-xs sm:text-sm" + > + + 공통 (*) - 모든 회사 적용 + + {companies.map((company) => ( + { + setTargetCompanyCode(company.companyCode); + setCompanySearchOpen(false); + }} + className="text-xs sm:text-sm" + > + + {company.companyName} ({company.companyCode}) + + ))} + + + + + +
+
+ )} + + {/* 사용 메모 */} +
+ +