Compare commits

..

No commits in common. "41e4fb89e8cadd7677a8073788ea9056b7651e6c" and "025c28bdbe8d24a2e9d34f4d803053dfe7036a15" have entirely different histories.

599 changed files with 8952 additions and 117771 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,279 @@
# inputType 사용 가이드
## 핵심 원칙
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
---
## 올바른 사용법
### ✅ inputType 사용 (권장)
```typescript
// 카테고리 타입 체크
if (columnMeta.inputType === "category") {
// 카테고리 처리 로직
}
// 코드 타입 체크
if (meta.inputType === "code") {
// 코드 처리 로직
}
// 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
```
### ❌ webType 사용 (금지)
```typescript
// ❌ 절대 사용 금지!
if (columnMeta.webType === "category") { ... }
// ❌ 이것도 금지!
const categoryColumns = columns.filter(col => col.webType === "category");
```
---
## API에서 inputType 가져오기
### Backend API
```typescript
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
// inputType 맵 생성
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
```
### columnMeta 구조
```typescript
interface ColumnMeta {
webType?: string; // 레거시, 사용 금지
codeCategory?: string;
inputType?: string; // ✅ 반드시 이것 사용!
}
const columnMeta: Record<string, ColumnMeta> = {
material: {
webType: "category", // 무시
codeCategory: "",
inputType: "category", // ✅ 이것만 사용
},
};
```
---
## 캐시 사용 시 주의사항
### ❌ 잘못된 캐시 처리 (inputType 누락)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
// ❌ inputType 누락!
};
});
}
```
### ✅ 올바른 캐시 처리 (inputType 포함)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
}
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
};
});
}
```
---
## 주요 inputType 종류
| inputType | 설명 | 사용 예시 |
| ---------- | ---------------- | ------------------ |
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
| `number` | 숫자 입력 | 금액, 수량 등 |
| `date` | 날짜 입력 | 생성일, 수정일 등 |
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
| `category` | 카테고리 선택 | 분류, 상태 등 |
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
| `boolean` | 예/아니오 | 활성화 여부 등 |
| `email` | 이메일 입력 | 이메일 주소 |
| `url` | URL 입력 | 웹사이트 주소 |
| `image` | 이미지 업로드 | 프로필 사진 등 |
| `file` | 파일 업로드 | 첨부파일 등 |
---
## 실제 적용 사례
### 1. TableListComponent - 카테고리 매핑 로드
```typescript
// ✅ inputType으로 카테고리 컬럼 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
// 각 카테고리 컬럼의 값 목록 조회
for (const columnName of categoryColumns) {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
);
// 매핑 처리...
}
```
### 2. InteractiveDataTable - 셀 값 렌더링
```typescript
// ✅ inputType으로 렌더링 분기
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
// 카테고리 배지 렌더링
return <Badge>{categoryLabel}</Badge>;
case "code":
// 코드명 표시
return codeName;
case "date":
// 날짜 포맷팅
return formatDate(value);
default:
return value;
}
```
### 3. 검색 필터 생성
```typescript
// ✅ inputType에 따라 다른 검색 UI 제공
const renderSearchInput = (column: ColumnConfig) => {
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
return <CategorySelect column={column} />;
case "code":
return <CodeSelect column={column} />;
case "date":
return <DateRangePicker column={column} />;
case "number":
return <NumberRangeInput column={column} />;
default:
return <TextInput column={column} />;
}
};
```
---
## 마이그레이션 체크리스트
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
- [ ] `webType` 참조를 모두 `inputType`으로 변경
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
- [ ] 타입 정의에서 `inputType` 필드 포함
- [ ] 조건문에서 `inputType` 체크로 변경
- [ ] 테스트 실행하여 정상 동작 확인
---
## 디버깅 팁
### inputType이 undefined인 경우
```typescript
// 디버깅 로그 추가
console.log("columnMeta:", columnMeta);
console.log("inputType:", columnMeta[columnName]?.inputType);
// 체크 포인트:
// 1. getColumnInputTypes() 호출 확인
// 2. inputTypeMap 생성 확인
// 3. meta 객체에 inputType 할당 확인
// 4. 캐시 사용 시 cached.inputTypes 확인
```
### webType만 있고 inputType이 없는 경우
```typescript
// ❌ 잘못된 데이터 구조
{
material: {
webType: "category",
codeCategory: "",
// inputType 누락!
}
}
// ✅ 올바른 데이터 구조
{
material: {
webType: "category", // 레거시, 무시됨
codeCategory: "",
inputType: "category" // ✅ 필수!
}
}
```
---
## 참고 자료
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
- **타입 정의**: `/frontend/types/table.ts`
---
## 요약
1. **항상 `inputType` 사용**, `webType` 사용 금지
2. **API에서 `getColumnInputTypes()` 호출** 필수
3. **캐시 사용 시 `inputTypes` 포함** 확인
4. **디버깅 시 `inputType` 값 확인**
5. **기존 코드 마이그레이션** 시 체크리스트 활용

View File

@ -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 (
<div>
<h2>{displayTitle}</h2>
<table>
<thead>
<tr>
{translatedColumns?.map((col, idx) => (
<th key={idx}>{col.displayLabel}</th>
))}
</tr>
</thead>
</table>
<button>{buttonText}</button>
</div>
);
};
```
### 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<string> => {
const keys = new Set<string>();
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<string> => {
const tableNames = new Set<string>();
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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트

View File

@ -20,7 +20,7 @@ CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),b
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),

View File

@ -1,48 +1,5 @@
# Cursor Rules for ERP-node Project
## 🚨 비즈니스 로직 요청 양식 검증 (필수)
**사용자가 화면 개발 또는 비즈니스 로직 구현을 요청할 때, 아래 양식을 따르지 않으면 반드시 다음과 같이 응답하세요:**
```
안녕하세요. Oh My Master! 양식을 못 알아 듣겠습니다.
다시 한번 작성해주십쇼.
=== 비즈니스 로직 요청서 ===
【화면 정보】
- 화면명:
- 회사코드:
- 메뉴ID (있으면):
【테이블 정보】
- 메인 테이블:
- 디테일 테이블 (있으면):
- 관계 FK (있으면):
【버튼 목록】
버튼1:
- 버튼명:
- 동작 유형: (저장/삭제/수정/조회/기타)
- 조건 (있으면):
- 대상 테이블:
- 추가 동작 (있으면):
【추가 요구사항】
-
```
**양식 미준수 판단 기준:**
1. "화면 만들어줘" 같이 테이블명/버튼 정보 없이 요청
2. "저장하면 저장해줘" 같이 구체적인 테이블/로직 설명 없음
3. "이전이랑 비슷하게" 같이 모호한 참조
4. 버튼별 조건/동작이 명시되지 않음
**양식 미준수 시 절대 작업 진행하지 말고, 위 양식을 보여주며 다시 작성하라고 요청하세요.**
**상세 가이드**: [화면개발_표준_가이드.md](docs/screen-implementation-guide/화면개발_표준_가이드.md)
---
## 🚨 최우선 보안 규칙: 멀티테넌시
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**

193
PLAN.MD
View File

@ -1,139 +1,104 @@
# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요
레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
1. [x] 레거시 컴포넌트 스키마 제거
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
3. [x] V2 컴포넌트 overrides 스키마 정의 (9개)
4. [x] componentConfig.ts 한 파일에서 통합 관리
### 1. 단일 화면 복제
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
## 정의된 V2 컴포넌트 (18개)
### 2. 그룹(폴더) 전체 복제
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] 정렬 순서(display_order) 유지
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
- v2-table-list, v2-button-primary, v2-text-display
- v2-split-panel-layout, v2-section-card, v2-section-paper
- v2-divider-line, v2-repeat-container, v2-rack-structure
- v2-numbering-rule, v2-category-manager, v2-pivot-grid
- v2-location-swap-selector, v2-aggregation-widget
- v2-card-display, v2-table-search-widget, v2-tabs-widget
- v2-v2-repeater
### 3. 고급 옵션: 이름 일괄 변경
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
- [x] 미리보기 기능
## 정의된 V2 컴포넌트 (9개)
### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
- v2-input, v2-select, v2-date
- v2-list, v2-layout, v2-group
- v2-media, v2-biz, v2-hierarchy
### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
## 테스트 계획
### 6. 테이블 설정 기능 (TableSettingModal)
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 1단계: 기본 기능
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
- [x] V2 컴포넌트 기본값과 스키마가 매칭됨
### 2단계: 에러 케이스
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
## 에러 처리 계획
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
- 기본값 누락 시 안전한 fallback 적용
## 진행 상태
- [x] 레거시 컴포넌트 제거 완료
- [x] V2/V2 스키마 정의 완료
- [x] 한 파일 통합 관리 완료
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
## 개요
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다.
## 핵심 변경사항
### DB 구조 변경 (완료)
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
- 복제 순서 의존성 문제 해결
### 복제 옵션 정리 (완료)
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션
- [x] **삭제**: 연쇄관계 설정 복사 옵션
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사"
### 현재 복제 옵션 (3개)
1. **채번 규칙 복사** - 채번규칙 복제
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values)
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
---
## 테스트 계획
### 1. 화면 간 연결 복제 테스트
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제
- [ ] 복제 후 연결 관계가 유지되는지 확인
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인
### 2. 제어관리 복제 테스트
- [ ] 다른 회사로 제어관리 복제
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인
### 3. 추가 옵션 복제 테스트
- [ ] 채번규칙 복사 정상 작동 확인
- [ ] 카테고리 값 복사 정상 작동 확인
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
### 4. 기본 복제 테스트
- [ ] 단일 화면 복제 (모달 포함)
- [ ] 그룹 전체 복제 (재귀적)
- [ ] 메뉴 동기화 정상 작동
---
### 7. 회사 코드 지원 (최고 관리자)
- [x] 대상 회사 선택 가능
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스
- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
- `frontend/lib/api/screen.ts` - 화면 API
- `frontend/lib/api/screenGroup.ts` - 그룹 API
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
## 진행 상태
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경)
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가)
- [대기] 화면 간 연결 복제 테스트
- [대기] 제어관리 복제 테스트
- [대기] 추가 옵션 복제 테스트
- [완료] 단일 화면 복제 + 새로고침
- [완료] 그룹 전체 복제 (재귀적)
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [완료] 화면 수정 (이름/그룹/역할/순서)
- [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
---
## 수정 이력
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정
## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음
## 핵심 기능
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
2. **백엔드 로직 개선**:
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제
## 테스트 계획
### 1단계: 기본 기능 및 DB 마이그레이션
- [x] DB 마이그레이션 스크립트 작성 및 실행
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
**수정 파일**: `backend-node/src/services/screenManagementService.ts`
### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가
- 쿼리에 `targetScreenId` 검색 조건 추가
- 문자열/숫자 타입 모두 처리
### 3단계: 프론트엔드 구현
- [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태
- [완료] 모든 단계 구현 완료

View File

@ -2,7 +2,7 @@
## 1. 개요
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(V2 Components)**로 재편합니다.
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
### 현재 컴포넌트 현황 (AS-IS)
@ -24,11 +24,11 @@
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. V2 Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
| **2. V2 Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
| **3. V2 Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
| **4. V2 Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
| **5. V2 Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
@ -36,10 +36,10 @@
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
| **6. V2 List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
| **7. V2 Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
| **8. V2 Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
| **9. V2 Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
### C. Config Panel 통합 전략 (핵심)
@ -60,16 +60,16 @@
### Case 1: "테이블을 카드 리스트로 변경"
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
- **TO-BE**: `V2List`의 속성창에서 **[View Mode]**를 `Table``Card`로 변경하면 즉시 반영.
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table``Card`로 변경하면 즉시 반영.
### Case 2: "단일 선택을 라디오 버튼으로 변경"
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
- **TO-BE**: `V2Select` 속성창에서 **[Display Mode]**를 `Dropdown``Radio`로 변경.
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown``Radio`로 변경.
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
- **TO-BE**: `V2List` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
---
@ -80,7 +80,7 @@
통합 작업 전 필수 분석 및 설계를 진행합니다.
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `V2Widget.type` 매핑 정의)
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
- [ ] `sys_input_type` 테이블 JSON Schema 설계
- [ ] DynamicConfigPanel 프로토타입 설계
@ -88,9 +88,9 @@
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
- [ ] **V2Input 구현**: Text, Number, Email, Tel, Password 통합
- [ ] **V2Select 구현**: Select, Radio, Checkbox, Boolean 통합
- [ ] **V2Date 구현**: Date, DateTime, Time 통합
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
### Phase 2: Config Panel 통합 (2주)
@ -105,15 +105,15 @@
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
- [ ] **V2List 구현**: Table, Card, Repeater 통합 렌더러 개발
- [ ] **V2Layout 구현**: Split Panel, Grid, Flex 통합
- [ ] **V2Group 구현**: Tab, Accordion, Modal 통합
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
### Phase 4: 안정화 및 마이그레이션 (2주)
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
- [ ] 신규 화면은 V2 컴포넌트만 사용하도록 가이드
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
- [ ] 마이그레이션 테스트 (스테이징 환경)
- [ ] 문서화 및 개발 가이드 작성
@ -122,7 +122,7 @@
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
- [ ] 사용 현황 재분석 (V2 전환율 확인)
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
- [ ] 미전환 화면 목록 정리
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
@ -132,27 +132,27 @@
### 5.1 위젯 타입 매핑 테이블
기존 `widgetType`을 신규 V2 컴포넌트로 매핑합니다.
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
| :-------------- | :------------ | :------------------------------ |
| `text` | V2Input | `type: "text"` |
| `number` | V2Input | `type: "number"` |
| `email` | V2Input | `type: "text", format: "email"` |
| `tel` | V2Input | `type: "text", format: "tel"` |
| `select` | V2Select | `mode: "dropdown"` |
| `radio` | V2Select | `mode: "radio"` |
| `checkbox` | V2Select | `mode: "check"` |
| `date` | V2Date | `type: "date"` |
| `datetime` | V2Date | `type: "datetime"` |
| `textarea` | V2Text | `mode: "simple"` |
| `file` | V2Media | `type: "file"` |
| `image` | V2Media | `type: "image"` |
| `text` | UnifiedInput | `type: "text"` |
| `number` | UnifiedInput | `type: "number"` |
| `email` | UnifiedInput | `type: "text", format: "email"` |
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
| `select` | UnifiedSelect | `mode: "dropdown"` |
| `radio` | UnifiedSelect | `mode: "radio"` |
| `checkbox` | UnifiedSelect | `mode: "check"` |
| `date` | UnifiedDate | `type: "date"` |
| `datetime` | UnifiedDate | `type: "datetime"` |
| `textarea` | UnifiedText | `mode: "simple"` |
| `file` | UnifiedMedia | `type: "file"` |
| `image` | UnifiedMedia | `type: "image"` |
### 5.2 마이그레이션 원칙
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `v2Type` 필드 추가
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
---
@ -183,7 +183,7 @@
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
#### V2Input으로 통합 (4개)
#### UnifiedInput으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :------------- |
@ -192,7 +192,7 @@
| slider-basic | `type: "slider"` | 속성 추가 필요 |
| button-primary | `type: "button"` | 별도 검토 |
#### V2Select로 통합 (8개)
#### UnifiedSelect로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------------ | :----------------------------------- | :------------- |
@ -205,19 +205,19 @@
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
| location-swap-selector | `mode: "swap"` | 특수 UI |
#### V2Date로 통합 (1개)
#### UnifiedDate로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------- | :--- |
| date-input | `type: "date"` | |
#### V2Text로 통합 (1개)
#### UnifiedText로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :--- |
| textarea-basic | `mode: "simple"` | |
#### V2Media로 통합 (3개)
#### UnifiedMedia로 통합 (3개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------------------------ | :--- |
@ -225,7 +225,7 @@
| image-widget | `type: "image"` | |
| image-display | `type: "image", readonly: true` | |
#### V2List로 통합 (8개)
#### UnifiedList로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------------------ | :------------ |
@ -238,7 +238,7 @@
| table-search-widget | `viewMode: "table", searchable: true` | |
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
#### V2Layout으로 통합 (4개)
#### UnifiedLayout으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------ | :-------------------------- | :------------- |
@ -247,7 +247,7 @@
| divider-line | `type: "divider"` | 속성 추가 필요 |
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
#### V2Group으로 통합 (5개)
#### UnifiedGroup으로 통합 (5개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------- | :--------------------- | :------------ |
@ -257,7 +257,7 @@
| section-card | `type: "card-section"` | |
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
#### V2Biz로 통합 (7개)
#### UnifiedBiz로 통합 (7개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------ | :--------------- |
@ -274,8 +274,8 @@
| 현재 컴포넌트 | 문제점 | 제안 |
| :-------------------------- | :------------------- | :------------------------------ |
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
| selected-items-detail-input | 복합 (선택+상세입력) | V2List + V2Group 조합 |
| text-display | 읽기 전용 텍스트 | V2Input (readonly: true) |
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
### 8.2 매핑 분석 결과
@ -291,7 +291,7 @@
### 8.3 속성 확장 필요 사항
#### V2Input 속성 확장
#### UnifiedInput 속성 확장
```typescript
// 기존
@ -301,7 +301,7 @@ type: "text" | "number" | "password";
type: "text" | "number" | "password" | "slider" | "color" | "button";
```
#### V2Select 속성 확장
#### UnifiedSelect 속성 확장
```typescript
// 기존
@ -311,7 +311,7 @@ mode: "dropdown" | "radio" | "check" | "tag";
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
```
#### V2Layout 속성 확장
#### UnifiedLayout 속성 확장
```typescript
// 기존
@ -326,8 +326,8 @@ type: "grid" | "split" | "flex" | "divider" | "screen-embed";
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
```typescript
// 모든 V2 컴포넌트에 적용 가능한 공통 속성
interface BaseV2Props {
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
interface BaseUnifiedProps {
// ... 기존 속성
/** 조건부 렌더링 설정 */
@ -356,12 +356,12 @@ DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
| **TREE** | 일반 트리 | 카테고리 |
### 9.2 통합 방안: V2Hierarchy 신설 (10번째 컴포넌트)
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
```typescript
interface V2HierarchyProps {
interface UnifiedHierarchyProps {
/** 계층 유형 */
type: "tree" | "org" | "bom" | "cascading";
@ -400,16 +400,16 @@ interface V2HierarchyProps {
| # | 컴포넌트 | 역할 | 커버 범위 |
| :-: | :------------------- | :------------- | :----------------------------------- |
| 1 | **V2Input** | 단일 값 입력 | text, number, slider, button 등 |
| 2 | **V2Select** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
| 3 | **V2Date** | 날짜/시간 입력 | date, datetime, time, range |
| 4 | **V2Text** | 다중 행 텍스트 | textarea, rich editor, markdown |
| 5 | **V2Media** | 파일/미디어 | file, image, video, audio |
| 6 | **V2List** | 데이터 목록 | table, card, repeater, kanban |
| 7 | **V2Layout** | 레이아웃 배치 | grid, split, flex, divider |
| 8 | **V2Group** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
| 9 | **V2Biz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
| 10 | **V2Hierarchy** | 계층 구조 | tree, org, bom, cascading |
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
---
@ -443,14 +443,14 @@ interface V2HierarchyProps {
### 11.3 속성 통합 설계
#### 2단계 연쇄 → V2Select 속성
#### 2단계 연쇄 → UnifiedSelect 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
// TO-BE: 컴포넌트 속성에서 직접 정의
<V2Select
<UnifiedSelect
source="db"
table="warehouse_location"
valueColumn="location_code"
@ -470,7 +470,7 @@ interface V2HierarchyProps {
// cascading_condition 테이블에 저장
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
<V2Input
<UnifiedInput
conditional={{
enabled: true,
field: "order_type", // 참조할 필드
@ -487,7 +487,7 @@ interface V2HierarchyProps {
// AS-IS: cascading_auto_fill_group 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<V2Input
<UnifiedInput
autoFill={{
enabled: true,
sourceTable: "company_mng", // 조회할 테이블
@ -504,7 +504,7 @@ interface V2HierarchyProps {
// AS-IS: cascading_mutual_exclusion 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<V2Select
<UnifiedSelect
mutualExclusion={{
enabled: true,
targetField: "sub_category", // 상호 배제 대상 필드
@ -518,7 +518,7 @@ interface V2HierarchyProps {
| 현재 메뉴 | TO-BE | 비고 |
| :-------------------------- | :----------------------- | :-------------------- |
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
| ├─ 2단계 연쇄관계 | V2Select 속성 | inline 정의 |
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
@ -557,21 +557,21 @@ interface V2HierarchyProps {
| # | 컴포넌트 | 역할 |
| :-: | :------------------- | :--------------------------------------- |
| 1 | **V2Input** | 단일 값 입력 (text, number, slider 등) |
| 2 | **V2Select** | 선택 입력 (dropdown, radio, checkbox 등) |
| 3 | **V2Date** | 날짜/시간 입력 |
| 4 | **V2Text** | 다중 행 텍스트 (textarea, rich editor) |
| 5 | **V2Media** | 파일/미디어 (file, image) |
| 6 | **V2List** | 데이터 목록 (table, card, repeater) |
| 7 | **V2Layout** | 레이아웃 배치 (grid, split, flex) |
| 8 | **V2Group** | 콘텐츠 그룹화 (tabs, accordion, section) |
| 9 | **V2Biz** | 비즈니스 특화 (flow, rack, map 등) |
| 10 | **V2Hierarchy** | 계층 구조 (tree, org, bom, cascading) |
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
| 3 | **UnifiedDate** | 날짜/시간 입력 |
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
### 12.2 공통 속성 (모든 컴포넌트에 적용)
```typescript
interface BaseV2Props {
interface BaseUnifiedProps {
// 기본 속성
id: string;
label?: string;
@ -614,10 +614,10 @@ interface BaseV2Props {
}
```
### 12.3 V2Select 전용 속성
### 12.3 UnifiedSelect 전용 속성
```typescript
interface V2SelectProps extends BaseV2Props {
interface UnifiedSelectProps extends BaseUnifiedProps {
// 표시 모드
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
@ -660,11 +660,11 @@ interface V2SelectProps extends BaseV2Props {
| AS-IS | TO-BE |
| :---------------------------- | :----------------------------------- |
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
| - 2단계 연쇄관계 | → V2Select.cascading 속성 |
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
| - 조건부 필터 | → 공통 conditional 속성 |
| - 자동 입력 | → 공통 autoFill 속성 |
| - 상호 배제 | → V2Select.mutualExclusion 속성 |
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
---

View File

@ -1044,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2371,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3474,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3710,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3927,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4453,6 +4458,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -5663,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7425,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8394,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9283,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10133,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10942,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11047,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -10,43 +10,6 @@ import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 프로세스 레벨 예외 처리 (서버 크래시 방지)
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
});
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
logger.error("🔥 Uncaught Exception:", {
message: error.message,
stack: error.stack,
});
// 예외 발생 후에도 서버를 유지하되, 상태가 불안정할 수 있으므로 주의
// 심각한 에러의 경우 graceful shutdown 후 재시작 권장
});
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
process.exit(0);
});
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
import adminRoutes from "./routes/adminRoutes";
@ -101,7 +64,6 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@ -109,7 +71,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
@ -121,7 +83,6 @@ import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -284,7 +245,6 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
@ -293,7 +253,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (V2Select용)
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
@ -302,7 +261,6 @@ app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -244,7 +244,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 검색 조건 처리
if (search && typeof search === "string" && search.trim()) {
// 통합 검색
searchType = "v2";
searchType = "unified";
const searchTerm = search.trim();
whereConditions.push(`(
@ -1461,8 +1461,11 @@ async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
[menuObjid]
);
// 4. numbering_rules: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)
// 새 스키마: table_name + column_name + company_code 기반
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
@ -3401,7 +3404,7 @@ export const resetUserPassword = async (
/**
* ( )
* table_type_columns
* column_labels
*/
export async function getTableSchema(
req: AuthenticatedRequest,
@ -3421,7 +3424,7 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = `
SELECT
ic.column_name,
@ -3431,16 +3434,15 @@ export async function getTableSchema(
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
ttc.column_label,
ttc.display_order
cl.column_label,
cl.display_order
FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc
ON ttc.table_name = ic.table_name
AND ttc.column_name = ic.column_name
AND ttc.company_code = '*'
LEFT JOIN column_labels cl
ON cl.table_name = ic.table_name
AND cl.column_name = ic.column_name
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName]);

View File

@ -759,45 +759,3 @@ export async function getAllRelationships(
});
}
}
/**
* (- )
*/
export async function getJoinRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { mainTable, detailTable } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!mainTable || !detailTable) {
res.status(400).json({
success: false,
message: "메인 테이블과 디테일 테이블이 필요합니다.",
});
return;
}
// DataflowService에서 조인 관계 조회
const { DataflowService } = await import("../services/dataflowService");
const dataflowService = new DataflowService();
const result = await dataflowService.getJoinRelationshipBetweenTables(
mainTable,
detailTable,
companyCode
);
res.json({
success: true,
data: result,
});
} catch (error) {
logger.error("조인 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "조인 관계 조회 실패",
});
}
}

View File

@ -1,266 +0,0 @@
/**
* ()
*/
import { Router, Request, Response } from "express";
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
import { logger } from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 인증된 사용자 타입
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
};
}
/**
* ( . )
* GET /api/category-tree/test/all-category-keys
* 주의: /test/:tableName/:columnName
*/
router.get("/test/all-category-keys", async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const keys = await categoryTreeService.getAllCategoryKeys(companyCode);
res.json({
success: true,
data: keys,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* GET /api/category-tree/test/:tableName/:columnName
*/
router.get("/test/:tableName/:columnName", async (req: AuthenticatedRequest, res: Response) => {
try {
const { tableName, columnName } = req.params;
const companyCode = req.user?.companyCode || "*";
const tree = await categoryTreeService.getCategoryTree(companyCode, tableName, columnName);
res.json({
success: true,
data: tree,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 트리 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
* ( )
* GET /api/category-tree/test/:tableName/:columnName/flat
*/
router.get("/test/:tableName/:columnName/flat", async (req: AuthenticatedRequest, res: Response) => {
try {
const { tableName, columnName } = req.params;
const companyCode = req.user?.companyCode || "*";
const list = await categoryTreeService.getCategoryList(companyCode, tableName, columnName);
res.json({
success: true,
data: list,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 목록 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* GET /api/category-tree/test/value/:valueId
*/
router.get("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
try {
const { valueId } = req.params;
const companyCode = req.user?.companyCode || "*";
const value = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
if (!value) {
return res.status(404).json({
success: false,
error: "카테고리 값을 찾을 수 없습니다",
});
}
res.json({
success: true,
data: value,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* POST /api/category-tree/test/value
*/
router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
try {
const input: CreateCategoryValueInput = req.body;
const userCompanyCode = req.user?.companyCode || "*";
const createdBy = req.user?.userId;
// 🔧 최고 관리자가 특정 회사를 선택한 경우, targetCompanyCode 우선 사용
// 단, 최고 관리자(companyCode = '*')만 다른 회사 코드 사용 가능
let companyCode = userCompanyCode;
if (input.targetCompanyCode && userCompanyCode === "*") {
companyCode = input.targetCompanyCode;
logger.info("🔓 최고 관리자 회사 코드 오버라이드 (카테고리 값 생성)", {
originalCompanyCode: userCompanyCode,
targetCompanyCode: input.targetCompanyCode,
});
}
if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) {
return res.status(400).json({
success: false,
error: "tableName, columnName, valueCode, valueLabel은 필수입니다",
});
}
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
res.json({
success: true,
data: value,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 생성 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* PUT /api/category-tree/test/value/:valueId
*/
router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
try {
const { valueId } = req.params;
const input: UpdateCategoryValueInput = req.body;
const companyCode = req.user?.companyCode || "*";
const updatedBy = req.user?.userId;
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
if (!value) {
return res.status(404).json({
success: false,
error: "카테고리 값을 찾을 수 없습니다",
});
}
res.json({
success: true,
data: value,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 수정 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* DELETE /api/category-tree/test/value/:valueId
*/
router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
try {
const { valueId } = req.params;
const companyCode = req.user?.companyCode || "*";
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
if (!success) {
return res.status(404).json({
success: false,
error: "카테고리 값을 찾을 수 없습니다",
});
}
res.json({
success: true,
message: "삭제되었습니다",
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 삭제 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
*
* GET /api/category-tree/test/columns/:tableName
*/
router.get("/test/columns/:tableName", async (req: AuthenticatedRequest, res: Response) => {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
const columns = await categoryTreeService.getCategoryColumns(companyCode, tableName);
res.json({
success: true,
data: columns,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 컬럼 목록 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
export default router;

View File

@ -412,13 +412,7 @@ export class EntityJoinController {
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
// 1. 현재 테이블의 Entity 조인 설정 조회
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
const joinConfigs = allJoinConfigs.filter(
(config) => config.referenceTable !== "table_column_category_values"
);
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length === 0) {
res.status(200).json({
@ -455,7 +449,6 @@ export class EntityJoinController {
columnName: col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType,
inputType: col.inputType || "text",
isNullable: true, // 기본값으로 설정
maxLength: undefined, // 정보가 없으므로 undefined
description: col.displayName,
@ -484,7 +477,6 @@ export class EntityJoinController {
columnName: string;
columnLabel: string;
dataType: string;
inputType: string;
joinAlias: string;
suggestedLabel: string;
}> = [];
@ -499,7 +491,6 @@ export class EntityJoinController {
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType,
inputType: col.inputType || "text",
joinAlias,
suggestedLabel,
});

View File

@ -36,10 +36,10 @@ export class EntityReferenceController {
search,
});
// 컬럼 정보 조회 (table_type_columns에서)
// 컬럼 정보 조회
const columnInfo = await queryOne<any>(
`SELECT * FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'
`SELECT * FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
@ -51,15 +51,15 @@ export class EntityReferenceController {
});
}
// inputType 확인
if (columnInfo.input_type !== "entity") {
// webType 확인
if (columnInfo.web_type !== "entity") {
return res.status(400).json({
success: false,
message: `컬럼 '${tableName}.${columnName}'은 entity 타입이 아닙니다. inputType: ${columnInfo.input_type}`,
message: `컬럼 '${tableName}.${columnName}'은 entity 타입이 아닙니다. webType: ${columnInfo.web_type}`,
});
}
// table_type_columns에서 직접 참조 정보 가져오기
// column_labels에서 직접 참조 정보 가져오기
const referenceTable = columnInfo.reference_table;
const referenceColumn = columnInfo.reference_column;
const displayColumn = columnInfo.display_column || "name";
@ -68,7 +68,7 @@ export class EntityReferenceController {
if (!referenceTable || !referenceColumn) {
return res.status(400).json({
success: false,
message: `Entity 타입 컬럼 '${tableName}.${columnName}'에 참조 테이블 정보가 설정되지 않았습니다. table_type_columns에서 reference_table과 reference_column을 확인해주세요.`,
message: `Entity 타입 컬럼 '${tableName}.${columnName}'에 참조 테이블 정보가 설정되지 않았습니다. column_labels에서 reference_table과 reference_column을 확인해주세요.`,
});
}
@ -85,7 +85,7 @@ export class EntityReferenceController {
);
return res.status(400).json({
success: false,
message: `참조 테이블 '${referenceTable}'이 존재하지 않습니다. table_type_columns의 reference_table 설정을 확인해주세요.`,
message: `참조 테이블 '${referenceTable}'이 존재하지 않습니다. column_labels의 reference_table 설정을 확인해주세요.`,
});
}

View File

@ -3,202 +3,6 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*
* DISTINCT
*/
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try {
const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
// 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다.",
});
}
if (!columnName || columnName === "undefined" || columnName === "null") {
return res.status(400).json({
success: false,
message: "컬럼명이 지정되지 않았습니다.",
});
}
const companyCode = req.user!.companyCode;
const pool = getPool();
// 테이블의 실제 컬럼 목록 조회
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 요청된 컬럼 검증
if (!existingColumns.has(columnName)) {
return res.status(400).json({
success: false,
message: `테이블 "${tableName}"에 컬럼 "${columnName}"이 존재하지 않습니다.`,
});
}
// 라벨 컬럼 결정 (지정되지 않으면 값 컬럼과 동일)
const effectiveLabelColumn = labelColumn && existingColumns.has(labelColumn as string)
? labelColumn as string
: columnName;
// WHERE 조건 (멀티테넌시)
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*" && existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// NULL 제외
whereConditions.push(`"${columnName}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// DISTINCT 쿼리 실행
const query = `
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
FROM "${tableName}"
${whereClause}
ORDER BY "${effectiveLabelColumn}" ASC
LIMIT 500
`;
const result = await pool.query(query, params);
logger.info("컬럼 DISTINCT 값 조회 성공", {
tableName,
columnName,
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
});
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("컬럼 DISTINCT 값 조회 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message });
}
}
/**
* API (V2Select용)
* GET /api/entity/:tableName/options
*
* Query Params:
* - value: (기본: id)
* - label: 표시 (기본: name)
*/
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name" } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName });
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다.",
});
}
const companyCode = req.user!.companyCode;
const pool = getPool();
// 테이블의 실제 컬럼 목록 조회
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 요청된 컬럼 검증
const valueColumn = existingColumns.has(value as string) ? value : "id";
const labelColumn = existingColumns.has(label as string) ? label : "name";
// 둘 다 없으면 에러
if (!existingColumns.has(valueColumn as string)) {
return res.status(400).json({
success: false,
message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`,
});
}
// label 컬럼이 없으면 value 컬럼을 label로도 사용
const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn;
// WHERE 조건 (멀티테넌시)
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*" && existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행 (최대 500개)
const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
FROM ${tableName}
${whereClause}
ORDER BY ${effectiveLabelColumn} ASC
LIMIT 500
`;
const result = await pool.query(query, params);
logger.info("엔티티 옵션 조회 성공", {
tableName,
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
});
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("엔티티 옵션 조회 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message });
}
}
/**
* API
* GET /api/entity-search/:tableName

View File

@ -627,19 +627,19 @@ export class FlowController {
return;
}
// table_type_columns 테이블에서 라벨 정보 조회
// column_labels 테이블에서 라벨 정보 조회
const { query } = await import("../database/db");
const labelRows = await query<{
column_name: string;
column_label: string | null;
}>(
`SELECT column_name, column_label
FROM table_type_columns
WHERE table_name = $1 AND column_label IS NOT NULL AND company_code = '*'`,
FROM column_labels
WHERE table_name = $1 AND column_label IS NOT NULL`,
[tableName]
);
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
console.log(`✅ [FlowController] column_labels 조회 완료:`, {
tableName,
rowCount: labelRows.length,
labels: labelRows.map((r) => ({

View File

@ -169,22 +169,14 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
const { ruleId } = req.params;
const updates = req.body;
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
try {
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
return res.json({ success: true, data: updatedRule });
} catch (error: any) {
logger.error("채번 규칙 수정 실패", {
ruleId,
companyCode,
error: error.message,
stack: error.stack
});
if (error.message.includes("찾을 수 없거나")) {
return res.status(404).json({ success: false, error: error.message });
}
logger.error("규칙 수정 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
@ -210,10 +202,9 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
logger.error("코드 미리보기 실패", { error: error.message });
@ -225,12 +216,11 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
logger.info("코드 할당 요청", { ruleId, companyCode });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {
@ -267,128 +257,4 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
}
});
// ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid });
try {
const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid);
logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length });
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName);
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테스트 테이블에 채번 규칙 저장
// 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결)
router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const ruleConfig = req.body;
logger.info("[테스트] 채번 규칙 저장 요청", {
ruleId: ruleConfig.ruleId,
ruleName: ruleConfig.ruleName,
tableName: ruleConfig.tableName || "(미지정)",
columnName: ruleConfig.columnName || "(미지정)",
});
try {
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
if (!ruleConfig.ruleName) {
return res.status(400).json({
success: false,
error: "ruleName is required"
});
}
const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId);
return res.json({ success: true, data: savedRule });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테스트 테이블에서 채번 규칙 삭제
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 코드 미리보기 (테스트 테이블 사용)
router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body;
try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// ==================== 회사별 채번규칙 복제 API ====================
// 회사별 채번규칙 복제
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const userCompanyCode = req.user!.companyCode;
const { sourceCompanyCode, targetCompanyCode } = req.body;
// 최고 관리자만 사용 가능
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
error: "최고 관리자만 사용할 수 있습니다"
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다"
});
}
try {
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
return res.json({ success: true, data: result });
} catch (error: any) {
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@ -1,223 +0,0 @@
/**
*
*
* , , API를 .
*/
import { Request, Response } from "express";
import { ScheduleService } from "../services/scheduleService";
export class ScheduleController {
private scheduleService: ScheduleService;
constructor() {
this.scheduleService = new ScheduleService();
}
/**
*
* POST /api/schedule/preview
*
* .
* .
*/
preview = async (req: Request, res: Response): Promise<void> => {
try {
const { config, sourceData, period } = req.body;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] preview 호출:", {
scheduleType: config?.scheduleType,
sourceDataCount: sourceData?.length,
period,
userId,
companyCode,
});
// 필수 파라미터 검증
if (!config || !config.scheduleType) {
res.status(400).json({
success: false,
message: "스케줄 설정(config)이 필요합니다.",
});
return;
}
if (!sourceData || sourceData.length === 0) {
res.status(400).json({
success: false,
message: "소스 데이터가 필요합니다.",
});
return;
}
// 미리보기 생성
const preview = await this.scheduleService.generatePreview(
config,
sourceData,
period,
companyCode
);
res.json({
success: true,
preview,
});
} catch (error: any) {
console.error("[ScheduleController] preview 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 미리보기 중 오류가 발생했습니다.",
});
}
};
/**
*
* POST /api/schedule/apply
*
* .
*/
apply = async (req: Request, res: Response): Promise<void> => {
try {
const { config, preview, options } = req.body;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] apply 호출:", {
scheduleType: config?.scheduleType,
createCount: preview?.summary?.createCount,
deleteCount: preview?.summary?.deleteCount,
options,
userId,
companyCode,
});
// 필수 파라미터 검증
if (!config || !preview) {
res.status(400).json({
success: false,
message: "설정(config)과 미리보기(preview)가 필요합니다.",
});
return;
}
// 적용
const applied = await this.scheduleService.applySchedules(
config,
preview,
options || { deleteExisting: true, updateMode: "replace" },
companyCode,
userId
);
res.json({
success: true,
applied,
message: `${applied.created}건 생성, ${applied.deleted}건 삭제, ${applied.updated}건 수정되었습니다.`,
});
} catch (error: any) {
console.error("[ScheduleController] apply 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 적용 중 오류가 발생했습니다.",
});
}
};
/**
*
* GET /api/schedule/list
*
* .
*/
list = async (req: Request, res: Response): Promise<void> => {
try {
const {
scheduleType,
resourceType,
resourceId,
startDate,
endDate,
status,
} = req.query;
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] list 호출:", {
scheduleType,
resourceType,
resourceId,
startDate,
endDate,
status,
companyCode,
});
const result = await this.scheduleService.getScheduleList({
scheduleType: scheduleType as string,
resourceType: resourceType as string,
resourceId: resourceId as string,
startDate: startDate as string,
endDate: endDate as string,
status: status as string,
companyCode,
});
res.json({
success: true,
data: result.data,
total: result.total,
});
} catch (error: any) {
console.error("[ScheduleController] list 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 조회 중 오류가 발생했습니다.",
});
}
};
/**
*
* DELETE /api/schedule/:scheduleId
*/
delete = async (req: Request, res: Response): Promise<void> => {
try {
const { scheduleId } = req.params;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] delete 호출:", {
scheduleId,
userId,
companyCode,
});
const result = await this.scheduleService.deleteSchedule(
parseInt(scheduleId, 10),
companyCode,
userId
);
if (!result.success) {
res.status(404).json({
success: false,
message: result.message || "스케줄을 찾을 수 없습니다.",
});
return;
}
res.json({
success: true,
message: "스케줄이 삭제되었습니다.",
});
} catch (error: any) {
console.error("[ScheduleController] delete 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 삭제 중 오류가 발생했습니다.",
});
}
};
}

View File

@ -308,108 +308,39 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
await client.query('BEGIN');
// 0. 삭제할 그룹의 company_code 확인
const targetGroupResult = await client.query(
`SELECT company_code FROM screen_groups WHERE id = $1`,
[id]
);
if (targetGroupResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." });
}
const targetCompanyCode = targetGroupResult.rows[0].company_code;
// 권한 체크: 최고관리자가 아닌 경우 자신의 회사 그룹만 삭제 가능
if (companyCode !== "*" && targetCompanyCode !== companyCode) {
await client.query('ROLLBACK');
return res.status(403).json({ success: false, message: "권한이 없습니다." });
}
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (같은 회사만 - CASCADE 삭제 대상)
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상)
const childGroupsResult = await client.query(`
WITH RECURSIVE child_groups AS (
SELECT id, company_code FROM screen_groups WHERE id = $1 AND company_code = $2
SELECT id FROM screen_groups WHERE id = $1
UNION ALL
SELECT sg.id, sg.company_code FROM screen_groups sg
JOIN child_groups cg ON sg.parent_group_id = cg.id AND sg.company_code = cg.company_code
SELECT sg.id FROM screen_groups sg
JOIN child_groups cg ON sg.parent_group_id = cg.id
)
SELECT id FROM child_groups
`, [id, targetCompanyCode]);
`, [id]);
const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id);
logger.info("화면 그룹 삭제 대상", {
companyCode,
targetCompanyCode,
groupId: id,
childGroupIds: groupIdsToDelete
});
// 2. 삭제될 그룹에 연결된 메뉴 정리
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
if (groupIdsToDelete.length > 0) {
// 2-1. 삭제할 메뉴 objid 수집
const menusToDelete = await client.query(`
SELECT objid FROM menu_info
await client.query(`
UPDATE menu_info
SET screen_group_id = NULL
WHERE screen_group_id = ANY($1::int[])
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
const menuObjids = menusToDelete.rows.map((r: any) => r.objid);
if (menuObjids.length > 0) {
// 2-2. screen_menu_assignments에서 해당 메뉴 관련 데이터 삭제
await client.query(`
DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1::bigint[])
AND company_code = $2
`, [menuObjids, targetCompanyCode]);
// 2-3. menu_info에서 해당 메뉴 삭제
await client.query(`
DELETE FROM menu_info
WHERE screen_group_id = ANY($1::int[])
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
logger.info("그룹 삭제 시 연결된 메뉴 삭제", {
groupIds: groupIdsToDelete,
deletedMenuCount: menuObjids.length,
companyCode: targetCompanyCode
});
}
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시)
// 삭제되는 그룹이 최상위인지 확인
const isRootGroup = await client.query(
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
[id]
);
if (isRootGroup.rows.length > 0) {
// 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
);
// 규칙 삭제
const deletedRules = await client.query(
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
[targetCompanyCode]
);
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
logger.info("그룹 삭제 시 채번 규칙 삭제", {
companyCode: targetCompanyCode,
deletedCount: deletedRules.rowCount
});
}
}
`, [groupIdsToDelete]);
}
// 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)
const result = await client.query(
`DELETE FROM screen_groups WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, targetCompanyCode]
);
// 3. screen_groups 삭제
let query = `DELETE FROM screen_groups WHERE id = $1`;
const params: any[] = [id];
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += " RETURNING id";
const result = await client.query(query, params);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
@ -418,7 +349,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
await client.query('COMMIT');
logger.info("화면 그룹 삭제 완료", { companyCode, targetCompanyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
} catch (error: any) {
@ -438,19 +369,14 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
// 그룹에 화면 추가
export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode || "*";
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "";
const { group_id, screen_id, screen_role, display_order, is_default, target_company_code } = req.body;
const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
if (!group_id || !screen_id) {
return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." });
}
// 최고 관리자가 다른 회사로 복제할 때 target_company_code 사용
const effectiveCompanyCode = (userCompanyCode === "*" && target_company_code)
? target_company_code
: userCompanyCode;
const query = `
INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@ -462,13 +388,13 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
screen_role || 'main',
display_order || 0,
is_default || 'N',
effectiveCompanyCode,
companyCode === "*" ? "*" : companyCode,
userId
];
const result = await pool.query(query, params);
logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, groupId: group_id, screenId: screen_id });
logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id });
res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." });
} catch (error: any) {
@ -1379,8 +1305,8 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
if (conditions.length > 0) {
const labelQuery = `
SELECT table_name, column_name, column_label
FROM table_type_columns
WHERE (${conditions.join(' OR ')}) AND company_code = '*'
FROM column_labels
WHERE ${conditions.join(' OR ')}
`;
const labelResult = await pool.query(labelQuery, params);
labelResult.rows.forEach((row: any) => {
@ -1476,7 +1402,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
}
});
// 2. 추가 방식: 화면에서 사용하는 컬럼 중 table_type_columns.reference_table이 설정된 경우
// 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우
// 화면의 usedColumns/joinColumns에서 reference_table 조회
const referenceQuery = `
WITH screen_used_columns AS (
@ -1582,8 +1508,8 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
cl.reference_column,
ref_cl.column_label as target_display_name
FROM screen_used_columns suc
JOIN table_type_columns cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name AND cl.company_code = '*'
LEFT JOIN table_type_columns ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column AND ref_cl.company_code = '*'
JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name
LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column
WHERE cl.reference_table IS NOT NULL
AND cl.reference_table != ''
AND cl.reference_table != suc.main_table
@ -1593,7 +1519,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
const referenceResult = await pool.query(referenceQuery, [screenIds]);
logger.info("table_type_columns reference_table 조회 결과", {
logger.info("column_labels reference_table 조회 결과", {
screenIds,
referenceCount: referenceResult.rows.length,
references: referenceResult.rows.map((r: any) => ({
@ -1873,7 +1799,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
rightPanelCount: rightPanelResult.rows.length
});
// 5. joinedTables에 대한 FK 컬럼을 table_type_columns에서 조회
// 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회
// rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기
const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = [];
Object.values(screenSubTables).forEach((screenData: any) => {
@ -1886,7 +1812,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
});
// table_type_columns에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기)
// column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기)
const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들]
if (joinedTableFKLookups.length > 0) {
const uniqueLookups = joinedTableFKLookups.filter((item, index, self) =>
@ -1905,11 +1831,10 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
cl.reference_table,
cl.reference_column,
tl.table_label as reference_table_label
FROM table_type_columns cl
FROM column_labels cl
LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name
WHERE cl.table_name = ANY($1)
AND cl.reference_table = ANY($2)
AND cl.company_code = '*'
`;
const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]);
@ -1954,7 +1879,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
}
// 5. 모든 fieldMappings의 한글명을 table_type_columns에서 가져와서 적용
// 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용
// 모든 테이블/컬럼 조합을 수집
const columnLookups: Array<{ tableName: string; columnName: string }> = [];
Object.values(screenSubTables).forEach((screenData: any) => {
@ -1979,7 +1904,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName)
);
// table_type_columns에서 한글명 조회
// column_labels에서 한글명 조회
const columnLabelsMap: { [key: string]: string } = {};
if (uniqueColumnLookups.length > 0) {
const columnLabelsQuery = `
@ -1987,11 +1912,10 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
table_name,
column_name,
column_label
FROM table_type_columns
FROM column_labels
WHERE (table_name, column_name) IN (
${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')}
)
AND company_code = '*'
`;
const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]);
@ -2001,9 +1925,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
const key = `${row.table_name}.${row.column_name}`;
columnLabelsMap[key] = row.column_label;
});
logger.info("table_type_columns 한글명 조회 완료", { count: columnLabelsResult.rows.length });
logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length });
} catch (error: any) {
logger.warn("table_type_columns 한글명 조회 실패 (무시하고 계속 진행):", error.message);
logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message);
}
}
@ -2327,169 +2251,3 @@ export const syncAllCompaniesController = async (req: AuthenticatedRequest, res:
}
};
/**
* [PoC] screen_groups
*
* menu_info screen_groups를 API
* - screen_groups를
* - URL
* - menu_objid를
*
* DB
*/
export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode || "*";
const { targetCompanyCode } = req.query;
// 조회할 회사 코드 결정
const companyCode = userCompanyCode === "*" && targetCompanyCode
? String(targetCompanyCode)
: userCompanyCode;
logger.info("[PoC] screen_groups 기반 메뉴 트리 조회", {
userCompanyCode,
targetCompanyCode: companyCode
});
// 1. screen_groups 조회 (계층 구조 포함)
const groupsQuery = `
SELECT
sg.id,
sg.group_name,
sg.group_code,
sg.parent_group_id,
sg.group_level,
sg.display_order,
sg.icon,
sg.is_active,
sg.menu_objid,
sg.company_code,
-- (URL )
(
SELECT json_build_object(
'screen_id', sd.screen_id,
'screen_name', sd.screen_name,
'screen_code', sd.screen_code
)
FROM screen_group_screens sgs
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
ORDER BY
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
sgs.display_order ASC
LIMIT 1
) as default_screen,
--
(
SELECT COUNT(*)
FROM screen_group_screens sgs
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
) as screen_count
FROM screen_groups sg
WHERE sg.company_code = $1
AND (sg.is_active = 'Y' OR sg.is_active IS NULL)
ORDER BY sg.group_level ASC, sg.display_order ASC, sg.group_name ASC
`;
const groupsResult = await pool.query(groupsQuery, [companyCode]);
// 2. 트리 구조로 변환
const groups = groupsResult.rows;
const groupMap = new Map<number, any>();
const rootGroups: any[] = [];
// 먼저 모든 그룹을 Map에 저장
for (const group of groups) {
const menuItem = {
id: group.id,
objid: group.menu_objid || group.id, // 권한 체크용 (menu_objid 우선)
name: group.group_name,
name_kor: group.group_name,
icon: group.icon,
url: group.default_screen
? `/screens/${group.default_screen.screen_id}`
: null,
screen_id: group.default_screen?.screen_id || null,
screen_code: group.default_screen?.screen_code || null,
screen_count: parseInt(group.screen_count) || 0,
parent_id: group.parent_group_id,
level: group.group_level || 0,
display_order: group.display_order || 0,
is_active: group.is_active === 'Y',
menu_objid: group.menu_objid, // 기존 권한 시스템 연결용
children: [],
// menu_info 호환 필드
menu_name_kor: group.group_name,
menu_url: group.default_screen
? `/screens/${group.default_screen.screen_id}`
: null,
parent_obj_id: null, // 나중에 설정
seq: group.display_order || 0,
status: group.is_active === 'Y' ? 'active' : 'inactive',
};
groupMap.set(group.id, menuItem);
}
// 부모-자식 관계 설정
for (const group of groups) {
const menuItem = groupMap.get(group.id);
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
const parent = groupMap.get(group.parent_group_id);
parent.children.push(menuItem);
menuItem.parent_obj_id = parent.objid;
} else {
// 최상위 그룹
rootGroups.push(menuItem);
menuItem.parent_obj_id = "0";
}
}
// 3. 통계 정보
const stats = {
totalGroups: groups.length,
groupsWithScreens: groups.filter(g => g.default_screen).length,
groupsWithMenuObjid: groups.filter(g => g.menu_objid).length,
rootGroups: rootGroups.length,
};
logger.info("[PoC] screen_groups 메뉴 트리 생성 완료", stats);
res.json({
success: true,
message: "[PoC] screen_groups 기반 메뉴 트리",
data: rootGroups,
stats,
// 플랫 리스트도 제공 (기존 menu_info 형식 호환)
flatList: Array.from(groupMap.values()).map(item => ({
objid: String(item.objid),
OBJID: String(item.objid),
menu_name_kor: item.name,
MENU_NAME_KOR: item.name,
menu_url: item.url,
MENU_URL: item.url,
parent_obj_id: String(item.parent_obj_id || "0"),
PARENT_OBJ_ID: String(item.parent_obj_id || "0"),
seq: item.seq,
SEQ: item.seq,
status: item.status,
STATUS: item.status,
menu_type: 1, // 사용자 메뉴
MENU_TYPE: 1,
screen_group_id: item.id,
menu_objid: item.menu_objid,
})),
});
} catch (error: any) {
logger.error("[PoC] screen_groups 메뉴 트리 조회 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 트리 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -674,64 +674,6 @@ export const getLayout = async (req: AuthenticatedRequest, res: Response) => {
}
};
// V1 레이아웃 조회 (component_url + custom_config 기반)
export const getLayoutV1 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayoutV1(
parseInt(screenId),
companyCode
);
res.json({ success: true, data: layout });
} catch (error) {
console.error("V3 레이아웃 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "V3 레이아웃 조회에 실패했습니다." });
}
};
// V2 레이아웃 조회 (1 레코드 방식 - url + overrides)
export const getLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode, userType } = req.user as any;
const layout = await screenManagementService.getLayoutV2(
parseInt(screenId),
companyCode,
userType
);
res.json({ success: true, data: layout });
} catch (error) {
console.error("V2 레이아웃 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "V2 레이아웃 조회에 실패했습니다." });
}
};
// V2 레이아웃 저장 (1 레코드 방식 - url + overrides)
export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layoutData = req.body;
await screenManagementService.saveLayoutV2(
parseInt(screenId),
layoutData,
companyCode
);
res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." });
} catch (error) {
console.error("V2 레이아웃 저장 실패:", error);
res
.status(500)
.json({ success: false, message: "V2 레이아웃 저장에 실패했습니다." });
}
};
// 화면 코드 자동 생성
export const generateScreenCode = async (
req: AuthenticatedRequest,
@ -892,264 +834,3 @@ export const cleanupDeletedScreenMenuAssignments = async (
});
}
};
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
export const updateTabScreenReferences = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { targetScreenIds, screenIdMap } = req.body;
if (!targetScreenIds || !Array.isArray(targetScreenIds)) {
return res.status(400).json({
success: false,
message: "targetScreenIds 배열이 필요합니다.",
});
}
if (!screenIdMap || typeof screenIdMap !== "object") {
return res.status(400).json({
success: false,
message: "screenIdMap 객체가 필요합니다.",
});
}
const result = await screenManagementService.updateTabScreenReferences(
targetScreenIds,
screenIdMap
);
return res.json({
success: true,
message: `${result.updated}개 레이아웃의 탭 참조가 업데이트되었습니다.`,
updated: result.updated,
details: result.details,
});
} catch (error) {
console.error("탭 screenId 참조 업데이트 실패:", error);
return res.status(500).json({
success: false,
message: "탭 screenId 참조 업데이트에 실패했습니다.",
});
}
};
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
export const copyScreenMenuAssignments = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { sourceCompanyCode, targetCompanyCode, screenIdMap } = req.body;
const userCompanyCode = req.user?.companyCode;
// 권한 체크: 최고 관리자만 가능
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
});
}
if (!screenIdMap || typeof screenIdMap !== "object") {
return res.status(400).json({
success: false,
message: "screenIdMap 객체가 필요합니다.",
});
}
const result = await screenManagementService.copyScreenMenuAssignments(
sourceCompanyCode,
targetCompanyCode,
screenIdMap
);
return res.json({
success: true,
message: `화면-메뉴 할당 ${result.copiedCount}개 복제 완료`,
data: result,
});
} catch (error) {
console.error("화면-메뉴 할당 복제 실패:", error);
return res.status(500).json({
success: false,
message: "화면-메뉴 할당 복제에 실패했습니다.",
});
}
};
// 코드 카테고리 + 코드 복제
export const copyCodeCategoryAndCodes = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { sourceCompanyCode, targetCompanyCode } = req.body;
const userCompanyCode = req.user?.companyCode;
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
});
}
const result = await screenManagementService.copyCodeCategoryAndCodes(
sourceCompanyCode,
targetCompanyCode
);
return res.json({
success: true,
message: `코드 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개 복제 완료`,
data: result,
});
} catch (error) {
console.error("코드 카테고리/코드 복제 실패:", error);
return res.status(500).json({
success: false,
message: "코드 카테고리/코드 복제에 실패했습니다.",
});
}
};
// 카테고리 매핑 + 값 복제
export const copyCategoryMapping = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { sourceCompanyCode, targetCompanyCode } = req.body;
const userCompanyCode = req.user?.companyCode;
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
});
}
const result = await screenManagementService.copyCategoryMapping(
sourceCompanyCode,
targetCompanyCode
);
return res.json({
success: true,
message: `카테고리 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개 복제 완료`,
data: result,
});
} catch (error) {
console.error("카테고리 매핑/값 복제 실패:", error);
return res.status(500).json({
success: false,
message: "카테고리 매핑/값 복제에 실패했습니다.",
});
}
};
// 테이블 타입관리 입력타입 설정 복제
export const copyTableTypeColumns = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { sourceCompanyCode, targetCompanyCode } = req.body;
const userCompanyCode = req.user?.companyCode;
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
});
}
const result = await screenManagementService.copyTableTypeColumns(
sourceCompanyCode,
targetCompanyCode
);
return res.json({
success: true,
message: `테이블 타입 컬럼 ${result.copiedCount}개 복제 완료`,
data: result,
});
} catch (error) {
console.error("테이블 타입 컬럼 복제 실패:", error);
return res.status(500).json({
success: false,
message: "테이블 타입 컬럼 복제에 실패했습니다.",
});
}
};
// 연쇄관계 설정 복제
export const copyCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { sourceCompanyCode, targetCompanyCode } = req.body;
const userCompanyCode = req.user?.companyCode;
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
});
}
const result = await screenManagementService.copyCascadingRelation(
sourceCompanyCode,
targetCompanyCode
);
return res.json({
success: true,
message: `연쇄관계 설정 ${result.copiedCount}개 복제 완료`,
data: result,
});
} catch (error) {
console.error("연쇄관계 설정 복제 실패:", error);
return res.status(500).json({
success: false,
message: "연쇄관계 설정 복제에 실패했습니다.",
});
}
};

View File

@ -97,16 +97,11 @@ export async function getColumnList(
}
const tableManagementService = new TableManagementService();
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
const bustCache = !!req.query._t;
const result = await tableManagementService.getColumnList(
tableName,
parseInt(page as string),
parseInt(size as string),
companyCode, // 🔥 회사 코드 전달
bustCache // 🔥 캐시 버스팅 옵션
companyCode // 🔥 회사 코드 전달
);
logger.info(
@ -557,16 +552,7 @@ export async function updateColumnInputType(
): Promise<void> {
try {
const { tableName, columnName } = req.params;
let { inputType, detailSettings } = req.body;
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
inputType = "text";
}
const { inputType, detailSettings } = req.body;
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
@ -671,14 +657,14 @@ export async function getTableRecord(
logger.info(`필터: ${filterColumn} = ${filterValue}`);
logger.info(`표시 컬럼: ${displayColumn}`);
if (!tableName || !filterColumn || !filterValue) {
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
const response: ApiResponse<null> = {
success: false,
message: "필수 파라미터가 누락되었습니다.",
error: {
code: "MISSING_PARAMETERS",
details:
"tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.",
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
},
};
res.status(400).json(response);
@ -710,12 +696,9 @@ export async function getTableRecord(
}
const record = result.data[0];
// displayColumn이 "*"이거나 없으면 전체 레코드 반환
const displayValue = displayColumn && displayColumn !== "*"
? record[displayColumn]
: record;
const displayValue = record[displayColumn];
logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`);
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
const response: ApiResponse<{ value: any; record: any }> = {
success: true,
@ -1369,17 +1352,8 @@ export async function updateColumnWebType(
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
);
// 🔥 inputType이 "direct" 또는 "auto"이면 무시하고 webType 사용
// "direct"/"auto"는 프론트엔드의 입력 방식(직접입력/자동입력) 구분값이지
// DB에 저장할 웹 타입(text, number, date 등)이 아님
let convertedInputType = webType || "text";
if (inputType && inputType !== "direct" && inputType !== "auto") {
convertedInputType = inputType;
}
logger.info(
`웹타입 변환: webType=${webType}, inputType=${inputType}${convertedInputType}`
);
// webType을 inputType으로 변환
const convertedInputType = inputType || webType || "text";
// 새로운 메서드 호출
req.body = { inputType: convertedInputType, detailSettings };
@ -1663,107 +1637,6 @@ export async function toggleLogTable(
}
}
/**
* ( )
*
* @route GET /api/table-management/category-columns
* @description table_type_columns에서 input_type = 'category'
*/
export async function getCategoryColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("📥 회사별 카테고리 컬럼 조회 요청", { companyCode });
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length
});
}
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 회사별 카테고리 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* ( )
*
@ -1792,26 +1665,57 @@ export async function getCategoryColumnsByMenu(
return;
}
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { menuObjid, user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
// category_column_mapping 대신 table_type_columns 기준으로 조회
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 1. category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists
`);
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
let columnsResult;
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") {
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
--
SELECT objid, parent_obj_id, menu_type, menu_name_kor
FROM menu_info
WHERE objid = $1
UNION ALL
--
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
FROM menu_info m
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
WHERE m.parent_obj_id != 0 -- (parent_obj_id=0)
)
SELECT
ARRAY_AGG(objid) as menu_objids,
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 ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@ -1819,28 +1723,67 @@ export async function getCategoryColumnsByMenu(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
ccm.logical_column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
ttc.input_type AS "inputType",
ccm.menu_objid AS "definedAtMenuObjid"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
AND ccm.physical_column_name = ttc.column_name
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
WHERE ccm.company_code = $1
AND ccm.menu_objid = ANY($2)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length
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 });
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
const columnsQuery = `
SELECT DISTINCT
SELECT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
@ -1848,23 +1791,24 @@ export async function getCategoryColumnsByMenu(
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length
});
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
@ -2237,7 +2181,7 @@ export async function multiTableSave(
/**
*
* table_type_columns의 entity/category
* column_labels의 entity/category
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
@ -2262,12 +2206,11 @@ export async function getTableEntityRelations(
table_name,
column_name,
column_label,
input_type as web_type,
web_type,
detail_settings
FROM table_type_columns
FROM column_labels
WHERE table_name IN ($1, $2)
AND input_type IN ('entity', 'category')
AND company_code = '*'
AND web_type IN ('entity', 'category')
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
@ -2337,91 +2280,3 @@ export async function getTableEntityRelations(
});
}
}
/**
* (FK로 )
* GET /api/table-management/columns/:tableName/referenced-by
*
* table_type_columns에서 reference_table이
* FK .
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "tableName 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "tableName 경로 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
const sqlQuery = `
SELECT DISTINCT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.table_name as table_label
FROM table_type_columns ttc
WHERE ttc.reference_table = $1
AND ttc.input_type = 'entity'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
`;
const result = await query(sqlQuery, [tableName]);
const referencedByTables = result.map((row: any) => ({
tableName: row.table_name,
tableLabel: row.table_label,
columnName: row.column_name,
columnLabel: row.column_label,
referenceTable: row.reference_table,
referenceColumn: row.reference_column || "id",
displayColumn: row.display_column,
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
);
const response: ApiResponse<any> = {
success: true,
message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`,
data: referencedByTables,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 참조 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 참조 관계 조회 중 오류가 발생했습니다.",
error: {
code: "REFERENCED_BY_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -81,26 +81,8 @@ export const initializePool = (): Pool => {
pool.on("error", (err, client) => {
console.error("❌ PostgreSQL 연결 풀 에러:", err);
// 연결 풀 에러 발생 시 자동 재연결 시도
// Pool은 자동으로 연결을 재생성하므로 별도 처리 불필요
// 다만, 연속 에러 발생 시 알림이 필요할 수 있음
});
// 연결 풀 상태 체크 (5분마다)
setInterval(() => {
if (pool) {
const status = {
totalCount: pool.totalCount,
idleCount: pool.idleCount,
waitingCount: pool.waitingCount,
};
// 대기 중인 연결이 많으면 경고
if (status.waitingCount > 5) {
console.warn("⚠️ PostgreSQL 연결 풀 대기열 증가:", status);
}
}
}, 5 * 60 * 1000);
console.log(
`🚀 PostgreSQL 연결 풀 초기화 완료: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
);

View File

@ -14,7 +14,6 @@ import {
executeOptimizedButton,
executeSimpleDataflow,
getJobStatus,
getJoinRelationship,
} from "../controllers/buttonDataflowController";
import { authenticateToken } from "../middleware/authMiddleware";
@ -62,13 +61,6 @@ router.post("/execute-simple", executeSimpleDataflow);
// 백그라운드 작업 상태 조회
router.get("/job-status/:jobId", getJobStatus);
// ============================================================================
// 🔥 테이블 관계 조회 (마스터-디테일 저장용)
// ============================================================================
// 두 테이블 간의 조인 관계 조회
router.get("/join-relationship/:mainTable/:detailTable", getJoinRelationship);
// ============================================================================
// 🔥 레거시 호환성 (기존 API와 호환)
// ============================================================================

View File

@ -57,6 +57,3 @@ export default router;

View File

@ -53,6 +53,3 @@ export default router;

View File

@ -69,6 +69,3 @@ export default router;

View File

@ -57,6 +57,3 @@ export default router;

View File

@ -1,8 +0,0 @@
/**
* ()
*/
import categoryTreeController from "../controllers/categoryTreeController";
export default categoryTreeController;

View File

@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (req, res) =>
commonCodeController.getCodeOptions(req, res)
);
// 계층 구조 코드 조회 (트리 형태)
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
commonCodeController.getCodesHierarchy(req, res)
);
// 자식 코드 조회 (연쇄 선택용)
router.get("/categories/:categoryCode/children", (req, res) =>
commonCodeController.getChildCodes(req, res)
);
// 카테고리 → 공통코드 호환 API (레거시 지원)
// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작
router.get("/category-options/:tableName/:columnName", (req, res) =>
commonCodeController.getCategoryOptionsAsCode(req, res)
);
export default router;

View File

@ -1,6 +1,6 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity, getEntityOptions, getDistinctColumnValues } from "../controllers/entitySearchController";
import { searchEntity } from "../controllers/entitySearchController";
const router = Router();
@ -12,18 +12,3 @@ router.get("/:tableName", authenticateToken, searchEntity);
export default router;
// 엔티티 옵션 라우터 (V2Select용)
export const entityOptionsRouter = Router();
/**
* API
* GET /api/entity/:tableName/options
*/
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*/
entityOptionsRouter.get("/:tableName/distinct/:columnName", authenticateToken, getDistinctColumnValues);

View File

@ -1,33 +0,0 @@
/**
*
*/
import { Router } from "express";
import { ScheduleController } from "../controllers/scheduleController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const scheduleController = new ScheduleController();
// 모든 스케줄 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ==================== 스케줄 생성 ====================
// 스케줄 미리보기
router.post("/preview", scheduleController.preview);
// 스케줄 적용
router.post("/apply", scheduleController.apply);
// ==================== 스케줄 조회 ====================
// 스케줄 목록 조회
router.get("/list", scheduleController.list);
// ==================== 스케줄 삭제 ====================
// 스케줄 삭제
router.delete("/:scheduleId", scheduleController.delete);
export default router;

View File

@ -23,21 +23,12 @@ import {
getTableColumns,
saveLayout,
getLayout,
getLayoutV1,
getLayoutV2,
saveLayoutV2,
generateScreenCode,
generateMultipleScreenCodes,
assignScreenToMenu,
getScreensByMenu,
unassignScreenFromMenu,
cleanupDeletedScreenMenuAssignments,
updateTabScreenReferences,
copyScreenMenuAssignments,
copyCodeCategoryAndCodes,
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
} from "../controllers/screenManagementController";
const router = express.Router();
@ -80,9 +71,6 @@ router.get("/tables/:tableName/columns", getTableColumns);
// 레이아웃 관리
router.post("/screens/:screenId/layout", saveLayout);
router.get("/screens/:screenId/layout", getLayout);
router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url + custom_config 기반 (다중 레코드)
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 메뉴-화면 할당 관리
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
@ -95,22 +83,4 @@ router.post(
cleanupDeletedScreenMenuAssignments
);
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
router.post("/screens/update-tab-references", updateTabScreenReferences);
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
router.post("/copy-menu-assignments", copyScreenMenuAssignments);
// 코드 카테고리 + 코드 복제
router.post("/copy-code-category", copyCodeCategoryAndCodes);
// 카테고리 매핑 + 값 복제
router.post("/copy-category-mapping", copyCategoryMapping);
// 테이블 타입 컬럼 복제
router.post("/copy-table-type-columns", copyTableTypeColumns);
// 연쇄관계 설정 복제
router.post("/copy-cascading-relation", copyCascadingRelation);
export default router;

View File

@ -24,10 +24,8 @@ import {
getLogData,
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
} from "../controllers/tableManagementController";
const router = express.Router();
@ -45,7 +43,7 @@ router.get("/tables", getTableList);
*
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* table_type_columns에서 /
* column_labels에서 /
* .
*/
router.get("/tables/entity-relations", getTableEntityRelations);
@ -56,14 +54,6 @@ router.get("/tables/entity-relations", getTableEntityRelations);
*/
router.get("/tables/:tableName/columns", getColumnList);
/**
*
* GET /api/table-management/columns/:tableName/referenced-by
*
* FK
*/
router.get("/columns/:tableName/referenced-by", getReferencedByTables);
/**
*
* PUT /api/table-management/tables/:tableName/label
@ -213,12 +203,6 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
// 메뉴 기반 카테고리 관리 API
// ========================================
/**
* ( )
* GET /api/table-management/category-columns
*/
router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -1,586 +0,0 @@
/**
* ()
* - ( 3단계: 대분류//)
*/
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 카테고리 값 타입
export interface CategoryValue {
valueId: number;
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder: number;
parentValueId: number | null;
depth: number;
path: string | null;
description: string | null;
color: string | null;
icon: string | null;
isActive: boolean;
isDefault: boolean;
companyCode: string;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
updatedBy: string | null;
children?: CategoryValue[];
}
// 카테고리 값 생성 입력
export interface CreateCategoryValueInput {
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
}
// 카테고리 값 수정 입력
export interface UpdateCategoryValueInput {
valueCode?: string;
valueLabel?: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
}
class CategoryTreeService {
/**
* ( )
*/
async getCategoryTree(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
const pool = getPool();
try {
logger.info("카테고리 트리 조회 시작", { companyCode, tableName, columnName });
const 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,
path,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
ORDER BY depth ASC, value_order ASC, value_label ASC
`;
const result = await pool.query(query, [companyCode, tableName, columnName]);
const flatList = result.rows as CategoryValue[];
const tree = this.buildTree(flatList);
logger.info("카테고리 트리 조회 완료", {
tableName,
columnName,
totalCount: flatList.length,
rootCount: tree.length,
});
return tree;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 트리 조회 실패", { error: err.message, tableName, columnName });
throw error;
}
}
/**
* ( )
*/
async getCategoryList(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
const pool = getPool();
try {
const 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,
path,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
ORDER BY depth ASC, value_order ASC, value_label ASC
`;
const result = await pool.query(query, [companyCode, tableName, columnName]);
return result.rows as CategoryValue[];
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 목록 조회 실패", { error: err.message });
throw error;
}
}
/**
*
*/
async getCategoryValue(companyCode: string, valueId: number): Promise<CategoryValue | null> {
const pool = getPool();
try {
const 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,
path,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
`;
const result = await pool.query(query, [companyCode, valueId]);
return result.rows[0] || null;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 조회 실패", { error: err.message, valueId });
throw error;
}
}
/**
*
*/
async createCategoryValue(companyCode: string, input: CreateCategoryValueInput, createdBy?: string): Promise<CategoryValue> {
const pool = getPool();
try {
// depth 계산
let depth = 1;
let path = input.valueLabel;
if (input.parentValueId) {
const parent = await this.getCategoryValue(companyCode, input.parentValueId);
if (parent) {
depth = parent.depth + 1;
path = parent.path ? `${parent.path}/${input.valueLabel}` : input.valueLabel;
if (depth > 3) {
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
}
}
}
const query = `
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by, updated_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
)
RETURNING
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,
path,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
`;
const params = [
input.tableName,
input.columnName,
input.valueCode,
input.valueLabel,
input.valueOrder ?? 0,
input.parentValueId ?? null,
depth,
path,
input.description ?? null,
input.color ?? null,
input.icon ?? null,
input.isActive ?? true,
input.isDefault ?? false,
companyCode,
createdBy ?? null,
];
const result = await pool.query(query, params);
logger.info("카테고리 값 생성 완료", {
valueId: result.rows[0].valueId,
valueLabel: input.valueLabel,
depth,
});
return result.rows[0];
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 생성 실패", { error: err.message, input });
throw error;
}
}
/**
*
*/
async updateCategoryValue(
companyCode: string,
valueId: number,
input: UpdateCategoryValueInput,
updatedBy?: string
): Promise<CategoryValue | null> {
const pool = getPool();
try {
const current = await this.getCategoryValue(companyCode, valueId);
if (!current) {
return null;
}
let newPath = current.path;
let newDepth = current.depth;
if (input.valueLabel && input.valueLabel !== current.valueLabel) {
if (current.parentValueId) {
const parent = await this.getCategoryValue(companyCode, current.parentValueId);
if (parent && parent.path) {
newPath = `${parent.path}/${input.valueLabel}`;
} else {
newPath = input.valueLabel;
}
} else {
newPath = input.valueLabel;
}
}
if (input.parentValueId !== undefined && input.parentValueId !== current.parentValueId) {
if (input.parentValueId) {
const newParent = await this.getCategoryValue(companyCode, input.parentValueId);
if (newParent) {
newDepth = newParent.depth + 1;
const label = input.valueLabel ?? current.valueLabel;
newPath = newParent.path ? `${newParent.path}/${label}` : label;
if (newDepth > 3) {
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
}
}
} else {
newDepth = 1;
newPath = input.valueLabel ?? current.valueLabel;
}
}
const query = `
UPDATE category_values
SET
value_code = COALESCE($3, value_code),
value_label = COALESCE($4, value_label),
value_order = COALESCE($5, value_order),
parent_value_id = $6,
depth = $7,
path = $8,
description = COALESCE($9, description),
color = COALESCE($10, color),
icon = COALESCE($11, icon),
is_active = COALESCE($12, is_active),
is_default = COALESCE($13, is_default),
updated_at = NOW(),
updated_by = $14
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
RETURNING
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,
path,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
`;
const params = [
companyCode,
valueId,
input.valueCode ?? null,
input.valueLabel ?? null,
input.valueOrder ?? null,
input.parentValueId !== undefined ? input.parentValueId : current.parentValueId,
newDepth,
newPath,
input.description ?? null,
input.color ?? null,
input.icon ?? null,
input.isActive ?? null,
input.isDefault ?? null,
updatedBy ?? null,
];
const result = await pool.query(query, params);
if (input.valueLabel || input.parentValueId !== undefined) {
await this.updateChildrenPaths(companyCode, valueId, newPath || "");
}
logger.info("카테고리 값 수정 완료", { valueId });
return result.rows[0] || null;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 수정 실패", { error: err.message, valueId });
throw error;
}
}
/**
* ID
*/
private async collectAllChildValueIds(
companyCode: string,
valueId: number
): Promise<number[]> {
const pool = getPool();
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
const query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT cv.value_id
FROM category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 OR cv.company_code = '*'
)
SELECT value_id FROM category_tree
`;
const result = await pool.query(query, [valueId, companyCode]);
return result.rows.map(row => row.value_id);
}
/**
* ( )
*/
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
const pool = getPool();
try {
// 1. 모든 하위 카테고리 ID 수집
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
const allValueIds = [valueId, ...childValueIds];
logger.info("삭제 대상 카테고리 값 수집 완료", {
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
const reversedIds = [...allValueIds].reverse();
for (const id of reversedIds) {
await pool.query(
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
[companyCode, id]
);
}
logger.info("카테고리 값 삭제 완료", {
valueId,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
});
return true;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
throw error;
}
}
/**
* path
*/
private async updateChildrenPaths(companyCode: string, parentValueId: number, parentPath: string): Promise<void> {
const pool = getPool();
const query = `
SELECT value_id, value_label
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
`;
const result = await pool.query(query, [companyCode, parentValueId]);
for (const child of result.rows) {
const newPath = `${parentPath}/${child.value_label}`;
await pool.query(`UPDATE category_values SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
newPath,
child.value_id,
]);
await this.updateChildrenPaths(companyCode, child.value_id, newPath);
}
}
/**
*
*/
private buildTree(flatList: CategoryValue[]): CategoryValue[] {
const map = new Map<number, CategoryValue>();
const roots: CategoryValue[] = [];
for (const item of flatList) {
map.set(item.valueId, { ...item, children: [] });
}
for (const item of flatList) {
const node = map.get(item.valueId)!;
if (item.parentValueId && map.has(item.parentValueId)) {
const parent = map.get(item.parentValueId)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
/**
*
*/
async getCategoryColumns(companyCode: string, tableName: string): Promise<{ columnName: string; columnLabel: string }[]> {
const pool = getPool();
try {
const query = `
SELECT DISTINCT column_name AS "columnName", column_label AS "columnLabel"
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'category'
AND (company_code = $2 OR company_code = '*')
ORDER BY column_name
`;
const result = await pool.query(query, [tableName, companyCode]);
return result.rows;
} catch (error: unknown) {
const err = error as Error;
logger.error("카테고리 컬럼 목록 조회 실패", { error: err.message, tableName });
throw error;
}
}
/**
* ( . )
* category_values table_name, column_name
*
*/
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
logger.info("getAllCategoryKeys 호출", { companyCode });
const pool = getPool();
try {
const query = `
SELECT DISTINCT
cv.table_name AS "tableName",
cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(ttc.column_label, cv.column_name) AS "columnLabel"
FROM category_values cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name AND ttc.column_name = cv.column_name AND ttc.company_code = '*'
WHERE cv.company_code = $1 OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name
`;
const result = await pool.query(query, [companyCode]);
logger.info("전체 카테고리 키 목록 조회 완료", { count: result.rows.length });
return result.rows;
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 실패", { error: err.message });
throw error;
}
}
}
export const categoryTreeService = new CategoryTreeService();

View File

@ -467,18 +467,18 @@ class DataService {
columnName: string
): Promise<string | null> {
try {
// table_type_columns 테이블에서 라벨 조회
const result = await query<{ column_label: string }>(
`SELECT column_label
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'
// column_labels 테이블에서 라벨 조회
const result = await query<{ label_ko: string }>(
`SELECT label_ko
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
return result[0]?.column_label || null;
return result[0]?.label_ko || null;
} catch (error) {
// table_type_columns 테이블이 없거나 오류가 발생하면 null 반환
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
return null;
}
}

View File

@ -337,110 +337,6 @@ export class DataflowService {
}
}
/**
* (- )
* @param mainTable ()
* @param detailTable ()
* @param companyCode
* @returns
*/
async getJoinRelationshipBetweenTables(
mainTable: string,
detailTable: string,
companyCode: string
): Promise<{
found: boolean;
mainColumn?: string;
detailColumn?: string;
relationshipType?: string;
}> {
try {
logger.info(
`DataflowService: 테이블 간 조인 관계 조회 - 메인: ${mainTable}, 디테일: ${detailTable}`
);
// 양방향 조회 (from → to 또는 to → from)
let queryText = `
SELECT
from_table_name,
from_column_name,
to_table_name,
to_column_name,
relationship_type,
settings
FROM table_relationships
WHERE is_active = 'Y'
AND (
(from_table_name = $1 AND to_table_name = $2)
OR (from_table_name = $2 AND to_table_name = $1)
)
`;
const params: any[] = [mainTable, detailTable];
// 관리자가 아닌 경우 회사코드 제한
if (companyCode !== "*") {
queryText += ` AND (company_code = $3 OR company_code = '*')`;
params.push(companyCode);
}
queryText += ` LIMIT 1`;
const result = await queryOne<{
from_table_name: string;
from_column_name: string;
to_table_name: string;
to_column_name: string;
relationship_type: string;
settings: any;
}>(queryText, params);
if (!result) {
logger.info(
`DataflowService: 테이블 간 조인 관계 없음 - ${mainTable}${detailTable}`
);
return { found: false };
}
// 방향에 따라 컬럼 매핑 결정
// mainTable이 from_table이면 그대로, 아니면 반대로
let mainColumn: string;
let detailColumn: string;
if (result.from_table_name === mainTable) {
// from → to 방향: mainTable.from_column → detailTable.to_column
mainColumn = result.from_column_name;
detailColumn = result.to_column_name;
} else {
// to → from 방향: mainTable.to_column → detailTable.from_column
mainColumn = result.to_column_name;
detailColumn = result.from_column_name;
}
// 쉼표로 구분된 다중 컬럼인 경우 첫 번째 컬럼만 사용
// (추후 다중 컬럼 지원 필요시 확장)
if (mainColumn.includes(",")) {
mainColumn = mainColumn.split(",")[0].trim();
}
if (detailColumn.includes(",")) {
detailColumn = detailColumn.split(",")[0].trim();
}
logger.info(
`DataflowService: 조인 관계 발견 - ${mainTable}.${mainColumn}${detailTable}.${detailColumn}`
);
return {
found: true,
mainColumn,
detailColumn,
relationshipType: result.relationship_type,
};
} catch (error) {
logger.error("DataflowService: 테이블 간 조인 관계 조회 실패", error);
return { found: false };
}
}
/**
*
*/

View File

@ -553,8 +553,77 @@ CREATE TABLE "${tableName}" (${baseColumns},
);
}
// 레거시 column_labels 테이블 지원 제거됨 (2026-01-26)
// 모든 컬럼 메타데이터는 table_type_columns에서 관리
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) {
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
defaultCol.name,
defaultCol.label,
defaultCol.inputType,
JSON.stringify({}),
defaultCol.description,
defaultCol.order,
defaultCol.isVisible,
]
);
}
// 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) {
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
column.name,
column.label || column.name,
inputType,
detailSettings,
column.description,
column.order || 0,
true,
]
);
}
}
/**
@ -671,9 +740,9 @@ CREATE TABLE "${tableName}" (${baseColumns},
[tableName]
);
// 컬럼 정보 조회 (table_type_columns에서)
// 컬럼 정보 조회
const columns = await query(
`SELECT * FROM table_type_columns WHERE table_name = $1 AND company_code = '*' ORDER BY display_order ASC`,
`SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
[tableName]
);
@ -746,7 +815,7 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(ddlQuery);
// 4-2. 관련 메타데이터 삭제
await client.query(`DELETE FROM table_type_columns WHERE table_name = $1`, [
await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [
tableName,
]);
await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [

View File

@ -11,7 +11,7 @@ import {
isValidWebType,
WEB_TYPE_TO_POSTGRES_CONVERTER,
WEB_TYPE_VALIDATION_PATTERNS,
} from "../types/v2-web-types";
} from "../types/unified-web-types";
import { DataflowControlService } from "./dataflowControlService";
// 테이블 컬럼 정보

View File

@ -24,8 +24,7 @@ export class EntityJoinService {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
// company_code = '*' (공통 설정) 우선 조회
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
const entityColumns = await query<{
column_name: string;
input_type: string;
@ -34,12 +33,9 @@ export class EntityJoinService {
display_column: string | null;
}>(
`SELECT column_name, input_type, reference_table, reference_column, display_column
FROM table_type_columns
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND company_code = '*'
AND reference_table IS NOT NULL
AND reference_table != ''`,
AND input_type IN ('entity', 'category')`,
[tableName]
);
@ -730,7 +726,6 @@ export class EntityJoinService {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}>
> {
try {
@ -749,40 +744,31 @@ export class EntityJoinService {
[tableName]
);
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
// 2. column_labels 테이블에서 라벨 정보 조회
const columnLabels = await query<{
column_name: string;
column_label: string | null;
input_type: string | null;
}>(
`SELECT column_name, column_label, input_type
FROM table_type_columns
WHERE table_name = $1
AND company_code = '*'`,
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
// 3. 라벨 및 inputType 정보를 맵으로 변환
const labelMap = new Map<string, { label: string; inputType: string }>();
columnLabels.forEach((col) => {
if (col.column_name) {
labelMap.set(col.column_name, {
label: col.column_label || col.column_name,
inputType: col.input_type || "text",
});
// 3. 라벨 정보를 맵으로 변환
const labelMap = new Map<string, string>();
columnLabels.forEach((label) => {
if (label.column_name && label.column_label) {
labelMap.set(label.column_name, label.column_label);
}
});
// 4. 컬럼 정보와 라벨/inputType 정보 결합
return columns.map((col) => {
const labelInfo = labelMap.get(col.column_name);
return {
// 4. 컬럼 정보와 라벨 정보 결합
return columns.map((col) => ({
columnName: col.column_name,
displayName: labelInfo?.label || col.column_name,
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
dataType: col.data_type,
inputType: labelInfo?.inputType || "text",
};
});
}));
} catch (error) {
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
return [];

View File

@ -316,9 +316,9 @@ export class FlowExecutionService {
flowDef.dbConnectionId
);
// 외부 DB 연결 정보 조회 (flow 전용 테이블 사용)
// 외부 DB 연결 정보 조회
const connectionResult = await db.query(
"SELECT * FROM flow_external_db_connection WHERE id = $1",
"SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId]
);

View File

@ -132,7 +132,7 @@ class MasterDetailExcelService {
}
/**
* table_type_columns에서 Entity
* column_labels에서 Entity
*
*/
async getEntityRelation(
@ -144,11 +144,10 @@ class MasterDetailExcelService {
const result = await queryOne<any>(
`SELECT column_name, reference_column
FROM table_type_columns
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
AND company_code = '*'
LIMIT 1`,
[detailTable, masterTable]
);
@ -177,8 +176,8 @@ class MasterDetailExcelService {
try {
const result = await query<any>(
`SELECT column_name, column_label
FROM table_type_columns
WHERE table_name = $1 AND company_code = '*'`,
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
@ -232,7 +231,7 @@ class MasterDetailExcelService {
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 table_type_columns에서 Entity 관계 조회
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) {
@ -323,7 +322,7 @@ class MasterDetailExcelService {
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// table_type_columns에서 FK 컬럼 찾기
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) {
entityJoins.push({
@ -351,7 +350,7 @@ class MasterDetailExcelService {
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// table_type_columns에서 FK 컬럼 찾기
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) {
entityJoins.push({
@ -456,11 +455,10 @@ class MasterDetailExcelService {
try {
const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column
FROM table_type_columns
FROM column_labels
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
AND company_code = '*'
LIMIT 1`,
[sourceTable, referenceTable]
);
@ -885,21 +883,16 @@ class MasterDetailExcelService {
/**
* ( numberingRuleService )
* @param client DB
* @param ruleId ID
* @param companyCode
* @param formData ( )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string,
formData?: Record<string, any>
companyCode: string
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);

View File

@ -16,8 +16,6 @@ export interface MenuCopyResult {
copiedCategoryMappings: number;
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
copiedCascadingRelations: number; // 연쇄관계 설정
copiedNodeFlows: number; // 노드 플로우 (제어관리)
copiedDataflowDiagrams: number; // 데이터플로우 다이어그램 (버튼 제어)
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
@ -851,10 +849,47 @@ export class MenuCopyService {
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-4. 채번 규칙 처리 (새 스키마에서는 menu_objid 없음 - 스킵)
// 새 numbering_rules 스키마: table_name + column_name + company_code 기반
// 메뉴와 직접 연결되지 않으므로 메뉴 삭제 시 처리 불필요
logger.info(` ⏭️ 채번 규칙: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)`);
// 5-4. 채번 규칙 처리 (체크 제약조건 고려)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
const menuScopedRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`,
[existingMenuIds, targetCompanyCode]
);
if (menuScopedRulesResult.rows.length > 0) {
const menuScopedRuleIds = menuScopedRulesResult.rows.map(
(r) => r.rule_id
);
// 채번 규칙 파트 먼저 삭제
await client.query(
`DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
// 채번 규칙 삭제
await client.query(
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
logger.info(
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`
);
}
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
const updatedNumberingRules = await client.query(
`UPDATE numbering_rules
SET menu_objid = NULL
WHERE menu_objid = ANY($1) AND company_code = $2
AND (scope_type IS NULL OR scope_type != 'menu')
RETURNING rule_id`,
[existingMenuIds, targetCompanyCode]
);
if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) {
logger.info(
` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)`
);
}
// 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
@ -924,16 +959,6 @@ export class MenuCopyService {
const menus = await this.collectMenuTree(sourceMenuObjid, client);
const sourceCompanyCode = menus[0].company_code!;
// 같은 회사로 복제하는 경우 경고 (자기 자신의 데이터 손상 위험)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 메뉴 복제 시도: ${sourceCompanyCode}${targetCompanyCode}`
);
warnings.push(
"같은 회사로 복제하면 추가 데이터(카테고리, 채번 등)가 복제되지 않습니다."
);
}
const screenIds = await this.collectScreens(
menus.map((m) => m.objid),
sourceCompanyCode,
@ -958,14 +983,6 @@ export class MenuCopyService {
client
);
// === 2.1단계: 노드 플로우 복사는 화면 복사에서 처리 ===
// (screenManagementService.ts의 copyScreen에서 처리)
const copiedNodeFlows = 0;
// === 2.2단계: 데이터플로우 다이어그램 복사는 화면 복사에서 처리 ===
// (screenManagementService.ts의 copyScreen에서 처리)
const copiedDataflowDiagrams = 0;
// 변수 초기화
let copiedCodeCategories = 0;
let copiedCodes = 0;
@ -1089,10 +1106,6 @@ export class MenuCopyService {
client
);
// === 6.5단계: 메뉴 URL 업데이트 (화면 ID 재매핑) ===
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
// === 7단계: 테이블 타입 설정 복사 ===
if (additionalCopyOptions?.copyTableTypeColumns) {
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
@ -1119,8 +1132,6 @@ export class MenuCopyService {
copiedCategoryMappings,
copiedTableTypeColumns,
copiedCascadingRelations,
copiedNodeFlows,
copiedDataflowDiagrams,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
@ -1133,8 +1144,6 @@ export class MenuCopyService {
- 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows}
- (): ${copiedNodeFlows}
- ( ): ${copiedDataflowDiagrams}
- 카테고리: ${copiedCodeCategories}
- 코드: ${copiedCodes}
- 채번규칙: ${copiedNumberingRules}
@ -1533,22 +1542,22 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id;
// 원본 V2 레이아웃 조회
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
// 원본 레이아웃 조회
const sourceLayoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
// 대상 V2 레이아웃 조회
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
// 대상 레이아웃 조회
const targetLayoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[existingScreenId]
);
// 변경 여부 확인 (V2 레이아웃 비교)
const hasChanges = this.hasLayoutChangesV2(
sourceLayoutV2Result.rows[0]?.layout_data,
targetLayoutV2Result.rows[0]?.layout_data
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
const hasChanges = this.hasLayoutChanges(
sourceLayoutsResult.rows,
targetLayoutsResult.rows
);
if (hasChanges) {
@ -1650,9 +1659,9 @@ export class MenuCopyService {
}
}
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
logger.info(
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
for (const {
@ -1662,51 +1671,91 @@ export class MenuCopyService {
isUpdate,
} of screenDefsToProcess) {
try {
// 원본 V2 레이아웃 조회
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
// 원본 레이아웃 조회
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
const layoutData = layoutV2Result.rows[0]?.layout_data;
const components = layoutData?.components || [];
if (layoutData && components.length > 0) {
// component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
components.forEach((comp: any, idx: number) => {
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(comp.id, newComponentId);
});
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
layoutData,
componentIdMap,
screenIdMap,
flowIdMap,
numberingRuleIdMap,
menuIdMap
);
// V2 레이아웃 저장 (UPSERT)
if (isUpdate) {
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
`DELETE FROM screen_layouts WHERE screen_id = $1`,
[targetScreenId]
);
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
} else {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
}
// component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
layoutsResult.rows.forEach((layout, idx) => {
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(layout.component_id, newComponentId);
});
// 레이아웃 배치 삽입 준비
if (layoutsResult.rows.length > 0) {
const layoutValues: string[] = [];
const layoutParams: any[] = [];
let paramIdx = 1;
for (const layout of layoutsResult.rows) {
const newComponentId = componentIdMap.get(layout.component_id)!;
const newParentId = layout.parent_id
? componentIdMap.get(layout.parent_id) || layout.parent_id
: null;
const newZoneId = layout.zone_id
? componentIdMap.get(layout.zone_id) || layout.zone_id
: null;
const updatedProperties = this.updateReferencesInProperties(
layout.properties,
screenIdMap,
flowIdMap,
numberingRuleIdMap,
menuIdMap
);
layoutValues.push(
`($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})`
);
layoutParams.push(
targetScreenId,
layout.component_type,
newComponentId,
newParentId,
layout.position_x,
layout.position_y,
layout.width,
layout.height,
updatedProperties,
layout.display_order,
layout.layout_type,
layout.layout_config,
layout.zones_config,
newZoneId
);
paramIdx += 14;
}
// 배치 INSERT
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, layout_type, layout_config, zones_config, zone_id
) VALUES ${layoutValues.join(", ")}`,
layoutParams
);
}
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}`);
} catch (error: any) {
logger.error(
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
`레이아웃 처리 실패: screen_id=${originalScreenId}`,
error
);
throw error;
@ -1772,83 +1821,6 @@ export class MenuCopyService {
return false;
}
/**
* V2 (screen_layouts_v2용)
*/
private hasLayoutChangesV2(
sourceLayoutData: any,
targetLayoutData: any
): boolean {
// 1. 둘 다 없으면 변경 없음
if (!sourceLayoutData && !targetLayoutData) return false;
// 2. 하나만 있으면 변경됨
if (!sourceLayoutData || !targetLayoutData) return true;
// 3. components 배열 비교
const sourceComps = sourceLayoutData.components || [];
const targetComps = targetLayoutData.components || [];
if (sourceComps.length !== targetComps.length) return true;
// 4. 각 컴포넌트 비교 (url, position, size, overrides)
for (let i = 0; i < sourceComps.length; i++) {
const s = sourceComps[i];
const t = targetComps[i];
if (s.url !== t.url) return true;
if (JSON.stringify(s.position) !== JSON.stringify(t.position)) return true;
if (JSON.stringify(s.size) !== JSON.stringify(t.size)) return true;
if (JSON.stringify(s.overrides) !== JSON.stringify(t.overrides)) return true;
}
return false;
}
/**
* V2 ID들을 (componentId, flowId, ruleId, screenId, menuId)
*/
private updateReferencesInLayoutDataV2(
layoutData: any,
componentIdMap: Map<string, string>,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): any {
if (!layoutData?.components) return layoutData;
const updatedComponents = layoutData.components.map((comp: any) => {
// 1. componentId 매핑
const newId = componentIdMap.get(comp.id) || comp.id;
// 2. overrides 복사 및 재귀적 참조 업데이트
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
// 재귀적으로 모든 참조 업데이트
this.recursiveUpdateReferences(
overrides,
screenIdMap,
flowIdMap,
"",
numberingRuleIdMap,
menuIdMap
);
return {
...comp,
id: newId,
overrides,
};
});
return {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString(),
};
}
/**
* ( )
*/
@ -2245,68 +2217,6 @@ export class MenuCopyService {
}
}
/**
* URL ( ID )
* menu_url에 /screens/{screenId} ID를 ID로
*/
private async updateMenuUrls(
menuIdMap: Map<number, number>,
screenIdMap: Map<number, number>,
client: PoolClient
): Promise<void> {
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
logger.info("📭 메뉴 URL 업데이트 대상 없음");
return;
}
const newMenuObjids = Array.from(menuIdMap.values());
// 복제된 메뉴 중 menu_url이 있는 것 조회
const menusWithUrl = await client.query<{
objid: number;
menu_url: string;
}>(
`SELECT objid, menu_url FROM menu_info
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
[newMenuObjids]
);
if (menusWithUrl.rows.length === 0) {
logger.info("📭 menu_url 업데이트 대상 없음");
return;
}
let updatedCount = 0;
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menusWithUrl.rows) {
const match = menu.menu_url.match(screenIdPattern);
if (!match) continue;
const originalScreenId = parseInt(match[1], 10);
const newScreenId = screenIdMap.get(originalScreenId);
if (newScreenId && newScreenId !== originalScreenId) {
const newMenuUrl = menu.menu_url.replace(
`/screens/${originalScreenId}`,
`/screens/${newScreenId}`
);
await client.query(
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
[newMenuUrl, menu.objid]
);
logger.info(
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}`
);
updatedCount++;
}
}
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}`);
}
/**
* + (최적화: 배치 /)
*/
@ -2553,9 +2463,8 @@ export class MenuCopyService {
}
/**
* ( 스키마: table_name + column_name )
* /numbering-rules/copy-for-company API를
* ruleIdMap ( numberingRuleService에서 )
* (최적화: 배치 /)
* numberingRuleId
*/
private async copyNumberingRulesWithMap(
menuObjids: number[],
@ -2564,46 +2473,220 @@ export class MenuCopyService {
userId: string,
client: PoolClient
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
let copiedCount = 0;
const ruleIdMap = new Map<string, string>();
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
// 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출
// 여기서는 기존 규칙 ID를 그대로 매핑 (화면 레이아웃의 numberingRuleId 참조용)
// 원본 회사의 채번규칙 조회 (company_code 기반)
const sourceRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[menuObjids.length > 0 ? (await client.query(
`SELECT company_code FROM menu_info WHERE objid = $1`,
[menuObjids[0]]
)).rows[0]?.company_code : null]
if (menuObjids.length === 0) {
return { copiedCount, ruleIdMap };
}
// === 최적화: 배치 조회 ===
// 1. 모든 원본 채번 규칙 한 번에 조회
const allRulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
[menuObjids]
);
// 대상 회사의 채번규칙 조회 (이름 기준 매핑)
const targetRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
if (allRulesResult.rows.length === 0) {
logger.info(` 📭 복사할 채번 규칙 없음`);
return { copiedCount, ruleIdMap };
}
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요)
const existingRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode]
);
const targetRulesByName = new Map(
targetRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id])
const existingRuleIds = new Set(
existingRulesResult.rows.map((r) => r.rule_id)
);
// 이름 기준으로 매핑 생성
for (const sourceRule of sourceRulesResult.rows) {
const targetRuleId = targetRulesByName.get(sourceRule.rule_name);
if (targetRuleId) {
ruleIdMap.set(sourceRule.rule_id, targetRuleId);
logger.info(` 🔗 채번규칙 매핑: ${sourceRule.rule_id} -> ${targetRuleId}`);
// 3. 복사할 규칙과 스킵할 규칙 분류
const rulesToCopy: any[] = [];
const originalToNewRuleMap: Array<{ original: string; new: string }> = [];
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
for (const rule of allRulesResult.rows) {
// 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가
// 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드
let baseName = rule.rule_id;
// 회사코드 접두사 패턴들을 순서대로 제거 시도
// 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_)
// 2. 일반 접두사_ 패턴 (예: WACE_)
if (baseName.match(/^COMPANY_\d+_/)) {
baseName = baseName.replace(/^COMPANY_\d+_/, "");
} else if (baseName.includes("_")) {
baseName = baseName.replace(/^[^_]+_/, "");
}
const newRuleId = `${targetCompanyCode}_${baseName}`;
if (existingRuleIds.has(rule.rule_id)) {
// 원본 ID가 이미 존재 (동일한 ID로 매핑)
ruleIdMap.set(rule.rule_id, rule.rule_id);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
}
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
} else if (existingRuleIds.has(newRuleId)) {
// 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑)
ruleIdMap.set(rule.rule_id, newRuleId);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid });
}
logger.info(
` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}`
);
} else {
// 새로 복사 필요
ruleIdMap.set(rule.rule_id, newRuleId);
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
rulesToCopy.push({ ...rule, newRuleId });
logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`);
}
}
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}`);
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
return { copiedCount: 0, ruleIdMap };
}
// 4. 배치 INSERT로 채번 규칙 복사
if (rulesToCopy.length > 0) {
const ruleValues = rulesToCopy
.map(
(_, i) =>
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
)
.join(", ");
const ruleParams = rulesToCopy.flatMap((r) => {
const newMenuObjid = menuIdMap.get(r.menu_objid);
// scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건)
// menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로
// scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
// scope_type 결정 로직:
// 1. menu 스코프인데 menu_objid 매핑이 없는 경우
// - table_name이 있으면 'table' 스코프로 변경
// - table_name이 없으면 'global' 스코프로 변경
// 2. 그 외에는 원본 scope_type 유지
let finalScopeType = r.scope_type;
if (r.scope_type === "menu" && finalMenuObjid === null) {
if (r.table_name) {
finalScopeType = "table"; // table_name이 있으면 table 스코프
} else {
finalScopeType = "global"; // table_name도 없으면 global 스코프
}
}
return [
r.newRuleId,
r.rule_name,
r.description,
r.separator,
r.reset_period,
0,
r.table_name,
r.column_name,
targetCompanyCode,
userId,
finalMenuObjid,
finalScopeType,
null,
];
});
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, created_by, menu_objid, scope_type, last_generated_date
) VALUES ${ruleValues}`,
ruleParams
);
copiedCount = rulesToCopy.length;
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`);
}
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
if (rulesToUpdate.length > 0) {
// CASE WHEN을 사용한 배치 업데이트
// menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요
const caseWhen = rulesToUpdate
.map(
(_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric`
)
.join(" ");
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
await client.query(
`UPDATE numbering_rules
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
[...params, ruleIdsForUpdate, targetCompanyCode]
);
logger.info(
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
);
}
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
if (rulesToCopy.length > 0) {
const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
const allPartsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`,
[originalRuleIds]
);
// 6. 배치 INSERT로 채번 규칙 파트 복사
if (allPartsResult.rows.length > 0) {
// 원본 rule_id -> 새 rule_id 매핑
const ruleMapping = new Map(
originalToNewRuleMap.map((m) => [m.original, m.new])
);
const partValues = allPartsResult.rows
.map(
(_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())`
)
.join(", ");
const partParams = allPartsResult.rows.flatMap((p) => [
ruleMapping.get(p.rule_id),
p.part_order,
p.part_type,
p.generation_method,
p.auto_config,
p.manual_config,
targetCompanyCode,
]);
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ${partValues}`,
partParams
);
logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`);
}
}
logger.info(
`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}`
);
return { copiedCount, ruleIdMap };
}
/**
* + (최적화: 배치 )
@ -3241,175 +3324,4 @@ export class MenuCopyService {
logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}`);
return copiedCount;
}
/**
* (node_flows - )
* - node_flows를
* -
* - (flow_data )
* - ID ID ( flowId, selectedDiagramId )
*/
private async copyNodeFlows(
sourceCompanyCode: string,
targetCompanyCode: string,
client: PoolClient
): Promise<{ copiedCount: number; nodeFlowIdMap: Map<number, number> }> {
logger.info(`📋 노드 플로우(제어관리) 복사 시작`);
const nodeFlowIdMap = new Map<number, number>();
let copiedCount = 0;
// 1. 원본 회사의 모든 node_flows 조회
const sourceFlowsResult = await client.query(
`SELECT * FROM node_flows WHERE company_code = $1`,
[sourceCompanyCode]
);
if (sourceFlowsResult.rows.length === 0) {
logger.info(` 📭 원본 회사에 노드 플로우 없음`);
return { copiedCount: 0, nodeFlowIdMap };
}
logger.info(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}`);
// 2. 대상 회사의 기존 노드 플로우 조회 (이름 기준)
const existingFlowsResult = await client.query(
`SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`,
[targetCompanyCode]
);
const existingFlowsByName = new Map<string, number>(
existingFlowsResult.rows.map((f) => [f.flow_name, f.flow_id])
);
// 3. 복사할 플로우 필터링 + 기존 플로우 매핑
const flowsToCopy: any[] = [];
for (const flow of sourceFlowsResult.rows) {
const existingId = existingFlowsByName.get(flow.flow_name);
if (existingId) {
// 기존 플로우 재사용 - ID 매핑 추가
nodeFlowIdMap.set(flow.flow_id, existingId);
logger.info(` ♻️ 기존 노드 플로우 재사용: ${flow.flow_name} (${flow.flow_id}${existingId})`);
} else {
flowsToCopy.push(flow);
}
}
if (flowsToCopy.length === 0) {
logger.info(` 📭 모든 노드 플로우가 이미 존재함 (매핑 ${nodeFlowIdMap.size}개)`);
return { copiedCount: 0, nodeFlowIdMap };
}
logger.info(` 🔄 복사할 노드 플로우: ${flowsToCopy.length}`);
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
for (const flow of flowsToCopy) {
const insertResult = await client.query(
`INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
VALUES ($1, $2, $3, $4)
RETURNING flow_id`,
[
flow.flow_name,
flow.flow_description,
JSON.stringify(flow.flow_data),
targetCompanyCode,
]
);
const newFlowId = insertResult.rows[0].flow_id;
nodeFlowIdMap.set(flow.flow_id, newFlowId);
logger.info(` 노드 플로우 복사: ${flow.flow_name} (${flow.flow_id}${newFlowId})`);
copiedCount++;
}
logger.info(` ✅ 노드 플로우 복사 완료: ${copiedCount}개, 매핑 ${nodeFlowIdMap.size}`);
return { copiedCount, nodeFlowIdMap };
}
/**
* (dataflow_diagrams - )
* - dataflow_diagrams를
* -
* - (relationships, node_positions, control, plan, category )
* - ID ID
*/
private async copyDataflowDiagrams(
sourceCompanyCode: string,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<{ copiedCount: number; diagramIdMap: Map<number, number> }> {
logger.info(`📋 데이터플로우 다이어그램(버튼 제어) 복사 시작`);
const diagramIdMap = new Map<number, number>();
let copiedCount = 0;
// 1. 원본 회사의 모든 dataflow_diagrams 조회
const sourceDiagramsResult = await client.query(
`SELECT * FROM dataflow_diagrams WHERE company_code = $1`,
[sourceCompanyCode]
);
if (sourceDiagramsResult.rows.length === 0) {
logger.info(` 📭 원본 회사에 데이터플로우 다이어그램 없음`);
return { copiedCount: 0, diagramIdMap };
}
logger.info(` 📋 원본 데이터플로우 다이어그램: ${sourceDiagramsResult.rows.length}`);
// 2. 대상 회사의 기존 다이어그램 조회 (이름 기준)
const existingDiagramsResult = await client.query(
`SELECT diagram_id, diagram_name FROM dataflow_diagrams WHERE company_code = $1`,
[targetCompanyCode]
);
const existingDiagramsByName = new Map<string, number>(
existingDiagramsResult.rows.map((d) => [d.diagram_name, d.diagram_id])
);
// 3. 복사할 다이어그램 필터링 + 기존 다이어그램 매핑
const diagramsToCopy: any[] = [];
for (const diagram of sourceDiagramsResult.rows) {
const existingId = existingDiagramsByName.get(diagram.diagram_name);
if (existingId) {
// 기존 다이어그램 재사용 - ID 매핑 추가
diagramIdMap.set(diagram.diagram_id, existingId);
logger.info(` ♻️ 기존 다이어그램 재사용: ${diagram.diagram_name} (${diagram.diagram_id}${existingId})`);
} else {
diagramsToCopy.push(diagram);
}
}
if (diagramsToCopy.length === 0) {
logger.info(` 📭 모든 다이어그램이 이미 존재함 (매핑 ${diagramIdMap.size}개)`);
return { copiedCount: 0, diagramIdMap };
}
logger.info(` 🔄 복사할 다이어그램: ${diagramsToCopy.length}`);
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
for (const diagram of diagramsToCopy) {
const insertResult = await client.query(
`INSERT INTO dataflow_diagrams (diagram_name, relationships, company_code, created_by, node_positions, control, plan, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING diagram_id`,
[
diagram.diagram_name,
JSON.stringify(diagram.relationships),
targetCompanyCode,
userId,
diagram.node_positions ? JSON.stringify(diagram.node_positions) : null,
diagram.control ? JSON.stringify(diagram.control) : null,
diagram.plan ? JSON.stringify(diagram.plan) : null,
diagram.category ? JSON.stringify(diagram.category) : null,
]
);
const newDiagramId = insertResult.rows[0].diagram_id;
diagramIdMap.set(diagram.diagram_id, newDiagramId);
logger.info(` 다이어그램 복사: ${diagram.diagram_name} (${diagram.diagram_id}${newDiagramId})`);
copiedCount++;
}
logger.info(` ✅ 데이터플로우 다이어그램 복사 완료: ${copiedCount}개, 매핑 ${diagramIdMap.size}`);
return { copiedCount, diagramIdMap };
}
}

View File

@ -243,28 +243,6 @@ export async function syncScreenGroupsToMenu(
[groupId, menuObjid]
);
// 해당 그룹에 연결된 기본 화면으로 URL 항상 업데이트 (화면 재생성 시에도 반영)
const defaultScreenQuery = `
SELECT sd.screen_id, sd.screen_code, sd.screen_name
FROM screen_group_screens sgs
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = $1 AND sgs.company_code = $2
ORDER BY
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
sgs.display_order ASC
LIMIT 1
`;
const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]);
if (defaultScreenResult.rows.length > 0) {
const defaultScreen = defaultScreenResult.rows[0];
const newMenuUrl = `/screens/${defaultScreen.screen_id}`;
await client.query(
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
[newMenuUrl, defaultScreen.screen_code, menuObjid]
);
logger.info("메뉴 URL 업데이트", { groupName, screenId: defaultScreen.screen_id, menuUrl: newMenuUrl });
}
groupToMenuMap.set(groupId, menuObjid);
result.linked++;
result.details.push({
@ -308,34 +286,12 @@ export async function syncScreenGroupsToMenu(
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
}
// 해당 그룹에 연결된 기본 화면 조회 (is_default = 'Y' 우선, 없으면 첫 번째 화면)
let menuUrl: string | null = null;
let screenCode: string | null = null;
const defaultScreenQuery2 = `
SELECT sd.screen_id, sd.screen_code, sd.screen_name
FROM screen_group_screens sgs
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = $1 AND sgs.company_code = $2
ORDER BY
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
sgs.display_order ASC
LIMIT 1
`;
const defaultScreenResult2 = await client.query(defaultScreenQuery2, [groupId, companyCode]);
if (defaultScreenResult2.rows.length > 0) {
const defaultScreen = defaultScreenResult2.rows[0];
screenCode = defaultScreen.screen_code;
menuUrl = `/screens/${defaultScreen.screen_id}`;
logger.info("기본 화면 URL 설정", { groupName, screenId: defaultScreen.screen_id, menuUrl });
}
// menu_info에 삽입
const insertMenuQuery = `
INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
menu_url, screen_code
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
RETURNING objid
`;
await client.query(insertMenuQuery, [
@ -348,8 +304,6 @@ export async function syncScreenGroupsToMenu(
userId,
groupId,
group.description || null,
menuUrl,
screenCode,
]);
// screen_groups에 menu_objid 업데이트
@ -382,13 +336,7 @@ export async function syncScreenGroupsToMenu(
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("화면관리 → 메뉴 동기화 실패", {
companyCode,
error: error.message,
stack: error.stack,
code: error.code,
detail: error.detail,
});
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
result.success = false;
result.errors.push(error.message);
return result;

View File

@ -777,7 +777,7 @@ export class MultiConnectionQueryService {
dataType: column.dataType,
dbType: column.dataType, // dataType을 dbType으로 사용
webType: column.webType || "text", // webType 사용, 기본값 text
inputType: column.inputType || "direct", // table_type_columns의 input_type 추가
inputType: column.inputType || "direct", // column_labels의 input_type 추가
codeCategory: column.codeCategory, // 코드 카테고리 정보 추가
isNullable: column.isNullable === "Y",
isPrimaryKey: column.isPrimaryKey || false,

View File

@ -984,11 +984,9 @@ export class NodeFlowExecutionService {
// 자동 생성 (채번 규칙)
const companyCode = context.buttonContext?.companyCode || "*";
try {
// 폼 데이터를 전달하여 날짜 컬럼 기준 생성 지원
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode,
data // 폼 데이터 전달 (날짜 컬럼 기준 생성 시 사용)
companyCode
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`

File diff suppressed because it is too large Load Diff

View File

@ -477,6 +477,7 @@ export class ReferenceCacheService {
// 일반적인 참조 테이블들
const commonTables = [
{ table: "user_info", key: "user_id", display: "user_name" },
{ table: "comm_code", key: "code_id", display: "code_name" },
{ table: "dept_info", key: "dept_code", display: "dept_name" },
{ table: "companies", key: "company_code", display: "company_name" },
];

View File

@ -1,520 +0,0 @@
/**
*
*
* , , .
*/
import { pool } from "../database/db";
// ============================================================================
// 타입 정의
// ============================================================================
export interface ScheduleGenerationConfig {
scheduleType: "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN";
source: {
tableName: string;
groupByField: string;
quantityField: string;
dueDateField?: string;
};
resource: {
type: string;
idField: string;
nameField: string;
};
rules: {
leadTimeDays?: number;
dailyCapacity?: number;
workingDays?: number[];
considerStock?: boolean;
stockTableName?: string;
stockQtyField?: string;
safetyStockField?: string;
};
target: {
tableName: string;
};
}
export interface SchedulePreview {
toCreate: any[];
toDelete: any[];
toUpdate: any[];
summary: {
createCount: number;
deleteCount: number;
updateCount: number;
totalQty: number;
};
}
export interface ApplyOptions {
deleteExisting: boolean;
updateMode: "replace" | "merge";
}
export interface ApplyResult {
created: number;
deleted: number;
updated: number;
}
export interface ScheduleListQuery {
scheduleType?: string;
resourceType?: string;
resourceId?: string;
startDate?: string;
endDate?: string;
status?: string;
companyCode: string;
}
// ============================================================================
// 서비스 클래스
// ============================================================================
export class ScheduleService {
/**
*
*/
async generatePreview(
config: ScheduleGenerationConfig,
sourceData: any[],
period: { start: string; end: string } | undefined,
companyCode: string
): Promise<SchedulePreview> {
console.log("[ScheduleService] generatePreview 시작:", {
scheduleType: config.scheduleType,
sourceDataCount: sourceData.length,
period,
companyCode,
});
// 기본 기간 설정 (현재 월)
const now = new Date();
const defaultPeriod = {
start: new Date(now.getFullYear(), now.getMonth(), 1)
.toISOString()
.split("T")[0],
end: new Date(now.getFullYear(), now.getMonth() + 1, 0)
.toISOString()
.split("T")[0],
};
const effectivePeriod = period || defaultPeriod;
// 1. 소스 데이터를 리소스별로 그룹화
const groupedData = this.groupByResource(sourceData, config);
// 2. 각 리소스에 대해 스케줄 생성
const toCreate: any[] = [];
let totalQty = 0;
for (const [resourceId, items] of Object.entries(groupedData)) {
const schedules = this.generateSchedulesForResource(
resourceId,
items as any[],
config,
effectivePeriod,
companyCode
);
toCreate.push(...schedules);
totalQty += schedules.reduce(
(sum, s) => sum + (s.plan_qty || 0),
0
);
}
// 3. 기존 스케줄 조회 (삭제 대상)
// 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만)
const resourceIds = [...new Set(
Object.keys(groupedData).map((key) => key.split("|")[0])
)];
const toDelete = await this.getExistingSchedules(
config.scheduleType,
resourceIds,
effectivePeriod,
companyCode
);
// 4. 미리보기 결과 생성
const preview: SchedulePreview = {
toCreate,
toDelete,
toUpdate: [], // 현재는 Replace 모드만 지원
summary: {
createCount: toCreate.length,
deleteCount: toDelete.length,
updateCount: 0,
totalQty,
},
};
console.log("[ScheduleService] generatePreview 완료:", preview.summary);
return preview;
}
/**
*
*/
async applySchedules(
config: ScheduleGenerationConfig,
preview: SchedulePreview,
options: ApplyOptions,
companyCode: string,
userId: string
): Promise<ApplyResult> {
console.log("[ScheduleService] applySchedules 시작:", {
createCount: preview.summary.createCount,
deleteCount: preview.summary.deleteCount,
options,
companyCode,
userId,
});
const client = await pool.connect();
const result: ApplyResult = { created: 0, deleted: 0, updated: 0 };
try {
await client.query("BEGIN");
// 1. 기존 스케줄 삭제
if (options.deleteExisting && preview.toDelete.length > 0) {
const deleteIds = preview.toDelete.map((s) => s.schedule_id);
await client.query(
`DELETE FROM schedule_mng
WHERE schedule_id = ANY($1) AND company_code = $2`,
[deleteIds, companyCode]
);
result.deleted = deleteIds.length;
console.log("[ScheduleService] 스케줄 삭제 완료:", result.deleted);
}
// 2. 새 스케줄 생성
for (const schedule of preview.toCreate) {
await client.query(
`INSERT INTO schedule_mng (
company_code, schedule_type, schedule_name,
resource_type, resource_id, resource_name,
start_date, end_date, due_date,
plan_qty, unit, status, priority,
source_table, source_id, source_group_key,
auto_generated, generated_at, generated_by,
metadata, created_by, updated_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)`,
[
companyCode,
schedule.schedule_type,
schedule.schedule_name,
schedule.resource_type,
schedule.resource_id,
schedule.resource_name,
schedule.start_date,
schedule.end_date,
schedule.due_date || null,
schedule.plan_qty,
schedule.unit || null,
schedule.status || "PLANNED",
schedule.priority || null,
schedule.source_table || null,
schedule.source_id || null,
schedule.source_group_key || null,
true,
new Date(),
userId,
schedule.metadata ? JSON.stringify(schedule.metadata) : null,
userId,
userId,
]
);
result.created++;
}
await client.query("COMMIT");
console.log("[ScheduleService] applySchedules 완료:", result);
return result;
} catch (error) {
await client.query("ROLLBACK");
console.error("[ScheduleService] applySchedules 오류:", error);
throw error;
} finally {
client.release();
}
}
/**
*
*/
async getScheduleList(
query: ScheduleListQuery
): Promise<{ data: any[]; total: number }> {
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// company_code 필터
if (query.companyCode !== "*") {
conditions.push(`company_code = $${paramIndex++}`);
params.push(query.companyCode);
}
// scheduleType 필터
if (query.scheduleType) {
conditions.push(`schedule_type = $${paramIndex++}`);
params.push(query.scheduleType);
}
// resourceType 필터
if (query.resourceType) {
conditions.push(`resource_type = $${paramIndex++}`);
params.push(query.resourceType);
}
// resourceId 필터
if (query.resourceId) {
conditions.push(`resource_id = $${paramIndex++}`);
params.push(query.resourceId);
}
// 기간 필터
if (query.startDate) {
conditions.push(`end_date >= $${paramIndex++}`);
params.push(query.startDate);
}
if (query.endDate) {
conditions.push(`start_date <= $${paramIndex++}`);
params.push(query.endDate);
}
// status 필터
if (query.status) {
conditions.push(`status = $${paramIndex++}`);
params.push(query.status);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT * FROM schedule_mng
${whereClause}
ORDER BY start_date, resource_id`,
params
);
return {
data: result.rows,
total: result.rows.length,
};
}
/**
*
*/
async deleteSchedule(
scheduleId: number,
companyCode: string,
userId: string
): Promise<{ success: boolean; message?: string }> {
const result = await pool.query(
`DELETE FROM schedule_mng
WHERE schedule_id = $1 AND (company_code = $2 OR $2 = '*')
RETURNING schedule_id`,
[scheduleId, companyCode]
);
if (result.rowCount === 0) {
return {
success: false,
message: "스케줄을 찾을 수 없거나 권한이 없습니다.",
};
}
// 이력 기록
await pool.query(
`INSERT INTO schedule_history (company_code, schedule_id, action, changed_by)
VALUES ($1, $2, 'DELETE', $3)`,
[companyCode, scheduleId, userId]
);
return { success: true };
}
// ============================================================================
// 헬퍼 메서드
// ============================================================================
/**
*
* - (dueDateField) 경우: 리소스 +
* - 경우: 리소스별로만
*/
private groupByResource(
sourceData: any[],
config: ScheduleGenerationConfig
): Record<string, any[]> {
const grouped: Record<string, any[]> = {};
const dueDateField = config.source.dueDateField;
for (const item of sourceData) {
const resourceId = item[config.resource.idField];
if (!resourceId) continue;
// 그룹 키 생성: 기준일이 있으면 "리소스ID|기준일", 없으면 "리소스ID"
let groupKey = resourceId;
if (dueDateField && item[dueDateField]) {
// 날짜를 YYYY-MM-DD 형식으로 정규화
const dueDate = new Date(item[dueDateField]).toISOString().split("T")[0];
groupKey = `${resourceId}|${dueDate}`;
}
if (!grouped[groupKey]) {
grouped[groupKey] = [];
}
grouped[groupKey].push(item);
}
console.log("[ScheduleService] 그룹화 결과:", {
groupCount: Object.keys(grouped).length,
groups: Object.keys(grouped),
dueDateField,
});
return grouped;
}
/**
*
* - groupKey : "리소스ID" "리소스ID|기준일(YYYY-MM-DD)"
*/
private generateSchedulesForResource(
groupKey: string,
items: any[],
config: ScheduleGenerationConfig,
period: { start: string; end: string },
companyCode: string
): any[] {
const schedules: any[] = [];
// 그룹 키에서 리소스ID와 기준일 분리
const [resourceId, groupDueDate] = groupKey.split("|");
const resourceName =
items[0]?.[config.resource.nameField] || resourceId;
// 총 수량 계산
const totalQty = items.reduce((sum, item) => {
return sum + (parseFloat(item[config.source.quantityField]) || 0);
}, 0);
if (totalQty <= 0) return schedules;
// 스케줄 규칙 적용
const {
leadTimeDays = 3,
dailyCapacity = totalQty,
workingDays = [1, 2, 3, 4, 5],
} = config.rules;
// 기준일(납기일/마감일) 결정
let dueDate: Date;
if (groupDueDate) {
// 그룹 키에 기준일이 포함된 경우
dueDate = new Date(groupDueDate);
} else if (config.source.dueDateField) {
// 아이템에서 기준일 찾기 (가장 빠른 날짜)
let earliestDate: Date | null = null;
for (const item of items) {
const itemDueDate = item[config.source.dueDateField];
if (itemDueDate) {
const date = new Date(itemDueDate);
if (!earliestDate || date < earliestDate) {
earliestDate = date;
}
}
}
dueDate = earliestDate || new Date(period.end);
} else {
// 기준일이 없으면 기간 종료일 사용
dueDate = new Date(period.end);
}
// 종료일 = 기준일 (납기일에 맞춰 완료)
const endDate = new Date(dueDate);
// 시작일 계산 (종료일에서 리드타임만큼 역산)
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - leadTimeDays);
// 스케줄명 생성 (기준일 포함)
const dueDateStr = dueDate.toISOString().split("T")[0];
const scheduleName = groupDueDate
? `${resourceName} (${dueDateStr})`
: `${resourceName} - ${config.scheduleType}`;
// 스케줄 생성
schedules.push({
schedule_type: config.scheduleType,
schedule_name: scheduleName,
resource_type: config.resource.type,
resource_id: resourceId,
resource_name: resourceName,
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
due_date: dueDate.toISOString(),
plan_qty: totalQty,
status: "PLANNED",
source_table: config.source.tableName,
source_id: items.map((i) => i.id || i.order_no || i.sales_order_no).join(","),
source_group_key: resourceId,
metadata: {
sourceCount: items.length,
dailyCapacity,
leadTimeDays,
workingDays,
groupDueDate: groupDueDate || null,
},
});
console.log("[ScheduleService] 스케줄 생성:", {
groupKey,
resourceId,
resourceName,
dueDate: dueDateStr,
totalQty,
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
});
return schedules;
}
/**
* ( )
*/
private async getExistingSchedules(
scheduleType: string,
resourceIds: string[],
period: { start: string; end: string },
companyCode: string
): Promise<any[]> {
if (resourceIds.length === 0) return [];
const result = await pool.query(
`SELECT * FROM schedule_mng
WHERE schedule_type = $1
AND resource_id = ANY($2)
AND end_date >= $3
AND start_date <= $4
AND (company_code = $5 OR $5 = '*')
AND auto_generated = true`,
[scheduleType, resourceIds, period.start, period.end, companyCode]
);
return result.rows;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -207,27 +207,48 @@ class TableCategoryValueService {
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
NULL::numeric AS "menuObjid",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
// category_values 테이블 사용 (menu_objid 없음)
if (companyCode === "*") {
// 최고 관리자: 모든 값 조회
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
// 최고 관리자: 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 = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (category_values)", { 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) {
@ -619,55 +640,7 @@ class TableCategoryValueService {
}
/**
* ID
*/
private async collectAllChildValueIds(
valueId: number,
companyCode: string
): Promise<number[]> {
const pool = getPool();
const allChildIds: number[] = [];
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
)
SELECT value_id FROM category_tree
`;
params = [valueId];
} else {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values
WHERE parent_value_id = $1 AND company_code = $2
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2
)
SELECT value_id FROM category_tree
`;
params = [valueId, companyCode];
}
const result = await pool.query(query, params);
result.rows.forEach(row => allChildIds.push(row.value_id));
return allChildIds;
}
/**
* ( )
* ( )
*/
async deleteCategoryValue(
valueId: number,
@ -677,74 +650,82 @@ class TableCategoryValueService {
const pool = getPool();
try {
// 1. 자기 자신 + 모든 하위 카테고리 ID 수집
const childValueIds = await this.collectAllChildValueIds(valueId, companyCode);
const allValueIds = [valueId, ...childValueIds];
logger.info("삭제 대상 카테고리 값 수집 완료", {
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
// 1. 사용 여부 확인
const usage = await this.checkCategoryValueUsage(valueId, companyCode);
// 2. 모든 대상 항목의 사용 여부 확인
for (const id of allValueIds) {
const usage = await this.checkCategoryValueUsage(id, companyCode);
if (usage.isUsed) {
let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n";
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.isUsed) {
// 사용 중인 항목 정보 조회
let labelQuery: string;
let labelParams: any[];
if (companyCode === "*") {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
labelParams = [id];
} else {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
labelParams = [id, companyCode];
}
const labelResult = await pool.query(labelQuery, labelParams);
const valueLabel = labelResult.rows[0]?.value_label || `ID:${id}`;
let errorMessage = `카테고리 "${valueLabel}"을(를) 삭제할 수 없습니다.\n`;
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
}
// 3. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allValueIds].reverse();
// 2. 하위 값 체크 (멀티테넌시 적용)
let checkQuery: string;
let checkParams: any[];
for (const id of reversedIds) {
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`;
deleteParams = [id];
} else {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
deleteParams = [id, companyCode];
}
await pool.query(deleteQuery, deleteParams);
if (companyCode === "*") {
// 최고 관리자: 모든 하위 값 체크
checkQuery = `
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
`;
checkParams = [valueId];
} else {
// 일반 회사: 자신의 하위 값만 체크
checkQuery = `
SELECT COUNT(*) as count
FROM table_column_category_values
WHERE parent_value_id = $1
AND company_code = $2
`;
checkParams = [valueId, companyCode];
}
const checkResult = await pool.query(checkQuery, checkParams);
if (parseInt(checkResult.rows[0].count) > 0) {
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
}
// 3. 물리적 삭제 (멀티테넌시 적용)
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
`;
deleteParams = [valueId];
} else {
// 일반 회사: 자신의 카테고리 값만 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
deleteParams = [valueId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
if (result.rowCount === 0) {
throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다");
}
logger.info("카테고리 값 삭제 완료", {
valueId,
companyCode,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);

View File

@ -10,7 +10,7 @@ import {
EntityJoinResponse,
EntityJoinConfig,
} from "../types/tableManagement";
import { WebType } from "../types/v2-web-types";
import { WebType } from "../types/unified-web-types";
import { entityJoinService } from "./entityJoinService";
import { referenceCacheService } from "./referenceCacheService";
@ -27,14 +27,13 @@ export class TableManagementService {
columnName: string
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
try {
// table_type_columns 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
// column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
const result = await query(
`SELECT input_type, code_category
FROM table_type_columns
FROM column_labels
WHERE table_name = $1
AND column_name = $2
AND input_type = 'code'
AND company_code = '*'`,
AND input_type = 'code'`,
[tableName, columnName]
);
@ -115,8 +114,7 @@ export class TableManagementService {
tableName: string,
page: number = 1,
size: number = 50,
companyCode?: string, // 🔥 회사 코드 추가
bustCache: boolean = false // 🔥 캐시 버스팅 옵션
companyCode?: string // 🔥 회사 코드 추가
): Promise<{
columns: ColumnTypeInfo[];
total: number;
@ -126,7 +124,7 @@ export class TableManagementService {
}> {
try {
logger.info(
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}`
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
);
// 캐시 키 생성 (companyCode 포함)
@ -134,37 +132,32 @@ export class TableManagementService {
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 🔥 캐시 버스팅: bustCache가 true면 캐시 무시
if (!bustCache) {
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
return cachedResult;
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
} else {
logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`);
return cachedResult;
}
// 전체 컬럼 수 조회 (캐시 확인)
@ -185,38 +178,37 @@ export class TableManagementService {
const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
// cl: 공통 설정 (company_code = '*'), ttc: 회사별 설정
const rawColumns = companyCode
? await query<any>(
`SELECT
c.column_name as "columnName",
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName",
COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
ttc.input_type as "ttc_input_type",
cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
COALESCE(ttc.description, cl.description, '') as "description",
COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
COALESCE(ttc.code_category, cl.code_category) as "codeCategory",
COALESCE(ttc.code_value, cl.code_value) as "codeValue",
COALESCE(ttc.reference_table, cl.reference_table) as "referenceTable",
COALESCE(ttc.reference_column, cl.reference_column) as "referenceColumn",
COALESCE(ttc.display_column, cl.display_column) as "displayColumn",
COALESCE(ttc.display_order, cl.display_order) as "displayOrder",
COALESCE(ttc.is_visible, cl.is_visible) as "isVisible",
cl.code_category as "codeCategory",
cl.code_value as "codeValue",
cl.reference_table as "referenceTable",
cl.reference_column as "referenceColumn",
cl.display_column as "displayColumn",
cl.display_order as "displayOrder",
cl.is_visible as "isVisible",
dcl.column_label as "displayColumnLabel"
FROM information_schema.columns c
LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4
LEFT JOIN table_type_columns dcl ON COALESCE(ttc.reference_table, cl.reference_table) = dcl.table_name AND COALESCE(ttc.display_column, cl.display_column) = dcl.column_name AND dcl.company_code = '*'
LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
LEFT JOIN (
SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc
@ -239,7 +231,7 @@ export class TableManagementService {
c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings",
COALESCE(cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
@ -256,8 +248,8 @@ export class TableManagementService {
cl.is_visible as "isVisible",
dcl.column_label as "displayColumnLabel"
FROM information_schema.columns c
LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
LEFT JOIN table_type_columns dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name AND dcl.company_code = '*'
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
LEFT JOIN (
SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc
@ -289,46 +281,29 @@ export class TableManagementService {
companyCode,
});
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
if (columnCheck.length > 0) {
// menu_objid 컬럼이 있는 경우
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
} catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
@ -351,7 +326,7 @@ export class TableManagementService {
? Number(column.displayOrder)
: null,
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 table_type_columns에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType,
};
@ -473,51 +448,35 @@ export class TableManagementService {
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
);
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (settings.inputType === "direct" || settings.inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${settings.inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
settings.inputType = "text";
}
// 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName);
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
let detailSettingsStr = settings.detailSettings;
if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) {
detailSettingsStr = JSON.stringify(settings.detailSettings);
}
// column_labels 업데이트 또는 생성
await query(
`INSERT INTO table_type_columns (
`INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
display_column, display_order, is_visible, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value),
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
code_category = EXCLUDED.code_category,
code_value = EXCLUDED.code_value,
reference_table = EXCLUDED.reference_table,
reference_column = EXCLUDED.reference_column,
display_column = EXCLUDED.display_column,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = NOW()`,
[
tableName,
columnName,
settings.columnLabel,
settings.inputType,
detailSettingsStr,
settings.detailSettings,
settings.codeCategory,
settings.codeValue,
settings.referenceTable,
@ -525,17 +484,36 @@ export class TableManagementService {
settings.displayColumn,
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true,
companyCode,
]
);
// 🔥 화면 레이아웃 동기화 (입력 타입 변경 시)
// 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
if (settings.inputType) {
await this.syncScreenLayoutsInputType(
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
let parsedDetailSettings: Record<string, any> | undefined = undefined;
if (settings.detailSettings) {
if (typeof settings.detailSettings === "string") {
try {
parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) {
logger.warn(
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
);
}
} else if (typeof settings.detailSettings === "object") {
parsedDetailSettings = settings.detailSettings as Record<
string,
any
>;
}
}
await this.updateColumnInputType(
tableName,
columnName,
settings.inputType as string,
companyCode
companyCode,
parsedDetailSettings
);
}
@ -683,8 +661,8 @@ export class TableManagementService {
`SELECT id, table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, created_date, updated_date
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'`,
FROM column_labels
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
@ -734,22 +712,12 @@ export class TableManagementService {
inputType?: string
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalWebType = webType;
if (webType === "direct" || webType === "auto") {
logger.warn(
`잘못된 webType 값 감지: ${webType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalWebType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalWebType}`
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
);
// 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType);
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
@ -757,21 +725,20 @@ export class TableManagementService {
...detailSettings,
};
// table_type_columns UPSERT로 업데이트 또는 생성 (company_code = '*' 공통 설정)
// column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용)
await query(
`INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, is_nullable,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, 'Y', '*', NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
`INSERT INTO column_labels (
table_name, column_name, input_type, detail_settings, created_date, updated_date
) VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`,
[tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)]
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
);
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
);
} catch (error) {
logger.error(
@ -796,23 +763,13 @@ export class TableManagementService {
detailSettings?: Record<string, any>
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalInputType = inputType;
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 input_type 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalInputType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalInputType}, company: ${companyCode}`
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
);
// 입력 타입별 기본 상세 설정 생성
const defaultDetailSettings =
this.generateDefaultInputTypeSettings(finalInputType);
this.generateDefaultInputTypeSettings(inputType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
@ -834,7 +791,7 @@ export class TableManagementService {
[
tableName,
columnName,
finalInputType,
inputType,
JSON.stringify(finalDetailSettings),
companyCode,
]
@ -844,7 +801,7 @@ export class TableManagementService {
await this.syncScreenLayoutsInputType(
tableName,
columnName,
finalInputType,
inputType,
companyCode
);
@ -1322,8 +1279,8 @@ export class TableManagementService {
try {
const fileColumns = await query<{ column_name: string }>(
`SELECT column_name
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'file' AND company_code = '*'`,
FROM column_labels
WHERE table_name = $1 AND web_type = 'file'`,
[tableName]
);
@ -1502,31 +1459,6 @@ export class TableManagementService {
const webType = columnInfo.webType;
// 🔧 다중선택 처리: actualValue가 파이프(|)를 포함하고 날짜 타입이 아닌 경우
if (
typeof actualValue === "string" &&
actualValue.includes("|") &&
webType !== "date" &&
webType !== "datetime"
) {
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 웹타입별 검색 조건 구성
switch (webType) {
case "date":
@ -2007,15 +1939,16 @@ export class TableManagementService {
} | null> {
try {
const result = await queryOne<{
web_type: string | null;
input_type: string | null;
code_category: string | null;
reference_table: string | null;
reference_column: string | null;
display_column: string | null;
}>(
`SELECT input_type, code_category, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'
`SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1 AND column_name = $2
LIMIT 1`,
[tableName, columnName]
);
@ -2024,6 +1957,7 @@ export class TableManagementService {
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
{
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
}
);
@ -2035,8 +1969,11 @@ export class TableManagementService {
return null;
}
// web_type이 없으면 input_type을 사용 (레거시 호환)
const webType = result.web_type || result.input_type || "";
const columnInfo = {
webType: result.input_type || "",
webType: webType,
inputType: result.input_type || "",
codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined,
@ -3633,7 +3570,7 @@ export class TableManagementService {
continue;
}
// 🔍 table_type_columns에서 해당 엔티티 설정 찾기
// 🔍 column_labels에서 해당 엔티티 설정 찾기
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
const entityColumnResult = await query<{
column_name: string;
@ -3641,11 +3578,10 @@ export class TableManagementService {
reference_column: string;
}>(
`SELECT column_name, reference_table, reference_column
FROM table_type_columns
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
AND company_code = '*'
LIMIT 1`,
[tableName, refTable]
);
@ -3778,23 +3714,23 @@ export class TableManagementService {
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
await query(
`INSERT INTO table_type_columns (
table_name, column_name, column_label, input_type, detail_settings,
`INSERT INTO column_labels (
table_name, column_name, column_label, web_type, detail_settings,
description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, is_nullable, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
reference_table, reference_column, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
description = COALESCE(EXCLUDED.description, table_type_columns.description),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value),
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
column_label = EXCLUDED.column_label,
web_type = EXCLUDED.web_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
code_category = EXCLUDED.code_category,
code_value = EXCLUDED.code_value,
reference_table = EXCLUDED.reference_table,
reference_column = EXCLUDED.reference_column,
updated_date = NOW()`,
[
tableName,
@ -4169,21 +4105,18 @@ export class TableManagementService {
// table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
// detail_settings 컬럼에 유효하지 않은 JSON이 있을 수 있으므로 안전하게 처리
const rawInputTypes = await query<any>(
`SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(ttc.column_label, ttc.column_name) as "displayName",
COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
CASE
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
WHEN ttc.detail_settings ~ '^\\s*\\{.*\\}\\s*$' THEN ttc.detail_settings::jsonb
ELSE '{}'::jsonb
END as "detailSettings",
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name AND ttc.column_name = cl.column_name
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
@ -4209,46 +4142,31 @@ export class TableManagementService {
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
const mappings = await query<any>(
`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 IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
if (columnCheck.length > 0) {
const mappings = await query<any>(
`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 IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
} catch (mappingError: any) {
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
@ -4362,7 +4280,7 @@ export class TableManagementService {
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/v2-web-types");
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
@ -4838,7 +4756,7 @@ export class TableManagementService {
/**
*
* table_type_columns에서 .
* column_labels에서 .
*
* @param leftTable
* @param rightTable
@ -4878,13 +4796,12 @@ export class TableManagementService {
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM table_type_columns
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 != ''
AND company_code = '*'`,
AND reference_column != ''`,
[rightTable, leftTable]
);
@ -4907,13 +4824,12 @@ export class TableManagementService {
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
FROM table_type_columns
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 != ''
AND company_code = '*'`,
AND reference_column != ''`,
[leftTable, rightTable]
);

View File

@ -5,7 +5,7 @@ export type ComponentType = "container" | "row" | "column" | "widget" | "group";
// 웹 타입 정의
// WebType은 통합 타입에서 import (중복 정의 제거)
import { WebType } from "./v2-web-types";
import { WebType } from "./unified-web-types";
export { WebType };
// 위치 정보

View File

@ -264,7 +264,7 @@ export const WEB_TYPE_VALIDATION_PATTERNS: Record<WebType, RegExp | null> = {
};
// 업데이트된 웹 타입 옵션 (기존 WEB_TYPE_OPTIONS 대체)
export const V2_WEB_TYPE_OPTIONS = [
export const UNIFIED_WEB_TYPE_OPTIONS = [
{
value: "text",
label: "text",

View File

@ -1,263 +0,0 @@
/**
*
*
* screen_layouts_v2 config_overrides를
* componentConfig를 .
*/
// 컴포넌트별 기본값 맵
export const componentDefaults: Record<string, any> = {
"button-primary": {
type: "button-primary",
text: "저장",
actionType: "button",
variant: "primary",
webType: "button",
},
"v2-button-primary": {
type: "v2-button-primary",
text: "저장",
actionType: "button",
variant: "primary",
webType: "button",
},
"text-input": {
type: "text-input",
webType: "text",
format: "none",
multiline: false,
placeholder: "텍스트를 입력하세요",
},
"number-input": {
type: "number-input",
webType: "number",
placeholder: "숫자를 입력하세요",
},
"date-input": {
type: "date-input",
webType: "date",
format: "YYYY-MM-DD",
showTime: false,
placeholder: "날짜를 선택하세요",
},
"select-basic": {
type: "select-basic",
webType: "code",
placeholder: "선택하세요",
options: [],
},
"file-upload": {
type: "file-upload",
webType: "file",
placeholder: "입력하세요",
},
"table-list": {
type: "table-list",
webType: "table",
displayMode: "table",
showHeader: true,
showFooter: true,
autoLoad: true,
autoWidth: true,
stickyHeader: false,
height: "auto",
columns: [],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
horizontalScroll: {
enabled: false,
},
filter: {
enabled: false,
filters: [],
},
actions: {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
tableStyle: {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: false,
hoverEffect: true,
borderStyle: "light",
},
},
"v2-table-list": {
type: "v2-table-list",
webType: "table",
displayMode: "table",
showHeader: true,
showFooter: true,
autoLoad: true,
autoWidth: true,
stickyHeader: false,
height: "auto",
columns: [],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
horizontalScroll: { enabled: false },
filter: { enabled: false, filters: [] },
actions: { showActions: false, actions: [], bulkActions: false, bulkActionList: [] },
tableStyle: { theme: "default", headerStyle: "default", rowHeight: "normal", alternateRows: false, hoverEffect: true, borderStyle: "light" },
},
"table-search-widget": { type: "table-search-widget", webType: "custom" },
"split-panel-layout": { type: "split-panel-layout", webType: "text", autoLoad: true, resizable: true, splitRatio: 30 },
"v2-split-panel-layout": { type: "v2-split-panel-layout", webType: "custom" },
"tabs-widget": { type: "tabs-widget", webType: "text", tabs: [] },
"v2-tabs-widget": { type: "v2-tabs-widget", webType: "custom", tabs: [] },
"flow-widget": { type: "flow-widget", webType: "text", displayMode: "horizontal", allowDataMove: false, showStepCount: true },
"entity-search-input": { type: "entity-search-input", webType: "entity" },
"autocomplete-search-input": { type: "autocomplete-search-input", webType: "entity" },
"v2-list": { type: "v2-list", webType: "table" },
"modal-repeater-table": { type: "modal-repeater-table", webType: "table", columns: [], multiSelect: true },
"category-manager": { type: "category-manager", webType: "custom" },
"numbering-rule": { type: "numbering-rule", webType: "text" },
"conditional-container": { type: "conditional-container", webType: "custom" },
"selected-items-detail-input": { type: "selected-items-detail-input", webType: "custom" },
"text-display": { type: "text-display", webType: "text" },
"image-widget": { type: "image-widget", webType: "image" },
"textarea-basic": { type: "textarea-basic", webType: "textarea", placeholder: "내용을 입력하세요" },
"checkbox-basic": { type: "checkbox-basic", webType: "checkbox" },
"radio-basic": { type: "radio-basic", webType: "radio" },
"divider-line": { type: "divider-line", webType: "custom" },
"section-paper": { type: "section-paper", webType: "custom" },
"section-card": { type: "section-card", webType: "custom" },
"card-display": { type: "card-display", webType: "custom" },
"pivot-grid": { type: "pivot-grid", webType: "table" },
"rack-structure": { type: "rack-structure", webType: "custom" },
"v2-rack-structure": { type: "v2-rack-structure", webType: "custom" },
"location-swap-selector": { type: "location-swap-selector", webType: "custom" },
"screen-split-panel": { type: "screen-split-panel", webType: "custom" },
"universal-form-modal": { type: "universal-form-modal", webType: "custom" },
"repeater-field-group": { type: "repeater-field-group", webType: "custom" },
"repeat-screen-modal": { type: "repeat-screen-modal", webType: "custom" },
"related-data-buttons": { type: "related-data-buttons", webType: "custom" },
"split-panel-layout2": { type: "split-panel-layout2", webType: "custom" },
"v2-input": { type: "v2-input", webType: "text" },
"v2-select": { type: "v2-select", webType: "select" },
"v2-date": { type: "v2-date", webType: "date" },
"v2-repeater": { type: "v2-repeater", webType: "custom" },
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
};
/**
*
*/
export function getComponentDefaults(componentType: string): any {
return componentDefaults[componentType] || {};
}
/**
* 복원: 기본값 + overrides
*
* @param componentType
* @param overrides (config_overrides)
* @returns
*/
export function reconstructConfig(componentType: string, overrides: any): any {
const defaults = getComponentDefaults(componentType);
if (!overrides || Object.keys(overrides).length === 0) {
return { ...defaults };
}
// _originalKeys가 있으면 해당 키만 복원
const originalKeys = overrides._originalKeys;
if (originalKeys && Array.isArray(originalKeys)) {
const result: any = {};
for (const key of originalKeys) {
if (key === "_originalKeys") continue;
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
result[key] = overrides[key];
} else if (Object.prototype.hasOwnProperty.call(defaults, key)) {
result[key] = defaults[key];
}
}
return result;
}
// _originalKeys가 없으면 단순 병합
return { ...defaults, ...overrides };
}
/**
*
*/
export function isDeepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return a === b;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!isDeepEqual(a[i], b[i])) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (!isDeepEqual(a[key], b[key])) return false;
}
return true;
}
/**
* 추출: 현재
*/
export function extractConfigDiff(componentType: string, currentConfig: any): any {
const defaults = getComponentDefaults(componentType);
if (!currentConfig) return {};
const diff: any = {
_originalKeys: Object.keys(currentConfig),
};
for (const key of Object.keys(currentConfig)) {
const defaultVal = defaults[key];
const currentVal = currentConfig[key];
if (!isDeepEqual(defaultVal, currentVal)) {
diff[key] = currentVal;
}
}
return diff;
}

View File

@ -1,83 +0,0 @@
# 078 마이그레이션 실행 가이드
## 실행할 파일 (순서대로)
1. **078_create_production_plan_tables.sql** - 테이블 생성
2. **078b_insert_production_plan_sample_data.sql** - 샘플 데이터
3. **078c_insert_production_plan_screen.sql** - 화면 정의 및 레이아웃
## 실행 방법
### 방법 1: psql 명령어 (터미널)
```bash
# 테이블 생성
psql -h localhost -U postgres -d wace -f db/migrations/078_create_production_plan_tables.sql
# 샘플 데이터 입력
psql -h localhost -U postgres -d wace -f db/migrations/078b_insert_production_plan_sample_data.sql
```
### 방법 2: DBeaver / pgAdmin에서 실행
1. DB 연결 후 SQL 에디터 열기
2. `078_create_production_plan_tables.sql` 내용 복사 & 실행
3. `078b_insert_production_plan_sample_data.sql` 내용 복사 & 실행
### 방법 3: Docker 환경
```bash
# Docker 컨테이너 내부에서 실행
docker exec -i <container_name> psql -U postgres -d wace < db/migrations/078_create_production_plan_tables.sql
docker exec -i <container_name> psql -U postgres -d wace < db/migrations/078b_insert_production_plan_sample_data.sql
```
## 생성되는 테이블
| 테이블명 | 설명 |
|---------|------|
| `equipment_info` | 설비 정보 마스터 |
| `production_plan_mng` | 생산계획 관리 |
| `production_plan_order_rel` | 생산계획-수주 연결 |
## 생성되는 화면
| 화면 | 설명 |
|------|------|
| 생산계획관리 (메인) | 생산계획 목록 조회/등록/수정/삭제 |
| 생산계획 등록/수정 (모달) | 생산계획 상세 입력 폼 |
## 확인 쿼리
```sql
-- 테이블 생성 확인
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('equipment_info', 'production_plan_mng', 'production_plan_order_rel');
-- 샘플 데이터 확인
SELECT * FROM equipment_info;
SELECT * FROM production_plan_mng;
-- 화면 생성 확인
SELECT id, screen_name, screen_code, table_name
FROM screen_definitions
WHERE screen_code LIKE '%PP%';
-- 레이아웃 확인
SELECT sl.id, sd.screen_name, sl.layout_name
FROM screen_layouts_v2 sl
JOIN screen_definitions sd ON sl.screen_id = sd.id
WHERE sd.screen_code LIKE '%PP%';
```
## 메뉴 연결 (수동 작업 필요)
화면 생성 후, 메뉴에 연결하려면 `menu_info` 테이블에서 해당 메뉴의 `screen_id`를 업데이트하세요:
```sql
-- 예시: 생산관리 > 생산계획관리 메뉴에 연결
UPDATE menu_info
SET screen_id = (SELECT id FROM screen_definitions WHERE screen_code = 'TOPSEAL_PP_MAIN')
WHERE menu_name = '생산계획관리' AND company_code = 'TOPSEAL';
```

View File

@ -1,179 +0,0 @@
{
"version": "2.0",
"components": [
{
"id": "comp_search",
"url": "@/lib/registry/components/v2-table-search-widget",
"size": { "width": 1920, "height": 80 },
"position": { "x": 0, "y": 20, "z": 1 },
"overrides": {
"type": "v2-table-search-widget",
"label": "Search Filter",
"webTypeConfig": {}
},
"displayOrder": 0
},
{
"id": "comp_table",
"url": "@/lib/registry/components/v2-table-list",
"size": { "width": 1920, "height": 800 },
"position": { "x": 0, "y": 150, "z": 1 },
"overrides": {
"type": "v2-table-list",
"label": "Sales Order List",
"filter": { "enabled": true, "filters": [] },
"height": "auto",
"actions": { "actions": [], "bulkActions": false, "showActions": false },
"columns": [
{ "align": "left", "order": 0, "format": "text", "visible": true, "sortable": true, "columnName": "order_no", "searchable": true, "displayName": "Order No" },
{ "align": "left", "order": 1, "format": "text", "visible": true, "sortable": true, "columnName": "partner_id", "searchable": true, "displayName": "Customer" },
{ "align": "left", "order": 2, "format": "text", "visible": true, "sortable": true, "columnName": "part_code", "searchable": true, "displayName": "Part Code" },
{ "align": "left", "order": 3, "format": "text", "visible": true, "sortable": true, "columnName": "part_name", "searchable": true, "displayName": "Part Name" },
{ "align": "left", "order": 4, "format": "text", "visible": true, "sortable": true, "columnName": "spec", "searchable": true, "displayName": "Spec" },
{ "align": "left", "order": 5, "format": "text", "visible": true, "sortable": true, "columnName": "material", "searchable": true, "displayName": "Material" },
{ "align": "right", "order": 6, "format": "number", "visible": true, "sortable": true, "columnName": "order_qty", "searchable": false, "displayName": "Order Qty" },
{ "align": "right", "order": 7, "format": "number", "visible": true, "sortable": true, "columnName": "ship_qty", "searchable": false, "displayName": "Ship Qty" },
{ "align": "right", "order": 8, "format": "number", "visible": true, "sortable": true, "columnName": "balance_qty", "searchable": false, "displayName": "Balance" },
{ "align": "right", "order": 9, "format": "number", "visible": true, "sortable": true, "columnName": "inventory_qty", "searchable": false, "displayName": "Stock" },
{ "align": "right", "order": 10, "format": "number", "visible": true, "sortable": true, "columnName": "plan_ship_qty", "searchable": false, "displayName": "Plan Ship Qty" },
{ "align": "right", "order": 11, "format": "number", "visible": true, "sortable": true, "columnName": "unit_price", "searchable": false, "displayName": "Unit Price" },
{ "align": "right", "order": 12, "format": "number", "visible": true, "sortable": true, "columnName": "total_amount", "searchable": false, "displayName": "Amount" },
{ "align": "left", "order": 13, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_partner_id", "searchable": true, "displayName": "Delivery Partner" },
{ "align": "left", "order": 14, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_address", "searchable": true, "displayName": "Delivery Address" },
{ "align": "center", "order": 15, "format": "text", "visible": true, "sortable": true, "columnName": "shipping_method", "searchable": true, "displayName": "Shipping Method" },
{ "align": "center", "order": 16, "format": "date", "visible": true, "sortable": true, "columnName": "due_date", "searchable": false, "displayName": "Due Date" },
{ "align": "center", "order": 17, "format": "date", "visible": true, "sortable": true, "columnName": "order_date", "searchable": false, "displayName": "Order Date" },
{ "align": "center", "order": 18, "format": "text", "visible": true, "sortable": true, "columnName": "status", "searchable": true, "displayName": "Status" },
{ "align": "left", "order": 19, "format": "text", "visible": true, "sortable": true, "columnName": "manager_name", "searchable": true, "displayName": "Manager" },
{ "align": "left", "order": 20, "format": "text", "visible": true, "sortable": true, "columnName": "memo", "searchable": true, "displayName": "Memo" }
],
"autoLoad": true,
"checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true },
"pagination": { "enabled": true, "pageSize": 20, "showPageInfo": true, "pageSizeOptions": [10, 20, 50, 100], "showSizeSelector": true },
"showFooter": true,
"showHeader": true,
"tableStyle": { "theme": "default", "rowHeight": "normal", "borderStyle": "light", "headerStyle": "default", "hoverEffect": true, "alternateRows": true },
"displayMode": "table",
"stickyHeader": false,
"selectedTable": "sales_order_mng",
"webTypeConfig": {},
"horizontalScroll": { "enabled": true, "maxColumnWidth": 300, "minColumnWidth": 80, "maxVisibleColumns": 10 }
},
"displayOrder": 1
},
{
"id": "comp_btn_upload",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1610, "y": 30, "z": 1 },
"overrides": {
"text": "Excel Upload",
"type": "v2-button-primary",
"label": "Excel Upload Button",
"action": { "type": "excel_upload" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 2
},
{
"id": "comp_btn_download",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 110, "height": 40 },
"position": { "x": 1720, "y": 30, "z": 1 },
"overrides": {
"text": "Excel Download",
"type": "v2-button-primary",
"label": "Excel Download Button",
"action": { "type": "excel_download" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 3
},
{
"id": "comp_btn_register",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1500, "y": 100, "z": 1 },
"overrides": {
"text": "New Order",
"type": "v2-button-primary",
"label": "New Order Button",
"action": {
"type": "modal",
"modalSize": "lg",
"modalTitle": "New Sales Order",
"targetScreenId": 3732,
"successMessage": "Saved successfully.",
"errorMessage": "Error saving."
},
"variant": "success",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 4
},
{
"id": "comp_btn_edit",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1610, "y": 100, "z": 1 },
"overrides": {
"text": "Edit",
"type": "v2-button-primary",
"label": "Edit Button",
"action": {
"type": "edit",
"modalSize": "lg",
"modalTitle": "Edit Sales Order",
"targetScreenId": 3732,
"successMessage": "Updated successfully.",
"errorMessage": "Error updating."
},
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 5
},
{
"id": "comp_btn_delete",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1700, "y": 100, "z": 1 },
"overrides": {
"text": "Delete",
"type": "v2-button-primary",
"label": "Delete Button",
"action": {
"type": "delete",
"successMessage": "Deleted successfully.",
"errorMessage": "Error deleting."
},
"variant": "danger",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 6
},
{
"id": "comp_btn_shipment",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1790, "y": 100, "z": 1 },
"overrides": {
"text": "Shipment Plan",
"type": "v2-button-primary",
"label": "Shipment Plan Button",
"action": { "type": "custom" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 7
}
]
}

View File

@ -1,179 +0,0 @@
{
"version": "2.0",
"components": [
{
"id": "comp_search",
"url": "@/lib/registry/components/v2-table-search-widget",
"size": { "width": 1920, "height": 80 },
"position": { "x": 0, "y": 20, "z": 1 },
"overrides": {
"type": "v2-table-search-widget",
"label": "검색 필터",
"webTypeConfig": {}
},
"displayOrder": 0
},
{
"id": "comp_table",
"url": "@/lib/registry/components/v2-table-list",
"size": { "width": 1920, "height": 800 },
"position": { "x": 0, "y": 150, "z": 1 },
"overrides": {
"type": "v2-table-list",
"label": "수주 목록",
"filter": { "enabled": true, "filters": [] },
"height": "auto",
"actions": { "actions": [], "bulkActions": false, "showActions": false },
"columns": [
{ "align": "left", "order": 0, "format": "text", "visible": true, "sortable": true, "columnName": "order_no", "searchable": true, "displayName": "수주번호" },
{ "align": "left", "order": 1, "format": "text", "visible": true, "sortable": true, "columnName": "partner_id", "searchable": true, "displayName": "거래처" },
{ "align": "left", "order": 2, "format": "text", "visible": true, "sortable": true, "columnName": "part_code", "searchable": true, "displayName": "품목코드" },
{ "align": "left", "order": 3, "format": "text", "visible": true, "sortable": true, "columnName": "part_name", "searchable": true, "displayName": "품명" },
{ "align": "left", "order": 4, "format": "text", "visible": true, "sortable": true, "columnName": "spec", "searchable": true, "displayName": "규격" },
{ "align": "left", "order": 5, "format": "text", "visible": true, "sortable": true, "columnName": "material", "searchable": true, "displayName": "재질" },
{ "align": "right", "order": 6, "format": "number", "visible": true, "sortable": true, "columnName": "order_qty", "searchable": false, "displayName": "수주수량" },
{ "align": "right", "order": 7, "format": "number", "visible": true, "sortable": true, "columnName": "ship_qty", "searchable": false, "displayName": "출하수량" },
{ "align": "right", "order": 8, "format": "number", "visible": true, "sortable": true, "columnName": "balance_qty", "searchable": false, "displayName": "잔량" },
{ "align": "right", "order": 9, "format": "number", "visible": true, "sortable": true, "columnName": "inventory_qty", "searchable": false, "displayName": "현재고" },
{ "align": "right", "order": 10, "format": "number", "visible": true, "sortable": true, "columnName": "plan_ship_qty", "searchable": false, "displayName": "출하계획량" },
{ "align": "right", "order": 11, "format": "number", "visible": true, "sortable": true, "columnName": "unit_price", "searchable": false, "displayName": "단가" },
{ "align": "right", "order": 12, "format": "number", "visible": true, "sortable": true, "columnName": "total_amount", "searchable": false, "displayName": "금액" },
{ "align": "left", "order": 13, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_partner_id", "searchable": true, "displayName": "납품처" },
{ "align": "left", "order": 14, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_address", "searchable": true, "displayName": "납품장소" },
{ "align": "center", "order": 15, "format": "text", "visible": true, "sortable": true, "columnName": "shipping_method", "searchable": true, "displayName": "배송방법" },
{ "align": "center", "order": 16, "format": "date", "visible": true, "sortable": true, "columnName": "due_date", "searchable": false, "displayName": "납기일" },
{ "align": "center", "order": 17, "format": "date", "visible": true, "sortable": true, "columnName": "order_date", "searchable": false, "displayName": "수주일" },
{ "align": "center", "order": 18, "format": "text", "visible": true, "sortable": true, "columnName": "status", "searchable": true, "displayName": "상태" },
{ "align": "left", "order": 19, "format": "text", "visible": true, "sortable": true, "columnName": "manager_name", "searchable": true, "displayName": "담당자" },
{ "align": "left", "order": 20, "format": "text", "visible": true, "sortable": true, "columnName": "memo", "searchable": true, "displayName": "메모" }
],
"autoLoad": true,
"checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true },
"pagination": { "enabled": true, "pageSize": 20, "showPageInfo": true, "pageSizeOptions": [10, 20, 50, 100], "showSizeSelector": true },
"showFooter": true,
"showHeader": true,
"tableStyle": { "theme": "default", "rowHeight": "normal", "borderStyle": "light", "headerStyle": "default", "hoverEffect": true, "alternateRows": true },
"displayMode": "table",
"stickyHeader": false,
"selectedTable": "sales_order_mng",
"webTypeConfig": {},
"horizontalScroll": { "enabled": true, "maxColumnWidth": 300, "minColumnWidth": 80, "maxVisibleColumns": 10 }
},
"displayOrder": 1
},
{
"id": "comp_btn_upload",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1610, "y": 30, "z": 1 },
"overrides": {
"text": "엑셀 업로드",
"type": "v2-button-primary",
"label": "엑셀 업로드 버튼",
"action": { "type": "excel_upload" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 2
},
{
"id": "comp_btn_download",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 110, "height": 40 },
"position": { "x": 1720, "y": 30, "z": 1 },
"overrides": {
"text": "엑셀 다운로드",
"type": "v2-button-primary",
"label": "엑셀 다운로드 버튼",
"action": { "type": "excel_download" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 3
},
{
"id": "comp_btn_register",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1500, "y": 100, "z": 1 },
"overrides": {
"text": "수주 등록",
"type": "v2-button-primary",
"label": "수주 등록 버튼",
"action": {
"type": "modal",
"modalSize": "lg",
"modalTitle": "수주 등록",
"targetScreenId": 3732,
"successMessage": "저장되었습니다.",
"errorMessage": "저장 중 오류가 발생했습니다."
},
"variant": "success",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 4
},
{
"id": "comp_btn_edit",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1610, "y": 100, "z": 1 },
"overrides": {
"text": "수정",
"type": "v2-button-primary",
"label": "수정 버튼",
"action": {
"type": "edit",
"modalSize": "lg",
"modalTitle": "수주 수정",
"targetScreenId": 3732,
"successMessage": "수정되었습니다.",
"errorMessage": "수정 중 오류가 발생했습니다."
},
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 5
},
{
"id": "comp_btn_delete",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1700, "y": 100, "z": 1 },
"overrides": {
"text": "삭제",
"type": "v2-button-primary",
"label": "삭제 버튼",
"action": {
"type": "delete",
"successMessage": "삭제되었습니다.",
"errorMessage": "삭제 중 오류가 발생했습니다."
},
"variant": "danger",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 6
},
{
"id": "comp_btn_shipment",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1790, "y": 100, "z": 1 },
"overrides": {
"text": "출하계획",
"type": "v2-button-primary",
"label": "출하계획 버튼",
"action": { "type": "custom" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 7
}
]
}

View File

@ -1,254 +0,0 @@
{
"version": "2.0",
"components": [
{
"id": "comp_order_no",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 20, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Order No",
"fieldName": "order_no",
"placeholder": "Enter order number",
"required": true
},
"displayOrder": 0
},
{
"id": "comp_order_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 20, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "Order Date",
"fieldName": "order_date",
"required": true
},
"displayOrder": 1
},
{
"id": "comp_partner_id",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 100, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Customer",
"fieldName": "partner_id",
"required": true,
"config": {
"mode": "dropdown",
"source": "table",
"sourceTable": "customer_mng",
"valueField": "id",
"labelField": "name"
}
},
"displayOrder": 2
},
{
"id": "comp_part_code",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 100, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Part Code",
"fieldName": "part_code",
"placeholder": "Enter part code",
"required": true
},
"displayOrder": 3
},
{
"id": "comp_part_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Part Name",
"fieldName": "part_name",
"placeholder": "Enter part name"
},
"displayOrder": 4
},
{
"id": "comp_spec",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Spec",
"fieldName": "spec",
"placeholder": "Enter spec"
},
"displayOrder": 5
},
{
"id": "comp_material",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Material",
"fieldName": "material",
"placeholder": "Enter material"
},
"displayOrder": 6
},
{
"id": "comp_order_qty",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "Order Qty",
"fieldName": "order_qty",
"placeholder": "Enter order quantity",
"required": true
},
"displayOrder": 7
},
{
"id": "comp_unit_price",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 340, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "Unit Price",
"fieldName": "unit_price",
"placeholder": "Enter unit price",
"required": true
},
"displayOrder": 8
},
{
"id": "comp_due_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 340, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "Due Date",
"fieldName": "due_date"
},
"displayOrder": 9
},
{
"id": "comp_status",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Status",
"fieldName": "status",
"required": true,
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "수주", "label": "수주" },
{ "value": "진행중", "label": "진행중" },
{ "value": "완료", "label": "완료" },
{ "value": "취소", "label": "취소" }
]
}
},
"displayOrder": 10
},
{
"id": "comp_shipping_method",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Shipping Method",
"fieldName": "shipping_method",
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "택배", "label": "택배" },
{ "value": "화물", "label": "화물" },
{ "value": "직송", "label": "직송" },
{ "value": "퀵서비스", "label": "퀵서비스" },
{ "value": "해상운송", "label": "해상운송" }
]
}
},
"displayOrder": 11
},
{
"id": "comp_delivery_address",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 60 },
"position": { "x": 20, "y": 500, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Delivery Address",
"fieldName": "delivery_address",
"placeholder": "Enter delivery address"
},
"displayOrder": 12
},
{
"id": "comp_manager_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 580, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Manager",
"fieldName": "manager_name",
"placeholder": "Enter manager name"
},
"displayOrder": 13
},
{
"id": "comp_memo",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 80 },
"position": { "x": 20, "y": 660, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "textarea",
"label": "Memo",
"fieldName": "memo",
"placeholder": "Enter memo"
},
"displayOrder": 14
},
{
"id": "comp_btn_save",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 540, "y": 760, "z": 1 },
"overrides": {
"text": "Save",
"type": "v2-button-primary",
"label": "Save Button",
"action": {
"type": "save",
"closeModalAfterSave": true,
"refreshParentTable": true,
"successMessage": "Saved successfully.",
"errorMessage": "Error saving."
},
"variant": "primary",
"actionType": "button"
},
"displayOrder": 15
}
]
}

View File

@ -1,254 +0,0 @@
{
"version": "2.0",
"components": [
{
"id": "comp_order_no",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 20, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "수주번호",
"fieldName": "order_no",
"placeholder": "수주번호를 입력하세요",
"required": true
},
"displayOrder": 0
},
{
"id": "comp_order_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 20, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "수주일",
"fieldName": "order_date",
"required": true
},
"displayOrder": 1
},
{
"id": "comp_partner_id",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 100, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "거래처",
"fieldName": "partner_id",
"required": true,
"config": {
"mode": "dropdown",
"source": "table",
"sourceTable": "customer_mng",
"valueField": "id",
"labelField": "name"
}
},
"displayOrder": 2
},
{
"id": "comp_part_code",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 100, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "품목코드",
"fieldName": "part_code",
"placeholder": "품목코드를 입력하세요",
"required": true
},
"displayOrder": 3
},
{
"id": "comp_part_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "품명",
"fieldName": "part_name",
"placeholder": "품명을 입력하세요"
},
"displayOrder": 4
},
{
"id": "comp_spec",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "규격",
"fieldName": "spec",
"placeholder": "규격을 입력하세요"
},
"displayOrder": 5
},
{
"id": "comp_material",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "재질",
"fieldName": "material",
"placeholder": "재질을 입력하세요"
},
"displayOrder": 6
},
{
"id": "comp_order_qty",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "수주수량",
"fieldName": "order_qty",
"placeholder": "수주수량을 입력하세요",
"required": true
},
"displayOrder": 7
},
{
"id": "comp_unit_price",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 340, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "단가",
"fieldName": "unit_price",
"placeholder": "단가를 입력하세요",
"required": true
},
"displayOrder": 8
},
{
"id": "comp_due_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 340, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "납기일",
"fieldName": "due_date"
},
"displayOrder": 9
},
{
"id": "comp_status",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "상태",
"fieldName": "status",
"required": true,
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "수주", "label": "수주" },
{ "value": "진행중", "label": "진행중" },
{ "value": "완료", "label": "완료" },
{ "value": "취소", "label": "취소" }
]
}
},
"displayOrder": 10
},
{
"id": "comp_shipping_method",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "배송방법",
"fieldName": "shipping_method",
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "택배", "label": "택배" },
{ "value": "화물", "label": "화물" },
{ "value": "직송", "label": "직송" },
{ "value": "퀵서비스", "label": "퀵서비스" },
{ "value": "해상운송", "label": "해상운송" }
]
}
},
"displayOrder": 11
},
{
"id": "comp_delivery_address",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 60 },
"position": { "x": 20, "y": 500, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "납품장소",
"fieldName": "delivery_address",
"placeholder": "납품장소를 입력하세요"
},
"displayOrder": 12
},
{
"id": "comp_manager_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 580, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "담당자",
"fieldName": "manager_name",
"placeholder": "담당자를 입력하세요"
},
"displayOrder": 13
},
{
"id": "comp_memo",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 80 },
"position": { "x": 20, "y": 660, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "textarea",
"label": "메모",
"fieldName": "memo",
"placeholder": "메모를 입력하세요"
},
"displayOrder": 14
},
{
"id": "comp_btn_save",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 540, "y": 760, "z": 1 },
"overrides": {
"text": "저장",
"type": "v2-button-primary",
"label": "저장 버튼",
"action": {
"type": "save",
"closeModalAfterSave": true,
"refreshParentTable": true,
"successMessage": "저장되었습니다.",
"errorMessage": "저장 중 오류가 발생했습니다."
},
"variant": "primary",
"actionType": "button"
},
"displayOrder": 15
}
]
}

View File

@ -5,7 +5,7 @@ services:
frontend:
build:
context: ./frontend
dockerfile: ../docker/dev/frontend.Dockerfile
dockerfile: Dockerfile.dev
container_name: pms-frontend-win
ports:
- "9771:3000"

View File

@ -3,9 +3,9 @@ FROM node:20-bookworm-slim
WORKDIR /app
# 시스템 패키지 설치 (curl: 헬스 체크용)
# 시스템 패키지 설치
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# package.json 복사 및 의존성 설치 (개발 의존성 포함)

View File

@ -16,5 +16,5 @@ COPY . .
# 포트 노출
EXPOSE 3000
# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지)
CMD ["npm", "run", "dev:docker"]
# 개발 서버 시작 (Docker에서는 포트 3000 사용)
CMD ["npm", "run", "dev", "--", "-p", "3000"]

View File

@ -1,548 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PLM 데이터베이스 구조 다이어그램</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 { color: #333; border-bottom: 2px solid #4a90d9; padding-bottom: 10px; }
h2 { color: #4a90d9; margin-top: 40px; }
h3 { color: #666; }
.diagram-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 20px 0;
overflow-x: auto;
}
.mermaid { text-align: center; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #4a90d9; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
.info { background: #e7f3ff; padding: 10px; border-radius: 4px; margin: 10px 0; }
</style>
</head>
<body>
<h1>PLM 데이터베이스 구조 다이어그램</h1>
<div class="info">
<strong>생성일:</strong> 2026-01-22 | <strong>총 테이블:</strong> 164개 | <strong>코드 기반 관계 분석 완료</strong>
</div>
<h2>사용자 화면 플로우 (User Flow)</h2>
<h3>1. 로그인 → 메뉴 → 화면 접근 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph LOGIN["🔐 로그인"]
A[사용자 로그인] --> B{user_info 인증}
B -->|성공| C[company_code 확인]
B -->|실패| D[login_access_log 기록]
end
subgraph COMPANY["🏢 회사 분기"]
C --> E{company_code 타입}
E -->|"*"| F[최고관리자 SUPER_ADMIN]
E -->|회사코드| G[회사관리자/일반사용자]
F --> H[company_mng 회사정보 조회]
G --> H
H --> I[JWT 토큰 발급 + companyCode 포함]
end
subgraph AUTH["👤 권한 확인"]
I --> J[authority_sub_user 조회]
J --> K[authority_master 권한 확인]
end
subgraph MENU["📋 메뉴 로딩"]
K --> L[rel_menu_auth 메뉴권한 조회]
L --> M[menu_info 메뉴 목록]
M -->|company_code 필터| N[해당 회사 메뉴만 표시]
end
subgraph SCREEN["📱 화면 렌더링"]
N -->|메뉴 클릭| O[screen_menu_assignments 조회]
O --> P[screen_definitions 화면정의]
P --> Q[screen_layouts 레이아웃]
Q --> R[table_type_columns 컬럼정보]
R -->|company_code 필터| S[해당 회사 데이터만 조회]
end
</div>
</div>
<h3>2. Low-code 화면 데이터 조회 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph USER["👤 사용자 액션"]
A[화면 접속] --> B[데이터 조회 요청]
end
subgraph SCREEN_DEF["📱 화면 정의 조회"]
B --> C[screen_definitions]
C --> D[screen_layouts]
D --> E{위젯 타입 확인}
end
subgraph TABLE_INFO["🏷️ 테이블 정보"]
E -->|테이블 위젯| F[table_type_columns]
F --> G[table_labels 라벨]
F --> H[table_column_category_values 카테고리]
F --> I[table_relationships 관계]
end
subgraph DATA_QUERY["📊 데이터 조회"]
G --> J[동적 SQL 생성]
H --> J
I --> J
J --> K[실제 비즈니스 테이블 조회]
K --> L[데이터 반환]
end
subgraph RENDER["🖥️ 화면 표시"]
L --> M[그리드/폼에 데이터 바인딩]
M --> N[사용자에게 표시]
end
</div>
</div>
<h3>3. 플로우 시스템 데이터 이동 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph FLOW_DEF["🔄 플로우 정의"]
A[flow_definition] --> B[flow_step]
B --> C[flow_step_connection]
end
subgraph USER_ACTION["👤 사용자 액션"]
D[데이터 선택] --> E[이동 버튼 클릭]
end
subgraph MOVE_PROCESS["📤 데이터 이동"]
E --> F{flow_step_connection 다음 스텝 확인}
F --> G[flow_data_mapping 매핑]
G --> H[소스 테이블에서 데이터 복사]
H --> I[타겟 테이블에 INSERT]
end
subgraph LOGGING["📝 로깅"]
I --> J[flow_audit_log 기록]
J --> K[flow_data_status 상태 업데이트]
end
subgraph RESULT["✅ 결과"]
K --> L[화면 새로고침]
L --> M[이동된 데이터 표시]
end
</div>
</div>
<h3>4. 배치 실행 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph TRIGGER["⏰ 트리거"]
A[스케줄러 cron] --> B[batch_configs 조회]
B --> C{활성화 여부}
end
subgraph CONNECTION["🔌 연결"]
C -->|활성| D[external_db_connections]
D --> E[외부 DB 연결]
end
subgraph MAPPING["🗺️ 매핑"]
E --> F[batch_mappings 조회]
F --> G[소스 테이블 → 타겟 테이블]
end
subgraph EXECUTION["⚡ 실행"]
G --> H[외부 DB에서 데이터 조회]
H --> I[내부 DB에 동기화]
I --> J[batch_execution_logs 기록]
end
subgraph RESULT["📊 결과"]
J --> K{성공/실패}
K -->|성공| L[다음 스케줄 대기]
K -->|실패| M[에러 로그 기록]
end
</div>
</div>
<h3>5. 화면 간 데이터 전달 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph PARENT["📱 부모 화면"]
A[screen_definitions A] --> B[그리드에서 행 선택]
B --> C[선택된 데이터]
end
subgraph TRANSFER["🔗 데이터 전달"]
C --> D[screen_embedding 관계 확인]
D --> E[screen_data_transfer 설정]
E --> F{전달 필드 매핑}
end
subgraph CHILD["📱 자식 화면"]
F --> G[screen_definitions B]
G --> H[필터 조건으로 적용]
H --> I[관련 데이터만 조회]
I --> J[자식 화면에 표시]
end
</div>
</div>
<h3>6. 캐스케이딩 선택 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph SELECT1["1⃣ 첫 번째 선택"]
A[사용자가 대분류 선택] --> B[cascading_hierarchy_group]
end
subgraph CASCADE["🔗 캐스케이딩"]
B --> C[cascading_hierarchy_level 조회]
C --> D[cascading_relation 관계 확인]
D --> E[하위 레벨 옵션 필터링]
end
subgraph SELECT2["2⃣ 두 번째 선택"]
E --> F[중분류 옵션만 표시]
F --> G[사용자가 중분류 선택]
end
subgraph SELECT3["3⃣ 세 번째 선택"]
G --> H[소분류 옵션 필터링]
H --> I[소분류 옵션만 표시]
I --> J[최종 선택 완료]
end
subgraph AUTOFILL["✨ 자동 채움"]
J --> K[cascading_auto_fill_mapping]
K --> L[관련 필드 자동 입력]
end
</div>
</div>
<hr/>
<h2>핵심 테이블 관계도 (ER Diagram)</h2>
<h3>1. 사용자/권한 시스템</h3>
<div class="diagram-container">
<div class="mermaid">
erDiagram
company_mng ||--o{ user_info : "company_code"
company_mng ||--o{ dept_info : "company_code"
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
authority_master ||--o{ authority_sub_user : "objid → master_objid"
user_info ||--o{ authority_sub_user : "user_id"
authority_master ||--o{ authority_master_history : "objid"
user_info ||--o{ user_info_history : "user_id"
user_info ||--o{ auth_tokens : "user_id"
user_info ||--o{ login_access_log : "user_id"
authority_master ||--o{ rel_menu_auth : "auth_group_id"
menu_info ||--o{ rel_menu_auth : "menu_objid"
user_info {
string user_id PK
string company_code
string user_name
}
authority_master {
int objid PK
string company_code
string auth_group_name
}
company_mng {
string company_code PK
string company_name
}
</div>
</div>
<h2>2. 메뉴/화면 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
menu_info ||--o{ screen_menu_assignments : "objid → menu_objid"
screen_definitions ||--o{ screen_menu_assignments : "screen_id"
screen_definitions ||--|| screen_layouts : "screen_id"
screen_groups ||--o{ screen_group_screens : "id → group_id"
screen_definitions ||--o{ screen_group_screens : "screen_id"
menu_info ||--o{ menu_screen_groups : "objid → menu_objid"
menu_screen_groups ||--o{ menu_screen_group_items : "id → group_id"
screen_definitions ||--o{ screen_data_flows : "source/target_screen_id"
screen_groups ||--o{ screen_data_flows : "group_id"
screen_definitions ||--o{ screen_table_relations : "screen_id"
screen_groups ||--o{ screen_table_relations : "group_id"
screen_definitions ||--o{ screen_field_joins : "screen_id"
screen_definitions ||--o{ screen_embedding : "parent/child_screen_id"
screen_embedding ||--o{ screen_split_panel : "left/right_embedding_id"
screen_embedding ||--o{ screen_data_transfer : "source/target"
screen_definitions {
uuid screen_id PK
string company_code
string screen_name
string table_name
}
screen_layouts {
uuid screen_id PK_FK
jsonb layout_metadata
}
menu_info {
int objid PK
string company_code
string menu_name
string menu_url
}
</div>
</div>
<h2>3. 플로우 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
flow_definition ||--o{ flow_step : "id → definition_id"
flow_step ||--o{ flow_step_connection : "id → from/to_step_id"
flow_step ||--o{ flow_audit_log : "id → from/to_step_id"
flow_step ||--o{ flow_data_mapping : "step_id"
flow_step ||--o{ flow_data_status : "step_id"
flow_definition ||--o{ flow_integration_log : "definition_id"
flow_definition ||--o{ node_flows : "definition_id"
flow_definition ||--o{ dataflow_diagrams : "definition_id"
flow_definition ||--o{ flow_external_db_connection : "definition_id"
flow_definition {
int id PK
string company_code
string name
string description
}
flow_step {
int id PK
int definition_id
string step_name
string table_name
}
flow_step_connection {
int id PK
int from_step_id
int to_step_id
}
</div>
</div>
<h2>4. 테이블타입/코드 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
table_type_columns ||--o{ table_labels : "table_name, column_name"
table_type_columns ||--o{ table_column_category_values : "table_name, column_name"
table_type_columns ||--o{ category_column_mapping : "table_name, column_name"
table_type_columns ||--o{ table_relationships : "table_name"
table_type_columns ||--o{ table_log_config : "original_table_name"
code_category ||--o{ code_info : "category_code"
cascading_hierarchy_group ||--o{ cascading_hierarchy_level : "group_code"
cascading_hierarchy_group ||--o{ cascading_relation : "group_code"
cascading_auto_fill_group ||--o{ cascading_auto_fill_mapping : "group_code"
category_value_cascading_group ||--o{ category_value_cascading_mapping : "group_id"
language_master ||--o{ multi_lang_category : "lang_code"
table_type_columns {
string table_name PK
string column_name PK
string company_code PK
string display_name
string data_type
}
code_category {
string category_code PK
string company_code PK
string category_name
}
code_info {
string category_code PK_FK
string code_value PK
string company_code PK
string code_name
}
</div>
</div>
<h2>5. 배치/수집 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
batch_configs ||--o{ batch_mappings : "id → config_id"
batch_configs ||--o{ batch_execution_logs : "id → config_id"
external_db_connections ||--o{ batch_configs : "connection_id"
external_db_connections ||--o{ data_collection_configs : "connection_id"
data_collection_configs ||--o{ data_collection_jobs : "id → config_id"
data_collection_jobs ||--o{ data_collection_history : "job_id"
external_rest_api_connections ||--o{ external_call_configs : "connection_id"
batch_configs {
int id PK
string company_code
string batch_name
string cron_expression
}
external_db_connections {
int id PK
string company_code
string connection_name
string db_type
}
</div>
</div>
<h2>6. 업무 도메인 (동적 관계)</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
customer_mng ||--o{ sales_order_mng : "customer_code"
sales_order_mng ||--o{ sales_order_detail : "order_id"
supplier_mng ||--o{ purchase_order_mng : "supplier_code"
purchase_order_mng ||--o{ purchase_detail : "order_id"
warehouse_info ||--o{ warehouse_location : "warehouse_code"
warehouse_info ||--o{ inventory_stock : "warehouse_code"
inventory_stock ||--o{ inventory_history : "stock_id"
item_info ||--o{ item_routing_version : "item_code"
item_routing_version ||--o{ item_routing_detail : "version_id"
process_mng ||--o{ process_equipment : "process_code"
carrier_mng ||--o{ carrier_vehicle_mng : "carrier_code"
carrier_mng ||--o{ carrier_contract_mng : "carrier_code"
vehicles ||--o{ vehicle_locations : "vehicle_id"
vehicles ||--o{ vehicle_location_history : "vehicle_id"
equipment_mng ||--o{ equipment_consumable : "equipment_code"
equipment_mng ||--o{ maintenance_schedules : "equipment_code"
</div>
</div>
<h2>전체 구조 개요</h2>
<div class="diagram-container">
<div class="mermaid">
graph TB
subgraph SYSTEM["🔐 시스템/인증 (11개)"]
AUTH[authority_master<br/>authority_sub_user<br/>rel_menu_auth]
USER[user_info<br/>user_dept<br/>auth_tokens]
ORG[company_mng<br/>dept_info]
end
subgraph SCREEN["📱 메뉴/화면 (18개)"]
MENU[menu_info<br/>menu_screen_groups]
SCR[screen_definitions<br/>screen_layouts<br/>screen_groups]
DASH[dashboards<br/>dashboard_elements]
end
subgraph CODE["🏷️ 테이블타입/코드 (20개)"]
TTC[table_type_columns<br/>table_labels<br/>table_relationships]
CODE_M[code_category<br/>code_info]
CASC[cascading_*]
end
subgraph FLOW["🔄 플로우 (10개)"]
FLOW_DEF[flow_definition<br/>flow_step<br/>flow_step_connection]
FLOW_DATA[flow_data_mapping<br/>flow_audit_log]
end
subgraph BATCH["⚙️ 배치/수집 (9개)"]
BATCH_CFG[batch_configs<br/>batch_mappings]
EXT_CONN[external_db_connections<br/>external_rest_api_connections]
end
subgraph DOMAIN["📊 업무도메인 (69개)"]
SALES[영업/구매 17개]
PROD[생산/품질 20개]
LOGI[물류/창고 8개]
TRANS[차량/운송 16개]
EQUIP[설비/안전 8개]
end
USER --> AUTH
MENU --> SCR
SCR --> TTC
FLOW_DEF --> FLOW_DATA
BATCH_CFG --> EXT_CONN
</div>
</div>
<h2>카테고리별 테이블 수</h2>
<table>
<tr><th>카테고리</th><th>테이블 수</th></tr>
<tr><td>🔐 시스템/인증</td><td>11개</td></tr>
<tr><td>📱 메뉴/화면</td><td>18개</td></tr>
<tr><td>🏷️ 테이블타입/코드</td><td>20개</td></tr>
<tr><td>🔄 플로우</td><td>10개</td></tr>
<tr><td>⚙️ 배치/수집</td><td>9개</td></tr>
<tr><td>📊 보고서</td><td>5개</td></tr>
<tr><td>📦 물류/창고</td><td>8개</td></tr>
<tr><td>🏭 생산/품질</td><td>20개</td></tr>
<tr><td>💰 영업/구매</td><td>17개</td></tr>
<tr><td>🔧 설비/안전</td><td>8개</td></tr>
<tr><td>🚛 차량/운송</td><td>16개</td></tr>
<tr><td>📁 기타</td><td>22개</td></tr>
<tr><th>총계</th><th>164개</th></tr>
</table>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
</script>
</body>
</html>

View File

@ -1,685 +0,0 @@
# CategoryTreeController 로직 분석 보고서
> 분석일: 2026-01-26 | 대상 파일: `backend-node/src/controllers/categoryTreeController.ts`
> 검증일: 2026-01-26 | TypeScript 컴파일 검증 완료
---
## 0. 검증 결과 요약
### TypeScript 컴파일 에러 (실제 확인됨)
```bash
$ tsc --noEmit src/controllers/categoryTreeController.ts
src/controllers/categoryTreeController.ts(139,15): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(140,27): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(143,34): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
```
**결론**: `targetCompanyCode` 타입 정의 누락 문제가 **실제로 존재함**
---
## 1. 시스템 개요
### 1.1 아키텍처 다이어그램
```mermaid
flowchart TB
subgraph Frontend["프론트엔드"]
UI[카테고리 관리 UI]
end
subgraph Backend["백엔드"]
subgraph Controllers["컨트롤러"]
CTC[categoryTreeController.ts]
end
subgraph Services["서비스"]
CTS[categoryTreeService.ts]
TCVS[tableCategoryValueService.ts]
end
subgraph Database["데이터베이스"]
CVT[(category_values_test)]
TCCV[(table_column_category_values)]
TTC[(table_type_columns)]
end
end
UI --> |"/api/category-tree/*"| CTC
CTC --> CTS
CTS --> CVT
TCVS --> TCCV
TCVS --> TTC
style CTC fill:#ff6b6b,stroke:#c92a2a
style CVT fill:#4ecdc4,stroke:#087f5b
style TCCV fill:#4ecdc4,stroke:#087f5b
```
### 1.2 관련 파일 목록
| 파일 | 역할 | 사용 테이블 |
|------|------|-------------|
| `categoryTreeController.ts` | 카테고리 트리 API 라우트 | - |
| `categoryTreeService.ts` | 카테고리 트리 비즈니스 로직 | `category_values_test` |
| `tableCategoryValueService.ts` | 테이블별 카테고리 값 관리 | `table_column_category_values` |
| `categoryTreeRoutes.ts` | 라우트 re-export | - |
---
## 2. 발견된 문제점 요약
```mermaid
pie title 문제점 심각도 분류
"🔴 Critical (즉시 수정)" : 3
"🟠 Major (수정 권장)" : 2
"🟡 Minor (검토 필요)" : 2
```
| 심각도 | 문제 | 영향도 | 검증 |
|--------|------|--------|------|
| 🔴 Critical | 라우트 순서 충돌 | GET 라우트 2개 호출 불가 | 이론적 분석 |
| 🔴 Critical | 타입 정의 불일치 | TypeScript 컴파일 에러 | ✅ tsc 검증됨 |
| 🔴 Critical | 멀티테넌시 규칙 위반 | **보안 문제** - 데이터 노출 | .cursorrules 규칙 확인 |
| 🟠 Major | 하위 항목 삭제 미구현 | 데이터 정합성 | 주석 vs 구현 비교 |
| 🟠 Major | 카테고리 시스템 이원화 | 유지보수 복잡도 | 코드 분석 |
| 🟡 Minor | 인덱스 비효율 쿼리 | 성능 저하 | 쿼리 패턴 분석 |
| 🟡 Minor | PUT/DELETE 오버라이드 누락 | 기능 제한 | 의도적 설계 가능 |
---
## 3. 🔴 Critical: 라우트 순서 충돌
### 3.1 문제 설명
Express 라우터는 **정의 순서대로** 매칭합니다. 현재 라우트 순서에서 일부 GET 라우트가 절대 호출되지 않습니다.
### 3.2 현재 라우트 순서 (문제)
```mermaid
flowchart LR
subgraph Order["현재 정의 순서"]
R1["Line 24<br/>GET /test/all-category-keys"]
R2["Line 48<br/>GET /test/:tableName/:columnName<br/>⚠️ 너무 일찍 정의"]
R3["Line 73<br/>GET /test/:tableName/:columnName/flat"]
R4["Line 98<br/>GET /test/value/:valueId<br/>❌ 가려짐"]
R5["Line 130<br/>POST /test/value"]
R6["Line 174<br/>PUT /test/value/:valueId"]
R7["Line 208<br/>DELETE /test/value/:valueId"]
R8["Line 240<br/>GET /test/columns/:tableName<br/>❌ 가려짐"]
end
R1 --> R2 --> R3 --> R4 --> R5 --> R6 --> R7 --> R8
style R2 fill:#fff3bf,stroke:#f59f00
style R4 fill:#ffe3e3,stroke:#c92a2a
style R8 fill:#ffe3e3,stroke:#c92a2a
```
### 3.3 요청 매칭 시뮬레이션
```mermaid
sequenceDiagram
participant Client as 클라이언트
participant Express as Express Router
participant R2 as Line 48<br/>/:tableName/:columnName
participant R4 as Line 98<br/>/value/:valueId
participant R8 as Line 240<br/>/columns/:tableName
Note over Client,Express: 요청: GET /test/value/123
Client->>Express: GET /test/value/123
Express->>R2: 패턴 매칭 시도
Note over R2: tableName="value"<br/>columnName="123"<br/>✅ 매칭됨!
R2-->>Express: 처리 완료
Note over R4: ❌ 검사되지 않음
Note over Client,Express: 요청: GET /test/columns/users
Client->>Express: GET /test/columns/users
Express->>R2: 패턴 매칭 시도
Note over R2: tableName="columns"<br/>columnName="users"<br/>✅ 매칭됨!
R2-->>Express: 처리 완료
Note over R8: ❌ 검사되지 않음
```
### 3.4 영향받는 라우트
| 라인 | 경로 | HTTP | 상태 | 원인 |
|------|------|------|------|------|
| 98 | `/test/value/:valueId` | GET | ❌ 호출 불가 | Line 48에 의해 가려짐 |
| 240 | `/test/columns/:tableName` | GET | ❌ 호출 불가 | Line 48에 의해 가려짐 |
### 3.5 PUT/DELETE는 왜 문제없는가?
```mermaid
flowchart TB
subgraph Methods["HTTP 메서드별 라우트 분리"]
subgraph GET["GET 메서드"]
G1["Line 24: /test/all-category-keys"]
G2["Line 48: /test/:tableName/:columnName ⚠️"]
G3["Line 73: /test/:tableName/:columnName/flat"]
G4["Line 98: /test/value/:valueId ❌"]
G5["Line 240: /test/columns/:tableName ❌"]
end
subgraph POST["POST 메서드"]
P1["Line 130: /test/value"]
end
subgraph PUT["PUT 메서드"]
U1["Line 174: /test/value/:valueId ✅"]
end
subgraph DELETE["DELETE 메서드"]
D1["Line 208: /test/value/:valueId ✅"]
end
end
Note1[Express는 같은 HTTP 메서드 내에서만<br/>순서대로 매칭함]
style G2 fill:#fff3bf
style G4 fill:#ffe3e3
style G5 fill:#ffe3e3
style U1 fill:#d3f9d8
style D1 fill:#d3f9d8
```
**결론**: PUT `/test/value/:valueId`와 DELETE `/test/value/:valueId`는 GET 라우트와 **HTTP 메서드가 다르므로** 충돌하지 않습니다.
### 3.6 수정 방안
```typescript
// ✅ 올바른 순서 (더 구체적인 경로 먼저)
// 1. 리터럴 경로 (가장 먼저)
router.get("/test/all-category-keys", ...);
// 2. 부분 리터럴 경로 (리터럴 + 파라미터)
router.get("/test/value/:valueId", ...); // "value"가 고정
router.get("/test/columns/:tableName", ...); // "columns"가 고정
// 3. 더 긴 동적 경로
router.get("/test/:tableName/:columnName/flat", ...); // 4세그먼트
// 4. 가장 일반적인 동적 경로 (마지막에)
router.get("/test/:tableName/:columnName", ...); // 3세그먼트
```
---
## 4. 🔴 Critical: 타입 정의 불일치
### 4.1 문제 설명
컨트롤러에서 `input.targetCompanyCode`를 사용하지만, 인터페이스에 해당 필드가 없습니다.
### 4.2 코드 비교
```mermaid
flowchart LR
subgraph Interface["CreateCategoryValueInput 인터페이스"]
I1[tableName: string]
I2[columnName: string]
I3[valueCode: string]
I4[valueLabel: string]
I5[valueOrder?: number]
I6[parentValueId?: number]
I7[description?: string]
I8[color?: string]
I9[icon?: string]
I10[isActive?: boolean]
I11[isDefault?: boolean]
Missing["❌ targetCompanyCode 없음"]
end
subgraph Controller["컨트롤러 (Line 139)"]
C1["input.targetCompanyCode 사용"]
end
Controller -.-> |"타입 불일치"| Missing
style Missing fill:#ffe3e3,stroke:#c92a2a
```
### 4.3 문제 코드
**인터페이스 정의 (`categoryTreeService.ts` Line 34-46):**
```typescript
export interface CreateCategoryValueInput {
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
// ❌ targetCompanyCode 필드 없음!
}
```
**컨트롤러 사용 (`categoryTreeController.ts` Line 136-145):**
```typescript
// 🔧 최고 관리자가 특정 회사를 선택한 경우, targetCompanyCode 우선 사용
let companyCode = userCompanyCode;
if (input.targetCompanyCode && userCompanyCode === "*") { // ⚠️ 타입 에러 가능
companyCode = input.targetCompanyCode;
logger.info("🔓 최고 관리자 회사 코드 오버라이드", {
originalCompanyCode: userCompanyCode,
targetCompanyCode: input.targetCompanyCode,
});
}
```
### 4.4 영향
1. TypeScript 컴파일 시 에러 또는 경고 발생 가능
2. 런타임에 `input.targetCompanyCode`가 항상 `undefined`
3. 최고 관리자의 회사 오버라이드 기능이 작동하지 않음
### 4.5 수정 방안
```typescript
// categoryTreeService.ts - 인터페이스 수정
export interface CreateCategoryValueInput {
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
targetCompanyCode?: string; // ✅ 추가
}
```
---
## 5. 🔴 Critical: 멀티테넌시 규칙 위반 (심각도 상향)
### 5.1 규칙 위반 설명
`.cursorrules` 파일에 명시된 프로젝트 규칙:
> **중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
> - ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
> - ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
>
> **핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다!
**현재 상태**: 서비스 코드에서 일반 회사도 `company_code = '*'` 데이터를 조회할 수 있음 → **보안 위반**
### 5.2 문제 쿼리 패턴
```mermaid
flowchart TB
subgraph Current["현재 구현 (문제)"]
Q1["WHERE (company_code = $1 OR company_code = '*')"]
subgraph Result1["일반 회사 'COMPANY_A' 조회 시"]
R1A["✅ COMPANY_A 데이터"]
R1B["⚠️ * 데이터도 조회됨 (규칙 위반)"]
end
end
subgraph Expected["올바른 구현"]
Q2["if (companyCode === '*')<br/> 전체 조회<br/>else<br/> WHERE company_code = $1"]
subgraph Result2["일반 회사 'COMPANY_A' 조회 시"]
R2A["✅ COMPANY_A 데이터만"]
end
end
style R1B fill:#ffe3e3,stroke:#c92a2a
style R2A fill:#d3f9d8,stroke:#087f5b
```
### 5.3 영향받는 함수 목록
| 서비스 | 함수 | 라인 | 문제 쿼리 |
|--------|------|------|-----------|
| `categoryTreeService.ts` | `getCategoryTree` | 93 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryList` | 146 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryValue` | 188 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `updateCategoryValue` | 352 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `deleteCategoryValue` | 415 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `updateChildrenPaths` | 443 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryColumns` | 498 | `WHERE (company_code = $2 OR company_code = '*')` |
| `categoryTreeService.ts` | `getAllCategoryKeys` | 530 | `WHERE cv.company_code = $1 OR cv.company_code = '*'` |
### 5.4 수정 방안
```typescript
// ✅ 올바른 멀티테넌시 패턴 (tableCategoryValueService.ts 참고)
async getCategoryTree(companyCode: string, tableName: string, columnName: string) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 데이터 조회
query = `
SELECT * FROM category_values_test
WHERE table_name = $1 AND column_name = $2
ORDER BY depth ASC, value_order ASC
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 데이터만 조회 (company_code = '*' 제외)
query = `
SELECT * FROM category_values_test
WHERE table_name = $1 AND column_name = $2
AND company_code = $3
ORDER BY depth ASC, value_order ASC
`;
params = [tableName, columnName, companyCode];
}
return await pool.query(query, params);
}
```
---
## 6. 🟠 Major: 하위 항목 삭제 미구현
### 6.1 문제 설명
주석에는 "하위 항목도 함께 삭제"라고 되어 있지만, 실제 구현에서는 단일 레코드만 삭제합니다.
### 6.2 코드 분석
```mermaid
flowchart TB
subgraph Comment["주석 (Line 407)"]
C1["카테고리 값 삭제 (하위 항목도 함께 삭제)"]
end
subgraph Implementation["실제 구현 (Line 413-416)"]
I1["DELETE FROM category_values_test<br/>WHERE ... AND value_id = $2"]
I2["단일 레코드만 삭제"]
end
Comment -.-> |"불일치"| Implementation
style Comment fill:#e7f5ff,stroke:#1971c2
style Implementation fill:#ffe3e3,stroke:#c92a2a
```
### 6.3 예상 문제 시나리오
```mermaid
flowchart TB
subgraph Before["삭제 전"]
P["대분류 (value_id=1)"]
C1["중분류 A (parent_value_id=1)"]
C2["중분류 B (parent_value_id=1)"]
C3["소분류 X (parent_value_id=C1)"]
P --> C1
P --> C2
C1 --> C3
end
subgraph After["'대분류' 삭제 후"]
C1o["중분류 A ⚠️ 고아"]
C2o["중분류 B ⚠️ 고아"]
C3o["소분류 X ⚠️ 고아"]
Orphan["parent_value_id가 존재하지 않는<br/>부모를 가리킴"]
end
Before --> |"DELETE"| After
style C1o fill:#ffe3e3
style C2o fill:#ffe3e3
style C3o fill:#ffe3e3
```
### 6.4 수정 방안
```typescript
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 재귀적으로 모든 하위 항목 ID 조회
const descendantsQuery = `
WITH RECURSIVE descendants AS (
SELECT value_id FROM category_values_test
WHERE value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT c.value_id FROM category_values_test c
JOIN descendants d ON c.parent_value_id = d.value_id
WHERE c.company_code = $2 OR c.company_code = '*'
)
SELECT value_id FROM descendants
`;
const descendants = await client.query(descendantsQuery, [valueId, companyCode]);
const idsToDelete = descendants.rows.map(r => r.value_id);
// 2. 하위 항목 포함 일괄 삭제
if (idsToDelete.length > 0) {
await client.query(
`DELETE FROM category_values_test WHERE value_id = ANY($1::int[])`,
[idsToDelete]
);
}
await client.query("COMMIT");
logger.info("카테고리 값 및 하위 항목 삭제 완료", {
valueId,
totalDeleted: idsToDelete.length
});
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
```
---
## 7. 🟠 Major: 카테고리 시스템 이원화
### 7.1 문제 설명
동일한 목적의 두 개의 카테고리 시스템이 존재합니다.
### 7.2 시스템 비교
```mermaid
flowchart TB
subgraph System1["시스템 1: categoryTreeService"]
S1C[categoryTreeController.ts]
S1S[categoryTreeService.ts]
S1T[(category_values_test)]
S1C --> S1S --> S1T
end
subgraph System2["시스템 2: tableCategoryValueService"]
S2S[tableCategoryValueService.ts]
S2T[(table_column_category_values)]
S2S --> S2T
end
subgraph Usage["사용처"]
U1[NumberingRuleDesigner.tsx]
U2[V2Select.tsx]
U3[screenManagementService.ts]
end
U1 --> S1T
U2 --> S1T
U3 --> S1T
style S1T fill:#4ecdc4,stroke:#087f5b
style S2T fill:#4ecdc4,stroke:#087f5b
```
### 7.3 테이블 비교
| 속성 | `category_values_test` | `table_column_category_values` |
|------|------------------------|-------------------------------|
| **서비스** | categoryTreeService | tableCategoryValueService |
| **menu_objid** | ❌ 없음 | ✅ 있음 |
| **계층 구조** | ✅ 지원 (최대 3단계) | ✅ 지원 |
| **path 컬럼** | ✅ 있음 | ❌ 없음 |
| **사용 빈도** | 높음 (108건) | 낮음 (0건 추정) |
| **명칭** | "테스트" | "정식" |
### 7.4 권장 사항
```mermaid
flowchart LR
subgraph Current["현재 상태"]
C1[category_values_test<br/>실제 사용 중]
C2[table_column_category_values<br/>거의 미사용]
end
subgraph Recommended["권장 조치"]
R1["1. 테이블명 정리:<br/>_test 접미사 제거"]
R2["2. 서비스 통합:<br/>하나의 서비스로"]
R3["3. 미사용 테이블 정리"]
end
Current --> Recommended
```
---
## 8. 🟡 Minor: 인덱스 비효율 쿼리
### 8.1 문제 쿼리
```sql
WHERE (company_code = $1 OR company_code = '*')
```
### 8.2 문제점
- `OR` 조건은 인덱스 최적화를 방해
- Full Table Scan 발생 가능
### 8.3 수정 방안
```sql
-- 옵션 1: UNION 사용 (권장)
SELECT * FROM category_values_test WHERE company_code = $1
UNION ALL
SELECT * FROM category_values_test WHERE company_code = '*'
-- 옵션 2: IN 연산자 사용
WHERE company_code IN ($1, '*')
-- 옵션 3: 조건별 분기 (가장 권장)
-- 최고 관리자와 일반 사용자 쿼리 분리 (멀티테넌시 규칙 준수와 함께)
```
---
## 9. 🟡 Minor: PUT/DELETE 오버라이드 누락
### 9.1 문제 설명
POST에서만 `targetCompanyCode` 오버라이드 로직이 있고, PUT/DELETE에는 없습니다.
### 9.2 비교 표
| 메서드 | 라인 | targetCompanyCode 처리 |
|--------|------|------------------------|
| POST `/test/value` | 136-145 | ✅ 있음 |
| PUT `/test/value/:valueId` | 174-201 | ❌ 없음 |
| DELETE `/test/value/:valueId` | 208-233 | ❌ 없음 |
### 9.3 영향
- 최고 관리자가 다른 회사의 카테고리 값을 수정/삭제할 때 제한될 수 있음
- 단, **의도적 설계**일 수 있음 (생성만 회사 지정, 수정/삭제는 기존 레코드의 company_code 사용)
### 9.4 권장 사항
기능 요구사항 확인 후 결정:
1. **의도적이라면**: 주석으로 의도 명시
2. **누락이라면**: POST와 동일한 로직 추가
---
## 10. 수정 계획
### 10.1 우선순위별 수정 항목
```mermaid
gantt
title 수정 우선순위
dateFormat YYYY-MM-DD
section 🔴 Critical
라우트 순서 수정 :crit, a1, 2026-01-26, 1d
타입 정의 수정 :crit, a2, 2026-01-26, 1d
멀티테넌시 규칙 준수 :crit, a3, 2026-01-26, 1d
section 🟠 Major
하위 항목 삭제 구현 :b1, 2026-01-27, 2d
section 🟡 Minor
쿼리 최적화 :c1, 2026-01-29, 1d
PUT/DELETE 검토 :c2, 2026-01-29, 1d
```
### 10.2 수정 체크리스트
#### 🔴 Critical (즉시 수정)
- [ ] **라우트 순서 수정** (Line 48, 98, 240)
- `/test/value/:valueId``/test/:tableName/:columnName` 앞으로 이동
- `/test/columns/:tableName``/test/:tableName/:columnName` 앞으로 이동
- [ ] **타입 정의 수정** (categoryTreeService.ts Line 34-46)
- `CreateCategoryValueInput``targetCompanyCode?: string` 추가
- TypeScript 컴파일 에러 해결
- [ ] **멀티테넌시 규칙 준수** (categoryTreeService.ts 모든 쿼리)
- `WHERE (company_code = $1 OR company_code = '*')` 패턴 제거
- 최고 관리자 분기와 일반 사용자 분기 분리
- 일반 사용자는 `company_code = '*'` 데이터 조회 불가
- **영향받는 함수**: getCategoryTree, getCategoryList, getCategoryValue, updateCategoryValue, deleteCategoryValue, updateChildrenPaths, getCategoryColumns, getAllCategoryKeys
#### 🟠 Major (수정 권장)
- [ ] **하위 항목 삭제 구현** (deleteCategoryValue 함수)
- 재귀적 하위 항목 조회 및 삭제 로직 추가
- 또는 주석 수정 (실제 동작과 일치하도록)
#### 🟡 Minor (검토 필요)
- [ ] **PUT/DELETE 오버라이드 검토**
- 필요 시 POST와 동일한 로직 추가
- 불필요 시 의도 주석 추가
---
## 11. 참고 자료
- 멀티테넌시 가이드: `.cursor/rules/multi-tenancy-guide.mdc`
- DB 비효율성 분석: `docs/DB_INEFFICIENCY_ANALYSIS.md`
- 보안 가이드: `.cursor/rules/security-guide.mdc`

View File

@ -1,107 +0,0 @@
# column_labels → table_type_columns 마이그레이션 완료
**작업일**: 2026-01-26
---
## 개요
`column_labels` 테이블의 데이터를 `table_type_columns`로 통합하여 멀티테넌시를 지원하고 데이터 중복을 제거함.
---
## 변경 사항
### 1. 스키마 확장
`table_type_columns`에 누락된 컬럼 추가:
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| column_label | VARCHAR(200) | 컬럼 라벨 |
| reference_table | VARCHAR(100) | 참조 테이블 |
| reference_column | VARCHAR(100) | 참조 컬럼 |
| display_column | VARCHAR(100) | 표시 컬럼 |
| code_category | VARCHAR(100) | 코드 카테고리 |
| code_value | VARCHAR(100) | 코드 값 |
| description | TEXT | 설명 |
| is_visible | BOOLEAN | 표시 여부 |
| web_type | VARCHAR(50) | 웹 타입 (레거시) |
### 2. 데이터 마이그레이션
```
column_labels (company_code 없음)
table_type_columns (company_code = '*')
```
**통합 기준**:
- `column_labels` 데이터 → `company_code = '*'` (공통 설정)
- 기존 회사별 설정 → **유지**
- 회사별 빈 값 → 공통(*)에서 복사 (COALESCE)
### 3. 코드 수정
**12개 파일** 수정:
| 파일 | 주요 변경 |
|------|----------|
| tableManagementService.ts | SELECT/INSERT → table_type_columns |
| screenManagementService.ts | JOIN column_labels → table_type_columns |
| entityJoinService.ts | 엔티티 조인 쿼리 변경 |
| ddlExecutionService.ts | DDL 시 column_labels 제거 |
| screenGroupController.ts | 화면 그룹 쿼리 변경 |
| tableManagementController.ts | 컬럼 관리 쿼리 변경 |
| adminController.ts | 스키마 조회 변경 |
| flowController.ts | 플로우 컬럼 조회 변경 |
| entityReferenceController.ts | 엔티티 참조 변경 |
| masterDetailExcelService.ts | 엑셀 처리 변경 |
| categoryTreeService.ts | 카테고리 트리 변경 |
| dataService.ts | 데이터 서비스 변경 |
---
## 백업
```
column_labels_backup_20260126 -- 원본 백업
table_type_columns_backup_20260126 -- 마이그레이션 전 백업
```
---
## 남은 작업
- [ ] 기능 테스트 (엔티티 조인, 화면 설정, 컬럼 라벨)
- [ ] 1-2주 모니터링
- [ ] `column_labels` 테이블 삭제
- [ ] `ddl.ts`에서 systemTables 배열 정리
---
## 롤백 방법
문제 발생 시:
```sql
-- 1. 백업에서 복원
DROP TABLE IF EXISTS column_labels;
CREATE TABLE column_labels AS SELECT * FROM column_labels_backup_20260126;
-- 2. table_type_columns 복원
DROP TABLE IF EXISTS table_type_columns;
CREATE TABLE table_type_columns AS SELECT * FROM table_type_columns_backup_20260126;
```
+ Git에서 코드 롤백 필요
---
## 결과
| 항목 | Before | After |
|------|--------|-------|
| 테이블 수 | 2개 | 1개 |
| 멀티테넌시 | 부분 지원 | 완전 지원 |
| 데이터 중복 | 있음 | 없음 |

View File

@ -1,561 +0,0 @@
# 컴포넌트 JSON 관리 시스템 분석 보고서
## 1. 개요
WACE 솔루션의 화면 컴포넌트는 **JSONB 형식**으로 데이터베이스에 저장되어 관리됩니다.
이 방식은 스키마 변경 없이 유연하게 컴포넌트 설정을 확장할 수 있는 장점이 있습니다.
---
## 2. 데이터베이스 구조
### 2.1 핵심 테이블: `screen_layouts`
```sql
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL, -- 'container', 'row', 'column', 'widget', 'component'
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100), -- 부모 컴포넌트 ID
position_x INTEGER NOT NULL, -- X 좌표 (그리드)
position_y INTEGER NOT NULL, -- Y 좌표 (그리드)
width INTEGER NOT NULL, -- 너비 (그리드 컬럼 수: 1-12)
height INTEGER NOT NULL, -- 높이 (픽셀)
properties JSONB, -- ⭐ 컴포넌트별 속성 (핵심 JSON 필드)
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100)
);
```
### 2.2 화면 정의: `screen_definitions`
```sql
CREATE TABLE screen_definitions (
screen_id SERIAL PRIMARY KEY,
screen_name VARCHAR(100) NOT NULL,
screen_code VARCHAR(50) UNIQUE NOT NULL,
table_name VARCHAR(100) NOT NULL,
company_code VARCHAR(50) NOT NULL,
description TEXT,
is_active CHAR(1) DEFAULT 'Y',
data_source_type VARCHAR(20), -- 'database' | 'restapi'
rest_api_endpoint VARCHAR(500),
rest_api_json_path VARCHAR(100)
);
```
---
## 3. JSON 구조 상세 분석
### 3.1 `properties` 필드의 최상위 구조
```typescript
interface ComponentProperties {
// 기본 식별 정보
id: string;
type: "widget" | "container" | "row" | "column" | "component";
// 위치 및 크기
position: { x: number; y: number; z?: number };
size: { width: number; height: number };
parentId?: string;
// 표시 정보
label?: string;
title?: string;
required?: boolean;
readonly?: boolean;
// 🆕 새 컴포넌트 시스템
componentType?: string; // 예: "v2-table-list", "v2-button-primary"
componentConfig?: any; // 컴포넌트별 상세 설정
// 레거시 위젯 시스템
widgetType?: string; // 예: "text-input", "select-basic"
webTypeConfig?: WebTypeConfig;
// 테이블/컬럼 정보
tableName?: string;
columnName?: string;
// 스타일
style?: ComponentStyle;
className?: string;
// 반응형 설정
responsiveConfig?: ResponsiveComponentConfig;
// 조건부 표시
conditional?: {
enabled: boolean;
field: string;
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
value: unknown;
action: "show" | "hide" | "enable" | "disable";
};
// 자동 입력
autoFill?: {
enabled: boolean;
sourceTable: string;
filterColumn: string;
userField: "companyCode" | "userId" | "deptCode";
displayColumn: string;
};
}
```
### 3.2 컴포넌트별 `componentConfig` 구조
#### 테이블 리스트 (`v2-table-list`)
```typescript
{
componentConfig: {
tableName: "user_info",
selectedTable: "user_info",
displayMode: "table" | "card",
columns: [
{
columnName: "user_id",
displayName: "사용자 ID",
visible: true,
sortable: true,
searchable: true,
width: 150,
align: "left",
format: "text",
order: 0,
editable: true,
hidden: false,
fixed: "left" | "right" | false,
autoGeneration: {
type: "uuid" | "numbering_rule",
enabled: false,
options: { numberingRuleId: "rule-123" }
}
}
],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
pageSizeOptions: [10, 20, 50, 100]
},
toolbar: {
showEditMode: true,
showExcel: true,
showRefresh: true
},
checkbox: {
enabled: true,
multiple: true,
position: "left"
},
filter: {
enabled: true,
filters: []
}
}
}
```
#### 버튼 (`v2-button-primary`)
```typescript
{
componentConfig: {
action: {
type: "save" | "delete" | "navigate" | "popup" | "excel" | "quickInsert",
// 화면 이동용
targetScreenId?: number,
targetScreenCode?: string,
navigateUrl?: string,
// 채번 규칙 연동
numberingRuleId?: string,
excelNumberingRuleId?: string,
// 엑셀 업로드 후 플로우 실행
excelAfterUploadFlows?: Array<{ flowId: number }>,
// 데이터 전송 설정
dataTransfer?: {
targetTable: string,
columnMappings: [
{ sourceColumn: string, targetColumn: string }
]
}
}
}
}
```
#### 분할 패널 레이아웃 (`v2-split-panel-layout`)
```typescript
{
componentConfig: {
leftPanel: {
tableName: "order_list",
displayMode: "table" | "card",
columns: [...],
addConfig: {
targetTable: "order_detail",
columnMappings: [...]
}
},
rightPanel: {
tableName: "order_detail",
displayMode: "table",
columns: [...]
},
dataTransfer: {
enabled: true,
buttonConfig: {
label: "선택 항목 추가",
position: "center"
}
}
}
}
```
#### 플로우 위젯 (`flow-widget`)
```typescript
{
webTypeConfig: {
dataflowConfig: {
flowConfig: {
flowId: 29
},
selectedDiagramId: 1,
flowControls: [
{ flowId: 30 },
{ flowId: 31 }
]
}
}
}
```
#### 탭 위젯 (`v2-tabs-widget`)
```typescript
{
componentConfig: {
tabs: [
{
id: "tab-1",
label: "기본 정보",
screenId: 45,
order: 0,
disabled: false
},
{
id: "tab-2",
label: "상세 정보",
screenId: 46,
order: 1
}
],
defaultTab: "tab-1",
orientation: "horizontal",
variant: "default"
}
}
```
### 3.3 메타데이터 저장 (`_metadata` 타입)
화면 전체 설정은 `component_type = "_metadata"`인 별도 레코드로 저장:
```typescript
{
properties: {
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true
},
screenResolution: {
width: 1920,
height: 1080,
name: "Full HD",
category: "desktop"
}
}
}
```
---
## 4. 프론트엔드 레지스트리 구조
### 4.1 디렉토리 구조
```
frontend/lib/registry/
├── init.ts # 레지스트리 초기화
├── ComponentRegistry.ts # 컴포넌트 등록 시스템
├── WebTypeRegistry.ts # 웹타입 레지스트리
└── components/ # 컴포넌트별 폴더
├── v2-table-list/
│ ├── index.ts # 컴포넌트 등록
│ ├── types.ts # 타입 정의
│ ├── TableListComponent.tsx
│ ├── TableListRenderer.tsx
│ └── TableListConfigPanel.tsx
├── v2-button-primary/
├── v2-split-panel-layout/
├── text-input/
├── select-basic/
└── ... (70+ 컴포넌트)
```
### 4.2 컴포넌트 등록 패턴
```typescript
// frontend/lib/registry/components/v2-table-list/index.ts
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
ComponentRegistry.register({
id: "v2-table-list",
name: "테이블 리스트",
category: "display",
component: TableListComponent,
renderer: TableListRenderer,
configPanel: TableListConfigPanel,
defaultConfig: {
tableName: "",
columns: [],
pagination: { enabled: true, pageSize: 20 }
}
});
```
### 4.3 현재 등록된 주요 컴포넌트 (70+ 개)
| 카테고리 | 컴포넌트 |
|---------|---------|
| **입력** | text-input, number-input, date-input, select-basic, checkbox-basic, radio-basic, textarea-basic, slider-basic, toggle-switch |
| **표시** | v2-table-list, v2-card-display, v2-text-display, image-display |
| **레이아웃** | v2-split-panel-layout, v2-section-card, v2-section-paper, accordion-basic, conditional-container |
| **버튼** | v2-button-primary, related-data-buttons |
| **고급** | flow-widget, v2-tabs-widget, v2-pivot-grid, v2-category-manager, v2-aggregation-widget |
| **파일** | file-upload |
| **반복** | repeat-container, repeater-field-group, simple-repeater-table, modal-repeater-table |
| **검색** | entity-search-input, autocomplete-search-input, table-search-widget |
| **특수** | numbering-rule, mail-recipient-selector, rack-structure, map |
---
## 5. 백엔드 서비스 로직
### 5.1 레이아웃 저장 (`saveLayout`)
```typescript
// backend-node/src/services/screenManagementService.ts
async saveLayout(screenId: number, layoutData: LayoutData, companyCode: string) {
// 1. 기존 레이아웃 삭제
await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);
// 2. 메타데이터 저장
if (layoutData.gridSettings || layoutData.screenResolution) {
const metadata = {
gridSettings: layoutData.gridSettings,
screenResolution: layoutData.screenResolution
};
await query(`
INSERT INTO screen_layouts (
screen_id, component_type, component_id, properties, display_order
) VALUES ($1, '_metadata', $2, $3, -1)
`, [screenId, `_metadata_${screenId}`, JSON.stringify(metadata)]);
}
// 3. 컴포넌트 저장
for (const component of layoutData.components) {
const properties = {
...componentData,
position: { x, y, z },
size: { width, height }
};
await query(`
INSERT INTO screen_layouts (...) VALUES (...)
`, [screenId, componentType, componentId, ..., JSON.stringify(properties)]);
}
}
```
### 5.2 레이아웃 조회 (`getLayout`)
```typescript
async getLayout(screenId: number, companyCode: string): Promise<LayoutData | null> {
// 레이아웃 조회
const layouts = await query(`
SELECT * FROM screen_layouts WHERE screen_id = $1
ORDER BY display_order ASC
`, [screenId]);
// 메타데이터와 컴포넌트 분리
const metadataLayout = layouts.find(l => l.component_type === "_metadata");
const componentLayouts = layouts.filter(l => l.component_type !== "_metadata");
// 컴포넌트 변환 (JSONB → TypeScript 객체)
const components = componentLayouts.map(layout => {
const properties = layout.properties as any; // ⭐ JSONB 자동 파싱
return {
id: layout.component_id,
type: layout.component_type,
position: { x: layout.position_x, y: layout.position_y },
size: { width: layout.width, height: layout.height },
...properties // 모든 properties 확장
};
});
return { components, gridSettings, screenResolution };
}
```
### 5.3 ID 참조 업데이트 (화면 복사 시)
화면 복사 시 JSON 내부의 ID 참조를 새 ID로 업데이트:
```typescript
// 채번 규칙 ID 업데이트
updateNumberingRuleIdsInProperties(properties, ruleIdMap) {
// componentConfig.autoGeneration.options.numberingRuleId
// componentConfig.action.numberingRuleId
// componentConfig.action.excelNumberingRuleId
}
// 화면 ID 업데이트
updateTabScreenIdsInProperties(properties, screenIdMap) {
// componentConfig.tabs[].screenId
}
// 플로우 ID 업데이트
updateFlowIdsInProperties(properties, flowIdMap) {
// webTypeConfig.dataflowConfig.flowConfig.flowId
// webTypeConfig.dataflowConfig.flowControls[].flowId
}
```
---
## 6. 장단점 분석
### 6.1 장점
| 장점 | 설명 |
|-----|-----|
| **유연성** | 스키마 변경 없이 새 컴포넌트 설정 추가 가능 |
| **확장성** | 새 컴포넌트 타입 추가 시 DB 마이그레이션 불필요 |
| **버전 호환성** | 이전 버전 컴포넌트도 그대로 동작 |
| **빠른 개발** | 프론트엔드에서 설정 구조 변경 후 바로 저장 가능 |
| **복잡한 구조** | 중첩된 설정 (예: columns 배열) 저장 용이 |
### 6.2 단점
| 단점 | 설명 |
|-----|-----|
| **타입 안정성** | 런타임에만 타입 검증 가능 |
| **쿼리 복잡도** | JSONB 내부 필드 검색/수정 어려움 |
| **인덱싱 한계** | 전체 JSON 검색 시 성능 저하 |
| **마이그레이션** | JSON 구조 변경 시 데이터 마이그레이션 필요 |
| **디버깅** | JSON 구조 파악 어려움 |
---
## 7. 현재 구조의 특징
### 7.1 레거시 + 신규 컴포넌트 공존
```typescript
// 레거시 방식 (widgetType + webTypeConfig)
{
type: "widget",
widgetType: "text",
webTypeConfig: { ... }
}
// 신규 방식 (componentType + componentConfig)
{
type: "component",
componentType: "v2-table-list",
componentConfig: { ... }
}
```
### 7.2 계층 구조
```
screen_layouts
├── _metadata (격자 설정, 해상도)
├── container (최상위 컨테이너)
│ ├── row (행)
│ │ ├── column (열)
│ │ │ └── widget/component (실제 컴포넌트)
│ │ └── column
│ └── row
└── component (독립 컴포넌트)
```
### 7.3 ID 참조 관계
```
properties.componentConfig
├── action.targetScreenId → screen_definitions.screen_id
├── action.numberingRuleId → numbering_rule.rule_id
├── action.excelAfterUploadFlows[].flowId → flow_definitions.flow_id
├── tabs[].screenId → screen_definitions.screen_id
└── webTypeConfig.dataflowConfig.flowConfig.flowId → flow_definitions.flow_id
```
---
## 8. 개선 권장사항
### 8.1 단기 개선
1. **타입 문서화**: 각 컴포넌트의 `componentConfig` 타입을 TypeScript 인터페이스로 명확히 정의
2. **검증 레이어**: 저장 전 JSON 스키마 검증 추가
3. **마이그레이션 도구**: JSON 구조 변경 시 자동 마이그레이션 스크립트
### 8.2 장기 개선
1. **버전 관리**: `properties` 내에 `version` 필드 추가
2. **인덱스 최적화**: 자주 검색되는 JSONB 필드에 GIN 인덱스 추가
3. **로깅 강화**: 컴포넌트 설정 변경 이력 추적
---
## 9. 결론
현재 시스템은 **JSONB를 활용한 유연한 컴포넌트 설정 관리** 방식을 채택하고 있습니다.
- **70개 이상의 컴포넌트**가 등록되어 있으며
- **`screen_layouts.properties`** 필드에 모든 컴포넌트 설정이 저장됩니다
- 레거시(`widgetType`)와 신규(`componentType`) 컴포넌트가 공존하며
- 화면 복사 시 JSON 내부의 ID 참조가 자동 업데이트됩니다
이 구조는 **빠른 기능 확장**에 적합하지만, **타입 안정성**과 **쿼리 성능** 측면에서 추가 개선이 필요합니다.

View File

@ -1,433 +0,0 @@
# 컴포넌트 레이아웃 V2 아키텍처
> 최종 업데이트: 2026-01-27
## 1. 개요
### 1.1 목표
- **핵심 목표**: 컴포넌트 코드 수정 시 모든 화면에 자동 반영
- **문제 해결**: 기존 JSON "박제" 방식으로 인한 코드 수정 미반영 문제
- **방식**: 1 레코드 방식 (화면당 1개 레코드, JSON에 모든 컴포넌트 포함)
### 1.2 핵심 원칙
```
저장: component_url + overrides (차이값만)
로드: 코드 기본값 + overrides 병합 (Zod)
```
**이전 방식 (문제점)**:
```json
// 전체 설정 박제 → 코드 수정해도 반영 안 됨
{
"componentType": "table-list",
"componentConfig": {
"columns": [...],
"pagination": true,
"pageSize": 20,
// ... 수백 줄의 설정
}
}
```
**V2 방식 (해결)**:
```json
// url로 코드 참조 + 차이값만 저장
{
"url": "@/lib/registry/components/table-list",
"overrides": {
"tableName": "user_info",
"columns": ["id", "name"]
}
}
```
---
## 2. 데이터베이스 구조
### 2.1 테이블 정의
```sql
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(20) NOT NULL,
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(screen_id, company_code)
);
-- 인덱스
CREATE INDEX idx_v2_screen_id ON screen_layouts_v2(screen_id);
CREATE INDEX idx_v2_company_code ON screen_layouts_v2(company_code);
CREATE INDEX idx_v2_screen_company ON screen_layouts_v2(screen_id, company_code);
```
### 2.2 layout_data 구조
```json
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "user_info",
"columns": ["id", "name", "email"]
}
},
{
"id": "comp_yyy",
"url": "@/lib/registry/components/button-primary",
"position": { "x": 0, "y": 60 },
"size": { "width": 20, "height": 5 },
"displayOrder": 1,
"overrides": {
"label": "저장",
"variant": "default"
}
}
],
"updatedAt": "2026-01-27T12:00:00Z"
}
```
### 2.3 필드 설명
| 필드 | 타입 | 설명 |
|-----|-----|-----|
| `id` | string | 컴포넌트 고유 ID |
| `url` | string | 컴포넌트 코드 경로 (필수) |
| `position` | object | 캔버스 내 위치 {x, y} |
| `size` | object | 크기 {width, height} |
| `displayOrder` | number | 렌더링 순서 |
| `overrides` | object | 기본값과 다른 설정만 (차이값) |
---
## 3. API 정의
### 3.1 레이아웃 조회
```
GET /api/screen-management/screens/:screenId/layout-v2
```
**응답**:
```json
{
"success": true,
"data": {
"version": "2.0",
"components": [...]
}
}
```
**로직**:
1. 회사별 레이아웃 먼저 조회
2. 없으면 공통(*) 레이아웃 조회
3. 없으면 null 반환
### 3.2 레이아웃 저장
```
POST /api/screen-management/screens/:screenId/layout-v2
```
**요청**:
```json
{
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"overrides": { ... }
}
]
}
```
**로직**:
1. 권한 확인
2. 버전 정보 추가
3. UPSERT (있으면 업데이트, 없으면 삽입)
---
## 4. 컴포넌트 URL 규칙
### 4.1 URL 형식
```
@/lib/registry/components/{component-name}
```
### 4.2 현재 등록된 컴포넌트
| URL | 설명 |
|-----|-----|
| `@/lib/registry/components/table-list` | 테이블 리스트 |
| `@/lib/registry/components/button-primary` | 기본 버튼 |
| `@/lib/registry/components/text-input` | 텍스트 입력 |
| `@/lib/registry/components/select-basic` | 기본 셀렉트 |
| `@/lib/registry/components/date-input` | 날짜 입력 |
| `@/lib/registry/components/split-panel-layout` | 분할 패널 |
| `@/lib/registry/components/tabs-widget` | 탭 위젯 |
| `@/lib/registry/components/card-display` | 카드 디스플레이 |
| `@/lib/registry/components/flow-widget` | 플로우 위젯 |
| `@/lib/registry/components/category-management` | 카테고리 관리 |
| `@/lib/registry/components/pivot-table` | 피벗 테이블 |
| `@/lib/registry/components/v2-grid` | 통합 그리드 |
---
## 5. Zod 스키마 관리
### 5.1 목적
- 런타임 타입 검증
- 기본값 자동 적용
- overrides 유효성 검사
### 5.2 구조
```typescript
// frontend/lib/schemas/componentConfig.ts
import { z } from "zod";
// 공통 스키마
export const baseComponentSchema = z.object({
id: z.string(),
url: z.string(),
position: z.object({
x: z.number().default(0),
y: z.number().default(0),
}),
size: z.object({
width: z.number().default(100),
height: z.number().default(100),
}),
displayOrder: z.number().default(0),
overrides: z.record(z.any()).default({}),
});
// 컴포넌트별 overrides 스키마
export const tableListOverridesSchema = z.object({
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
pagination: z.boolean().default(true),
pageSize: z.number().default(20),
});
export const buttonOverridesSchema = z.object({
label: z.string().default("버튼"),
variant: z.enum(["default", "destructive", "outline", "ghost"]).default("default"),
icon: z.string().optional(),
});
```
### 5.3 사용 방법
```typescript
// 로드 시: 코드 기본값 + overrides 병합
function loadComponent(component: any) {
const schema = getSchemaByUrl(component.url);
const defaults = schema.parse({});
const merged = deepMerge(defaults, component.overrides);
return merged;
}
// 저장 시: 기본값과 다른 부분만 추출
function saveComponent(component: any, config: any) {
const schema = getSchemaByUrl(component.url);
const defaults = schema.parse({});
const overrides = extractDiff(defaults, config);
return { ...component, overrides };
}
```
---
## 6. 마이그레이션 현황
### 6.1 완료된 작업
| 작업 | 상태 | 날짜 |
|-----|-----|-----|
| screen_layouts_v2 테이블 생성 | ✅ 완료 | 2026-01-27 |
| 기존 데이터 마이그레이션 | ✅ 완료 | 2026-01-27 |
| 백엔드 API 추가 (getLayoutV2, saveLayoutV2) | ✅ 완료 | 2026-01-27 |
| 프론트엔드 API 클라이언트 추가 | ✅ 완료 | 2026-01-27 |
| Zod 스키마 V2 확장 | ✅ 완료 | 2026-01-27 |
| V2 변환 유틸리티 (layoutV2Converter.ts) | ✅ 완료 | 2026-01-27 |
| ScreenDesigner V2 API 연동 | ✅ 완료 | 2026-01-27 |
### 6.2 마이그레이션 통계
```
마이그레이션 대상 화면: 1,347개
성공: 1,347개 (100%)
실패: 0개
컴포넌트 많은 화면 TOP 5:
- screen 74: 25개 컴포넌트
- screen 1204: 18개 컴포넌트
- screen 1242: 18개 컴포넌트
- screen 119: 18개 컴포넌트
- screen 1255: 18개 컴포넌트
```
---
## 7. 남은 작업
### 7.1 필수 작업
| 작업 | 우선순위 | 예상 공수 | 상태 |
|-----|---------|---------|------|
| 프론트엔드 디자이너 V2 API 연동 | 높음 | 3일 | ✅ 완료 |
| Zod 스키마 컴포넌트별 정의 | 높음 | 2일 | ✅ 완료 |
| V2 변환 유틸리티 | 높음 | 1일 | ✅ 완료 |
| 테스트 및 검증 | 중간 | 2일 | 🔄 진행 필요 |
### 7.2 선택 작업
| 작업 | 우선순위 | 예상 공수 |
|-----|---------|---------|
| 기존 API (layout, layout-v1) 제거 | 낮음 | 1일 |
| 기존 테이블 (screen_layouts, screen_layouts_v1) 정리 | 낮음 | 1일 |
| 마이그레이션 검증 도구 | 낮음 | 1일 |
| 컴포넌트별 기본값 레지스트리 확장 | 낮음 | 2일 |
---
## 8. 개발 가이드
### 8.1 새 컴포넌트 추가 시
1. **컴포넌트 코드 생성**
```
frontend/lib/registry/components/{component-name}/
├── index.ts
├── {ComponentName}Renderer.tsx
└── types.ts
```
2. **Zod 스키마 정의**
```typescript
// frontend/lib/schemas/components/{component-name}.ts
export const {componentName}OverridesSchema = z.object({
// 컴포넌트 고유 설정
});
```
3. **레지스트리 등록**
```typescript
// frontend/lib/registry/components/index.ts
export { default as {ComponentName} } from "./{component-name}";
```
### 8.2 화면 저장 시
```typescript
// 디자이너에서 저장 시
async function handleSave() {
const layoutData = {
components: components.map(comp => ({
id: comp.id,
url: comp.url,
position: comp.position,
size: comp.size,
displayOrder: comp.displayOrder,
overrides: extractOverrides(comp.url, comp.config) // 차이값만 추출
}))
};
await screenApi.saveLayoutV2(screenId, layoutData);
}
```
### 8.3 화면 로드 시
```typescript
// 화면 렌더러에서 로드 시
async function loadScreen(screenId: number) {
const layoutData = await screenApi.getLayoutV2(screenId);
const components = layoutData.components.map(comp => {
const defaults = getDefaultsByUrl(comp.url); // Zod 기본값
const mergedConfig = deepMerge(defaults, comp.overrides);
return {
...comp,
config: mergedConfig
};
});
return components;
}
```
---
## 9. 비교: 기존 vs V2
| 항목 | 기존 (다중 레코드) | V2 (1 레코드) |
|-----|------------------|--------------|
| 레코드 수 | 화면당 N개 (컴포넌트 수) | 화면당 1개 |
| 저장 방식 | 전체 설정 박제 | url + overrides |
| 코드 수정 반영 | ❌ 안 됨 | ✅ 자동 반영 |
| 중복 데이터 | 있음 (DB 컬럼 + JSON) | 없음 |
| 공사량 | - | 테이블 변경 필요 |
---
## 10. 관련 파일
### 10.1 백엔드
- `backend-node/src/services/screenManagementService.ts` - getLayoutV2, saveLayoutV2
- `backend-node/src/controllers/screenManagementController.ts` - API 엔드포인트
- `backend-node/src/routes/screenManagementRoutes.ts` - 라우트 정의
### 10.2 프론트엔드
- `frontend/lib/api/screen.ts` - getLayoutV2, saveLayoutV2 클라이언트
- `frontend/lib/schemas/componentConfig.ts` - Zod 스키마 및 V2 유틸리티
- `frontend/lib/utils/layoutV2Converter.ts` - V2 ↔ Legacy 변환 유틸리티
- `frontend/components/screen/ScreenDesigner.tsx` - V2 API 연동 (USE_V2_API 플래그)
- `frontend/lib/registry/components/` - 컴포넌트 레지스트리
### 10.3 데이터베이스
- `screen_layouts_v2` - V2 레이아웃 테이블
---
## 11. FAQ
### Q1: 기존 화면은 어떻게 되나요?
기존 화면은 마이그레이션되어 `screen_layouts_v2`에 저장됩니다. 디자이너가 V2 API를 사용하도록 수정되면 자동으로 새 구조를 사용합니다.
### Q2: 컴포넌트 코드를 수정하면 정말 전체 반영되나요?
네. `overrides`에는 차이값만 저장되고, 로드 시 코드의 기본값과 병합됩니다. 기본값을 수정하면 모든 화면에 반영됩니다.
### Q3: 회사별 설정은 어떻게 관리하나요?
`company_code` 컬럼으로 회사별 레이아웃을 분리합니다. 회사별 레이아웃이 없으면 공통(*) 레이아웃을 사용합니다.
### Q4: 기존 테이블(screen_layouts)은 언제 삭제하나요?
V2가 안정화되고 모든 기능이 정상 동작하는지 확인된 후에 삭제합니다. 최소 1개월 이상 병행 운영 권장.
---
## 12. 변경 이력
| 날짜 | 변경 내용 | 작성자 |
|-----|----------|-------|
| 2026-01-27 | 초안 작성, 테이블 생성, 마이그레이션, API 추가 | Claude |
| 2026-01-27 | Zod 스키마 V2 확장, 변환 유틸리티, ScreenDesigner 연동 | Claude |

View File

@ -1,627 +0,0 @@
# 컴포넌트 관리 시스템 최종 설계
---
## 🔒 확정 사항 (변경 금지)
| 항목 | 확정 내용 | 비고 |
|-----|---------|-----|
| **slot 저장 위치** | `custom_config.slot` | DB 컬럼 아님 |
| **component_url** | 모든 컴포넌트 **필수** | NULL 허용 안 함 |
| **멀티테넌시** | 모든 쿼리에 `company_code` 필터 필수 | action 실행/참조 조회 포함 |
⚠️ **위 3가지는 개발 중 절대 변경하지 말 것**
---
## 1. 현재 문제점 (복사본 문제)
### 문제 상황
- 컴포넌트 코드 수정 시 기존 화면에 반영 안 됨
- JSON에 모든 설정이 저장되어 있어서 코드 변경이 무시됨
- JSON 구조가 복잡해서 디버깅 어려움
- 어떤 파일을 수정해야 하는지 찾기 어려움
### 핵심 원인: DB에 "복사본"이 생김
- 화면 저장할 때 컴포넌트 설정 **전체**를 JSON으로 저장
- 그 순간 DB 안에 **"컴포넌트 복사본"**이 생김
- 나중에 코드(원본)를 고쳐도, 화면은 DB 복사본을 읽어서 **원본 수정이 안 먹음**
### 현재 구조 (문제되는 방식)
```json
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary",
"backgroundColor": "#111", // 기본값인데도 저장됨 → 복사본
"textColor": "#fff", // 기본값인데도 저장됨 → 복사본
...전체 설정...
}
}
```
- 4,414개 레코드
- 모든 설정이 JSON에 통째로 저장 (= 복사본)
---
## 2. 해결 방안 비교
### 방안 A: 1개 레코드 (화면당 1개, components 배열)
```json
{
"components": [
{ "type": "split-panel-layout", "url": "...", "config": {...} },
{ "type": "table-list", "url": "...", "config": {...} },
{ "type": "button", "config": {...} }
]
}
```
| 장점 | 단점 |
|-----|-----|
| 레코드 수 감소 (4414 → ~200) | JSON 크기 커짐 (10~50KB/화면) |
| 화면 단위 관리 | 버튼 하나 수정해도 전체 JSON 업데이트 |
| | 동시 편집 시 충돌 위험 |
| | 특정 컴포넌트 쿼리 어려움 (JSON 내부 검색) |
**결론: 비효율적**
---
### 방안 B: 다중 레코드 + URL (선택)
```sql
screen_layouts_v3
├── component_id
├── component_url = "@/lib/registry/components/split-panel-layout"
├── custom_config = { 커스텀 설정만 }
```
| 장점 | 단점 |
|-----|-----|
| 개별 컴포넌트 수정 가능 | 레코드 수 많음 (기존과 동일) |
| 부분 업데이트 | |
| URL로 바로 파일 위치 확인 | |
| 인덱스 검색 가능 | |
| 동시 편집 안전 | |
**결론: 효율적**
---
## 3. URL + overrides 방식의 핵심
### 핵심 개념
- **URL = 참조 방식**: "이 컴포넌트의 코드는 어디 파일이냐?"
- **overrides = 차이값**: "회사/화면별로 다른 값만"
- **DB는 복사본이 아닌 참조 + 메모**
### 저장 구조 비교
**AS-IS (복사본 = 문제):**
```json
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary", // 기본값
"backgroundColor": "#111", // 기본값
"textColor": "#fff", // 기본값
...전체...
}
}
```
**TO-BE (참조 + 차이값 = 해결):**
```json
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "저장",
"action": { "type": "save" }
}
}
```
### 왜 코드 수정이 전체 반영되나?
1. 코드(원본)에 defaults 정의: `{ variant: "primary", backgroundColor: "#111" }`
2. DB에는 overrides만: `{ text: "저장" }`
3. 렌더링 시 merge: `{ ...defaults, ...overrides }`
4. 코드의 defaults 수정 → 모든 화면 즉시 반영
### 디버깅 효율성
**URL 없을 때:**
```
1. component_type = "split-panel-layout" 확인
2. 어디에 파일이 있지? 매핑 찾기
3. 규칙 추론 또는 설정 파일 확인
4. 해당 파일로 이동
```
→ 3~4단계
**URL 있을 때:**
```
1. component_url = "@/lib/registry/components/split-panel-layout" 확인
2. 해당 파일로 바로 이동
```
→ 1단계
---
## 4. 최종 설계
### DB 구조
```sql
screen_layouts_v3 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER,
component_id VARCHAR(100) UNIQUE NOT NULL,
component_url VARCHAR(200) NOT NULL, -- 모든 컴포넌트 URL 참조 (권장)
custom_config JSONB NOT NULL DEFAULT '{}', -- slot, dataSource 등 포함
parent_id VARCHAR(100), -- 부모 컴포넌트 ID (컨테이너-자식 관계)
position_x INTEGER DEFAULT 0,
position_y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
**주요 컬럼:**
- `component_url`: 컴포넌트 코드 경로 (필수)
- `custom_config`: 회사/화면별 차이값 (slot 포함)
- `parent_id`: 부모 컴포넌트 ID (계층 구조)
### component_url 정책
**원칙: 모든 컴포넌트는 URL 참조가 가능해야 함**
| 구분 | 예시 | component_url | 설명 |
|-----|-----|--------------|------|
| 메인 | split-panel, tabs, table-list | `@/lib/.../split-panel-layout` | 코드 수정 시 전체 반영 |
| 공용 | button, text-input | `@/lib/.../button-primary` | 동일하게 URL 참조 |
**참고**:
- 공용 컴포넌트도 URL로 참조하면 코드 수정 시 전체 반영 가능
- `NULL` 허용은 마이그레이션 단순화를 위한 선택적 옵션 (권장하지 않음)
### 데이터 저장/로드
**컴포넌트 파일에 defaults 정의:**
```typescript
// @/lib/registry/components/split-panel-layout/index.tsx
export const defaultConfig = {
splitRatio: 30,
resizable: true,
minSize: 100,
};
```
**저장 시 (diff만):**
```json
// DB에 저장되는 custom_config
{
"splitRatio": 50,
"tableName": "user_info"
}
// resizable, minSize는 기본값과 같으므로 저장 안 함
```
**로드 시 (merge):**
```typescript
const fullConfig = { ...defaultConfig, ...customConfig };
// 결과: { splitRatio: 50, resizable: true, minSize: 100, tableName: "user_info" }
```
### Zod 스키마
```typescript
// 컴포넌트별 스키마 (defaults 포함)
const splitPanelSchema = z.object({
splitRatio: z.number().default(30),
resizable: z.boolean().default(true),
minSize: z.number().default(100),
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
});
// 저장 시: schema.parse(config)로 검증
// 로드 시: schema.parse(customConfig)로 defaults 적용
```
---
## 5. 장점 요약
1. **코드 수정 → 전체 반영**
- 컴포넌트 파일 수정하면 해당 URL 사용하는 모든 화면에 적용
2. **JSON 크기 감소**
- 기본값과 다른 것만 저장
- 디버깅 시 "뭐가 커스텀인지" 바로 파악
3. **새 기능 추가 시 자동 적용**
- 코드에 새 필드 + default 추가
- 기존 데이터는 그대로, 로드 시 default 적용
4. **디버깅 쉬움**
- URL 보고 바로 파일 위치 확인
- 매핑 파일 불필요
5. **유지보수 용이**
- 컴포넌트별로 스키마 관리
- Zod로 타입 안전성 확보
---
## 6. 회사별 설정 & 비즈니스 로직 처리
### 회사별 UI 차이 (색깔 등)
```json
// A회사
{ "overrides": { "colorVariant": "blue" } }
// B회사
{ "overrides": { "colorVariant": "red" } }
```
- Zod로 허용 값 제한: `z.enum(["blue", "red", "primary"])`
- 임의의 hex 허용할지, 토큰만 허용할지 스키마로 강제
### 비즈니스 로직 연결 (제어관리 등)
**버튼에 함수/코드 직접 붙이면 안 됨** → 다시 복사본 문제 발생
**해결: 액션 정의(데이터)만 저장, 실행은 공통 엔진**
```json
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "제어실행",
"action": {
"type": "CONTROL_EXECUTE",
"ruleId": "RULE_001",
"params": { "targetTable": "user_info" }
}
}
}
```
**실행 흐름:**
1. 버튼 클릭
2. 공통 ActionRunner가 `action.type` 확인
3. `CONTROL_EXECUTE` → 제어관리 로직 실행
4. `ruleId`, `params`로 실제 동작
**장점:**
- 액션 시스템 버그 수정 → 전 회사 버튼 같이 개선
- 회사별로는 `ruleId`/`params`만 다르게 저장
- Zod로 `action` 타입/필수필드 검증 가능
---
## 7. 구현 순서
1. **DB 스키마 변경**
- `screen_layouts_v3` 테이블 생성
- `component_url`, `custom_config` 컬럼
2. **컴포넌트별 defaults 정의**
- 각 컴포넌트 파일에 `defaultConfig` export
3. **저장 로직**
- 저장 시 defaults와 비교하여 diff만 저장
4. **로드 로직**
- 로드 시 defaults + customConfig merge
5. **마이그레이션**
- 기존 데이터에서 component_url 추출
- properties.componentConfig → custom_config 변환
- (기존 데이터는 일단 전체 저장, 추후 diff로 변환 가능)
6. **프론트엔드 수정**
- 컴포넌트 로딩 시 URL 기반으로 동적 import
- config merge 로직 적용
---
## 8. 레코드 개수 원칙
### 핵심 원칙
**컴포넌트 인스턴스 1개 = 레코드 1개**
### 현재 문제 (split-panel에 몰아넣기)
```
split-panel-layout 1개 레코드에:
├── leftPanel 설정 (table-list 역할) → 박제
├── rightPanel 설정 (card 역할) → 박제
├── relation, binding 등등 → 박제
└── 전부 JSON으로 들어감
```
**문제점:**
- table-list 코드 수정해도 반영 안 됨 (JSON에 박제)
- 컨테이너 스키마가 계속 비대해짐
- URL 참조 체계와 충돌
### 올바른 구조 (레코드 분리)
```
레코드 1: split-panel-layout (컨테이너)
└── component_url: @/lib/.../split-panel-layout ← URL 필수 (코드 참조)
└── parent_id: null
└── custom_config: { splitRatio: 30 }
레코드 2: table-list (왼쪽)
└── component_url: @/lib/.../table-list
└── parent_id: "comp_split_001"
└── custom_config: {
slot: "left", ← slot은 custom_config 안에
dataSource: {...},
selection: { publishKey: "selectedId" }
}
레코드 3: card-display (오른쪽)
└── component_url: @/lib/.../card-display
└── parent_id: "comp_split_001"
└── custom_config: {
slot: "right", ← slot은 custom_config 안에
dataSource: { where: { id: { fromContext: "selectedId" } } }
}
```
**주의**:
- 컨테이너도 컴포넌트이므로 `component_url` 필수
- `slot`은 DB 컬럼이 아닌 `custom_config` 안에 저장
### 부모-자식 연결 방식
| 컬럼 | 위치 | 설명 |
|-----|-----|-----|
| `parent_id` | DB 컬럼 | 부모 컴포넌트 ID |
| `slot` | custom_config 내부 | 슬롯명 (left/right/header/footer) |
`parent_id`는 DB 컬럼, `slot`은 JSON 안에 → **일관성 유지**
**장점:**
- table-list 코드 수정 → 전체 반영 ✅
- card-display 코드 수정 → 전체 반영 ✅
- 컨테이너는 레이아웃만 담당 (설정 폭발 방지)
- 재사용/확장 용이
### 연결 방식
**연결 정보는 각 컴포넌트의 custom_config에 저장**, 실행은 공통 컨텍스트 매니저가 처리:
```json
// table-list의 custom_config
{ "selection": { "publishKey": "selectedId" } }
// card-display의 custom_config
{ "dataSource": { "where": { "id": { "fromContext": "selectedId" } } } }
```
- **저장**: 각 컴포넌트 custom_config에 바인딩 정보
- **실행**: 공통 ScreenContext가 publish/subscribe 처리
---
## 9. 마이그레이션 전략
### 2단계 전략 (반자동 + 검증)
**1단계: 자동 변환**
```
split-panel-layout 레코드에서:
├── properties.componentConfig.leftPanel → 왼쪽 컴포넌트 레코드 생성
├── properties.componentConfig.rightPanel → 오른쪽 컴포넌트 레코드 생성
├── properties.componentConfig.relation → 바인딩 설정으로 변환
└── 원본 → 컨테이너 레코드 (레이아웃만)
```
**2단계: 검증/수동 보정**
- 특이 케이스 (커스텀 필드, 중첩 구조) 확인
- 사람이 검증 후 보정
**이유**: "완전 자동"은 예외가 많고, "완전 수동"은 시간이 너무 듦
---
## 10. publish/subscribe 바인딩 설계
### 스코프
**화면(screen) 단위**가 기본
**이유**: 같은 key(selectedId)가 다른 화면에서 섞이면 사고
### 구현 방식 (React)
**권장: ScreenContext 기반**
```typescript
// ScreenContext + 내부 store
const ScreenContext = createContext<Map<string, any>>();
// 사용
const { publish, subscribe } = useScreenContext();
// table-list에서
publish("selectedId", row.id);
// card-display에서
const selectedId = subscribe("selectedId");
```
**장점:**
- 화면 언마운트 시 상태 자동 폐기
- 디버깅 쉬움 ("현재 화면 컨텍스트 값" 표시 가능)
---
## 11. ActionRunner 설계
### 원칙
- 버튼에는 **"실행할 일의 데이터"만** 저장
- 실행은 **공통 ActionRunner**가 처리
### 구조
```typescript
// action.type은 enum으로 고정 (Zod 검증)
const actionTypeSchema = z.enum([
"OPEN_SCREEN",
"CRUD_SAVE",
"CRUD_DELETE",
"CONTROL_EXECUTE",
"FLOW_EXECUTE",
"API_CALL",
]);
// payload는 타입별 스키마로 분기
const actionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("OPEN_SCREEN"), screenId: z.number(), filters: z.record(z.any()).optional() }),
z.object({ type: z.literal("CRUD_SAVE"), tableName: z.string() }),
z.object({ type: z.literal("CONTROL_EXECUTE"), ruleId: z.string(), params: z.record(z.any()).optional() }),
z.object({ type: z.literal("FLOW_EXECUTE"), flowId: z.number() }),
// ...
]);
```
### 초기 action.type 목록
| type | 설명 | payload |
|-----|-----|---------|
| `OPEN_SCREEN` | 화면 이동 | `{ screenId, filters? }` |
| `CRUD_SAVE` | 저장 | `{ tableName }` |
| `CRUD_DELETE` | 삭제 | `{ tableName }` |
| `CONTROL_EXECUTE` | 제어관리 실행 | `{ ruleId, params? }` |
| `FLOW_EXECUTE` | 플로우 실행 | `{ flowId }` |
| `API_CALL` | 외부/내부 API 호출 | `{ endpoint, method, body? }` (보안/허용 목록 필수) |
---
## 12. 구현 우선순위
### 순서 (권장)
| 순서 | 단계 | 설명 |
|-----|-----|-----|
| 1 | **데이터 모델/스키마 확정** | component_url 정책, parent_id + slot 위치 |
| 2 | **프론트 렌더링 파이프라인** | 로드 → merge → Zod → 렌더링 |
| 3 | **바인딩 컨텍스트 + ActionRunner** | publish/subscribe + 공통 실행 엔진 |
| 4 | **화면 디자이너 저장 포맷 변경** | "박제 JSON" 방지 (저장 시 차단) |
| 5 | **마이그레이션 스크립트** | 기존 데이터 → 새 구조 변환 |
### 핵심
- 렌더링이 먼저 되어야 검증 가능
- 저장 로직을 마지막에 수정해야 "새 박제" 방지
---
## 13. 주의사항
- 기존 화면은 **동일하게 렌더링**되어야 함
- 마이그레이션 시 데이터 손실 없어야 함
- 새 테이블(v1)에서 테스트 후 전환
- **company_code 필터 필수** (멀티테넌시)
- action.type `API_CALL`**허용 목록 필수** (보안)
---
## 14. 구현 진행 상황
### 완료된 작업
| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 1-1 | `screen_layouts_v1` 테이블 생성 | ✅ 완료 |
| 1-2 | 복합 인덱스 생성 (company_code, screen_id) | ✅ 완료 |
| 1-3 | 기존 데이터 마이그레이션 (4,414개) | ✅ 완료 |
| 1-4 | **split-panel 자식 분리** (leftPanel/rightPanel → 별도 레코드) | ✅ 완료 |
| 1-5 | **repeat-container 자식 분리** (children → 별도 레코드) | ✅ 완료 |
| 2-1 | 백엔드 `getLayoutV1` API 구현 | ✅ 완료 |
| 2-2 | 프론트엔드 `getLayoutV1` API 추가 | ✅ 완료 |
| 2-3 | Zod 스키마 및 merge 함수 | ✅ 완료 |
### 마이그레이션 결과
```
총 레코드: 4,691개
├── 루트 컴포넌트: 4,414개
└── 자식 컴포넌트: 277개 (parent_id 있음)
slot 분포:
├── left: 136개
├── right: 135개
└── child_0~3: 6개
박제 제거:
├── split-panel의 leftPanel/rightPanel: 0개 (완료)
├── repeat-container의 children: 0개 (완료)
└── tabs 내부 components: 13개 (추후 처리)
```
### 샘플 구조 (screen 1383 - 수주등록)
```
comp_lspd9b9m (split-panel-layout)
├── comp_lspd9b9m_left (table-list)
│ ├── slot: "left"
│ └── tableName: "sales_order_mng"
└── comp_lspd9b9m_right (table-list)
├── slot: "right"
└── tableName: "sales_order_detail"
```
### DB 스키마
```sql
CREATE TABLE screen_layouts_v1 (
layout_id SERIAL PRIMARY KEY,
screen_id VARCHAR(50) NOT NULL,
component_id VARCHAR(100) NOT NULL,
component_url VARCHAR(200) NOT NULL, -- 🔒 필수
custom_config JSONB NOT NULL DEFAULT '{}', -- slot 포함
parent_id VARCHAR(100),
position_x INTEGER NOT NULL DEFAULT 0,
position_y INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 100,
height INTEGER NOT NULL DEFAULT 100,
display_order INTEGER DEFAULT 0,
company_code VARCHAR(20) NOT NULL, -- 🔒 멀티테넌시
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(company_code, screen_id, component_id)
);
-- 인덱스
CREATE INDEX idx_v1_company_screen ON screen_layouts_v1(company_code, screen_id);
CREATE INDEX idx_v1_company_parent ON screen_layouts_v1(company_code, parent_id);
CREATE INDEX idx_v1_component_url ON screen_layouts_v1(component_url);
```
### API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v1
```
### 남은 작업
| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 3-1 | 바인딩 컨텍스트 (ScreenContext) 구현 | 🔲 대기 |
| 3-2 | ActionRunner 공통 엔진 구현 | 🔲 대기 |
| 4 | 화면 디자이너 저장 포맷 변경 | 🔲 대기 |
| 5 | 컴포넌트별 defaultConfig 정의 | 🔲 대기 |

View File

@ -1,496 +0,0 @@
# 컴포넌트 관리 시스템 리팩토링 제안서
## 1. 현재 문제점
### 1.1 핵심 문제
```
컴포넌트 오류 발생 시 → 코드 수정 → 해당 컴포넌트 사용하는 모든 화면에 영향
```
현재 구조에서는:
- 컴포넌트 코드가 **프론트엔드에 하드코딩**되어 있음
- 설정이 **JSONB로 각 화면마다 중복 저장**됨
- 컴포넌트 수정 시 **개별 화면 데이터 마이그레이션 필요**
### 1.2 구체적 문제 사례
```
예: v2-table-list 컴포넌트의 pagination 구조 변경 시
현재 방식:
1. 프론트엔드 코드 수정
2. screen_layouts 테이블의 모든 해당 컴포넌트 JSON 수정 필요
3. 100개 화면에서 사용 중이면 100개 레코드 마이그레이션
4. 테스트 및 검증 공수 발생
```
---
## 2. 개선 방안 비교
### 방안 1: URL 기반 코드 참조 + 설정 분리
#### 개념
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트 코드 (URL 참조) │
├─────────────────────────────────────────────────────────────┤
│ 경로: /lib/registry/components/v2-table-list/ │
│ - 상대경로: ./v2-table-list │
│ - 절대경로: @/lib/registry/components/v2-table-list │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 설정 분리 저장 │
├────────────────────────┬────────────────────────────────────┤
│ 공용 설정 (1개) │ 회사별 설정 (N개) │
│ │ │
│ - 기본 pagination │ - A회사: pageSize=20 │
│ - 기본 toolbar │ - B회사: pageSize=50 │
│ - 기본 columns 구조 │ - C회사: 특수 컬럼 추가 │
└────────────────────────┴────────────────────────────────────┘
```
#### 데이터베이스 구조 (예시)
```sql
-- 1. 컴포넌트 정의 테이블 (공용)
CREATE TABLE component_definitions (
component_id VARCHAR(50) PRIMARY KEY, -- 'v2-table-list'
component_path VARCHAR(200) NOT NULL, -- '@/lib/registry/components/v2-table-list'
component_name VARCHAR(100), -- '테이블 리스트'
category VARCHAR(50), -- 'display'
version VARCHAR(20), -- '2.1.0'
default_config JSONB, -- 기본 설정 (공용)
is_active CHAR(1) DEFAULT 'Y'
);
-- 2. 회사별 컴포넌트 설정 오버라이드
CREATE TABLE company_component_config (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
config_override JSONB, -- 회사별 오버라이드 설정
UNIQUE(company_code, component_id)
);
-- 3. 화면 레이아웃 (간소화)
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
instance_config JSONB -- 해당 인스턴스만의 설정 (최소화)
);
```
#### 설정 병합 로직
```typescript
// 설정 우선순위: 인스턴스 설정 > 회사 설정 > 공용 기본 설정
function getComponentConfig(componentId: string, companyCode: string, instanceConfig: any) {
const defaultConfig = await getDefaultConfig(componentId); // 공용
const companyConfig = await getCompanyConfig(componentId, companyCode); // 회사별
return deepMerge(defaultConfig, companyConfig, instanceConfig);
}
```
#### 장점
| 장점 | 설명 |
|-----|-----|
| **코드 단일 관리** | 컴포넌트 코드는 한 곳에서만 관리 (URL 참조) |
| **설정 계층화** | 공용 → 회사 → 인스턴스 순으로 설정 상속 |
| **유연한 커스터마이징** | 회사별로 다른 기본값 설정 가능 |
| **마이그레이션 최소화** | 공용 설정 변경 시 한 곳만 수정 |
| **버전 관리** | 컴포넌트 버전별 호환성 관리 가능 |
#### 단점
| 단점 | 설명 |
|-----|-----|
| **복잡한 병합 로직** | 3단계 설정 병합 로직 필요 |
| **런타임 오버헤드** | 설정 조회 시 여러 테이블 JOIN |
| **디버깅 어려움** | 최종 설정이 어디서 온 것인지 추적 필요 |
| **기존 데이터 마이그레이션** | 기존 JSONB 데이터를 분리 저장 필요 |
---
### 방안 2: 정형화된 테이블 (컬럼 파싱)
#### 개념
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트별 전용 테이블 생성 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ table_list │ │ button_config │ │ split_panel │
│ _components │ │ _components │ │ _components │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ id │ │ id │ │ id │
│ screen_id │ │ screen_id │ │ screen_id │
│ table_name │ │ action_type │ │ left_table │
│ page_size │ │ target_screen │ │ right_table │
│ show_checkbox │ │ button_text │ │ split_ratio │
│ show_excel │ │ icon │ │ transfer_type │
│ ... │ │ ... │ │ ... │
└───────────────┘ └───────────────┘ └───────────────┘
```
#### 데이터베이스 구조 (예시)
```sql
-- 1. 공통 컴포넌트 메타 테이블
CREATE TABLE component_instances (
instance_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
component_type VARCHAR(50) NOT NULL, -- 'table-list', 'button', 'split-panel'
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
company_code VARCHAR(50)
);
-- 2. 테이블 리스트 컴포넌트 전용 테이블
CREATE TABLE component_table_list (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
table_name VARCHAR(100),
page_size INTEGER DEFAULT 20,
show_checkbox BOOLEAN DEFAULT true,
checkbox_multiple BOOLEAN DEFAULT true,
show_excel BOOLEAN DEFAULT true,
show_refresh BOOLEAN DEFAULT true,
show_search BOOLEAN DEFAULT true,
header_style VARCHAR(20) DEFAULT 'default',
row_height VARCHAR(20) DEFAULT 'normal',
auto_load BOOLEAN DEFAULT true
);
-- 3. 테이블 리스트 컬럼 설정 테이블
CREATE TABLE component_table_list_columns (
id SERIAL PRIMARY KEY,
table_list_id INTEGER REFERENCES component_table_list(id),
column_name VARCHAR(100) NOT NULL,
display_name VARCHAR(100),
visible BOOLEAN DEFAULT true,
sortable BOOLEAN DEFAULT true,
searchable BOOLEAN DEFAULT false,
width INTEGER,
align VARCHAR(10) DEFAULT 'left',
format VARCHAR(20) DEFAULT 'text',
display_order INTEGER DEFAULT 0,
fixed VARCHAR(10), -- 'left', 'right', null
editable BOOLEAN DEFAULT true
);
-- 4. 버튼 컴포넌트 전용 테이블
CREATE TABLE component_button (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
button_text VARCHAR(100),
action_type VARCHAR(50), -- 'save', 'delete', 'navigate', 'popup'
target_screen_id INTEGER,
target_url VARCHAR(500),
numbering_rule_id VARCHAR(100),
variant VARCHAR(20) DEFAULT 'default',
size VARCHAR(10) DEFAULT 'md',
icon VARCHAR(50)
);
-- 5. 분할 패널 컴포넌트 전용 테이블
CREATE TABLE component_split_panel (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
left_table_name VARCHAR(100),
right_table_name VARCHAR(100),
split_ratio INTEGER DEFAULT 50,
transfer_enabled BOOLEAN DEFAULT true,
transfer_button_label VARCHAR(100)
);
```
#### 장점
| 장점 | 설명 |
|-----|-----|
| **타입 안정성** | 각 컬럼이 명확한 데이터 타입 |
| **SQL 쿼리 용이** | `WHERE page_size > 50` 같은 직접 쿼리 가능 |
| **인덱스 최적화** | 특정 컬럼에 인덱스 생성 가능 |
| **데이터 무결성** | 외래키, CHECK 제약 조건 적용 가능 |
| **일괄 수정 용이** | `UPDATE component_table_list SET page_size = 30 WHERE ...` |
| **명확한 스키마** | 어떤 설정이 있는지 테이블 구조로 명확히 파악 |
#### 단점
| 단점 | 설명 |
|-----|-----|
| **테이블 폭발** | 70+ 컴포넌트 × 하위 설정 = 100개 이상 테이블 |
| **스키마 변경 필수** | 새 설정 추가 시 ALTER TABLE 필요 |
| **JOIN 복잡도** | 화면 로드 시 여러 테이블 JOIN |
| **유연성 저하** | 임시/실험적 설정 저장 어려움 |
| **마이그레이션 대규모** | 기존 JSONB → 정형 테이블 대규모 작업 |
---
## 3. 상세 비교 분석
### 3.1 개발 공수 비교
| 항목 | 방안 1 (URL + 설정 분리) | 방안 2 (정형 테이블) |
|-----|------------------------|-------------------|
| 초기 설계 | 중간 | 높음 (테이블 설계) |
| 마이그레이션 | 중간 | 매우 높음 |
| 프론트엔드 수정 | 중간 | 높음 (쿼리 변경) |
| 백엔드 수정 | 중간 | 높음 (ORM/쿼리) |
| 테스트 | 중간 | 높음 |
### 3.2 유지보수 비교
| 항목 | 방안 1 | 방안 2 |
|-----|-------|-------|
| 컴포넌트 버그 수정 | 쉬움 (코드만) | 쉬움 (코드만) |
| 새 설정 추가 | 쉬움 (JSON 확장) | 어려움 (ALTER TABLE) |
| 일괄 설정 변경 | 중간 (JSON 쿼리) | 쉬움 (SQL UPDATE) |
| 디버깅 | 중간 | 쉬움 (명확한 컬럼) |
### 3.3 성능 비교
| 항목 | 방안 1 | 방안 2 |
|-----|-------|-------|
| 읽기 성능 | 중간 (설정 병합) | 좋음 (직접 조회) |
| 쓰기 성능 | 좋음 (단일 JSONB) | 중간 (여러 테이블) |
| 검색 성능 | 나쁨 (JSONB 검색) | 좋음 (인덱스) |
| 캐싱 | 좋음 (계층 캐싱) | 중간 |
---
## 4. 하이브리드 방안 제안
두 방안의 장점을 결합한 **하이브리드 접근법**:
### 4.1 구조
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트 메타 (정형 테이블) │
├─────────────────────────────────────────────────────────────┤
│ component_id | path | name | category | version │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 설정 계층 (공용 → 회사 → 인스턴스) │
├────────────────────────┬────────────────────────────────────┤
│ 공용 기본 설정 (JSONB) │ 회사별 오버라이드 (JSONB) │
└────────────────────────┴────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 핵심 설정만 정형 컬럼 (자주 검색/수정) │
├─────────────────────────────────────────────────────────────┤
│ table_name | page_size | is_active | ... │
│ + extra_config JSONB (나머지 설정) │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 데이터베이스 구조
```sql
-- 1. 컴포넌트 정의 (공용)
CREATE TABLE component_definitions (
component_id VARCHAR(50) PRIMARY KEY,
component_path VARCHAR(200) NOT NULL,
component_name VARCHAR(100),
category VARCHAR(50),
version VARCHAR(20),
default_config JSONB, -- 기본 설정
schema_version INTEGER DEFAULT 1, -- 설정 스키마 버전
is_active CHAR(1) DEFAULT 'Y'
);
-- 2. 컴포넌트 인스턴스 (핵심 필드 정형화 + 나머지 JSONB)
CREATE TABLE component_instances (
instance_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
-- 공통 정형 필드 (자주 검색/수정)
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
is_visible BOOLEAN DEFAULT true,
display_order INTEGER DEFAULT 0,
-- 컴포넌트 타입별 핵심 필드 (자주 검색/수정)
target_table VARCHAR(100), -- table-list, split-panel 등
action_type VARCHAR(50), -- button
-- 나머지 상세 설정 (유연성)
config_override JSONB, -- 인스턴스별 설정 오버라이드
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 3. 회사별 컴포넌트 기본 설정
CREATE TABLE company_component_defaults (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
config_override JSONB, -- 회사별 기본값 오버라이드
UNIQUE(company_code, component_id)
);
-- 인덱스 최적화
CREATE INDEX idx_instances_screen ON component_instances(screen_id);
CREATE INDEX idx_instances_company ON component_instances(company_code);
CREATE INDEX idx_instances_component ON component_instances(component_id);
CREATE INDEX idx_instances_target_table ON component_instances(target_table);
```
### 4.3 설정 조회 로직
```typescript
async function getComponentFullConfig(
instanceId: number,
companyCode: string
): Promise<ComponentConfig> {
// 1. 인스턴스 + 컴포넌트 정의 조회 (단일 쿼리)
const result = await query(`
SELECT
i.*,
d.default_config,
c.config_override as company_override
FROM component_instances i
JOIN component_definitions d ON i.component_id = d.component_id
LEFT JOIN company_component_defaults c
ON c.component_id = i.component_id
AND c.company_code = i.company_code
WHERE i.instance_id = $1
`, [instanceId]);
// 2. 설정 병합 (공용 → 회사 → 인스턴스)
return deepMerge(
result.default_config, // 공용 기본값
result.company_override, // 회사별 오버라이드
result.config_override // 인스턴스별 오버라이드
);
}
```
### 4.4 일괄 수정 예시
```sql
-- 특정 테이블을 사용하는 모든 컴포넌트의 page_size 변경
UPDATE component_instances
SET config_override = jsonb_set(
COALESCE(config_override, '{}'),
'{pagination,pageSize}',
'30'
)
WHERE target_table = 'user_info';
-- 특정 회사의 모든 테이블 리스트 기본값 변경
UPDATE company_component_defaults
SET config_override = jsonb_set(
COALESCE(config_override, '{}'),
'{pagination,pageSize}',
'50'
)
WHERE company_code = 'COMPANY_A'
AND component_id = 'v2-table-list';
```
---
## 5. 권장사항
### 5.1 단기 (1-2주)
**방안 1 (URL + 설정 분리)** 권장
이유:
- 현재 JSONB 구조와 호환성 유지
- 마이그레이션 공수 최소화
- 점진적 적용 가능
### 5.2 장기 (1-2개월)
**하이브리드 방안** 권장
이유:
- 자주 검색/수정되는 핵심 필드만 정형화
- 나머지는 JSONB로 유연성 유지
- 성능과 유연성의 균형
---
## 6. 마이그레이션 로드맵
### Phase 1: 컴포넌트 정의 분리 (1주)
```sql
-- 기존 컴포넌트를 component_definitions로 추출
INSERT INTO component_definitions (component_id, component_path, default_config)
SELECT DISTINCT
componentType,
CONCAT('@/lib/registry/components/', componentType),
'{}' -- 기본값은 코드에서 정의
FROM (
SELECT properties->>'componentType' as componentType
FROM screen_layouts
WHERE properties->>'componentType' IS NOT NULL
) t;
```
### Phase 2: 회사별 설정 분리 (1주)
```typescript
// 각 회사별 공통 패턴 분석 후 company_component_defaults 생성
async function extractCompanyDefaults(companyCode: string) {
// 해당 회사의 컴포넌트 사용 패턴 분석
// 가장 많이 사용되는 설정을 기본값으로 추출
}
```
### Phase 3: 인스턴스 설정 최소화 (2주)
```typescript
// 인스턴스별 설정에서 기본값과 동일한 부분 제거
async function minimizeInstanceConfig(instanceId: number) {
const fullConfig = currentConfig;
const defaultConfig = getDefaultConfig();
const companyConfig = getCompanyConfig();
// 차이나는 부분만 저장
const minimalConfig = getDiff(fullConfig, merge(defaultConfig, companyConfig));
await saveInstanceConfig(instanceId, minimalConfig);
}
```
---
## 7. 결론
| 방안 | 적합한 상황 |
|-----|-----------|
| **방안 1 (URL + 설정 분리)** | 빠른 개선이 필요하고, 현재 구조와의 호환성 중요 시 |
| **방안 2 (정형 테이블)** | 완전한 재설계가 가능하고, 장기적 유지보수 최우선 시 |
| **하이브리드** | 두 방안의 장점을 모두 원하고, 충분한 개발 리소스 있을 시 |
**권장**: 단기적으로 **방안 1**을 적용하고, 안정화 후 **하이브리드**로 전환

View File

@ -1,672 +0,0 @@
# 컴포넌트 시스템 마이그레이션 계획서
## 1. 개요
### 1.1 목적
- 현재 JSON 기반 컴포넌트 관리 시스템을 URL 참조 + Zod 스키마 기반으로 전환
- 컴포넌트 코드 수정 시 모든 회사에 즉시 반영되는 구조로 개선
- JSON 구조 표준화 및 런타임 검증 체계 구축
### 1.2 핵심 원칙
1. **화면 동일성 유지**: 마이그레이션 전후 렌더링 결과가 100% 동일해야 함
2. **안전한 테스트**: 기존 테이블 수정 없이 새 테이블에서 테스트
3. **롤백 가능**: 문제 발생 시 즉시 원복 가능한 구조
### 1.3 현재 상태 (DB 분석 결과)
| 항목 | 수치 |
|-----|-----|
| 총 레코드 | 7,170개 |
| 화면 수 | 1,363개 |
| 회사 수 | 15개 |
| 컴포넌트 타입 | 50개 |
---
## 2. 테이블 구조
### 2.1 기존 테이블: `screen_layouts`
```sql
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL,
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100),
position_x INTEGER NOT NULL,
position_y INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
properties JSONB, -- 전체 설정이 포함됨
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100)
);
```
### 2.2 신규 테이블: `screen_layouts_v2` (테스트용)
```sql
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL,
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100),
position_x INTEGER NOT NULL,
position_y INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 변경된 부분
component_ref VARCHAR(100) NOT NULL, -- 컴포넌트 URL 참조 (예: "button-primary")
config_overrides JSONB DEFAULT '{}', -- 기본값과 다른 설정만 저장
-- 기존 필드 유지
properties JSONB, -- 기존 호환용 (마이그레이션 완료 후 제거)
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100),
-- 마이그레이션 추적
migrated_at TIMESTAMPTZ,
migration_status VARCHAR(20) DEFAULT 'pending' -- pending, success, failed
);
```
---
## 3. 마이그레이션 단계
### 3.1 Phase 1: 테이블 생성 및 데이터 복사
```sql
-- Step 1: 새 테이블 생성
CREATE TABLE screen_layouts_v2 AS
SELECT * FROM screen_layouts;
-- Step 2: 새 컬럼 추가
ALTER TABLE screen_layouts_v2
ADD COLUMN component_ref VARCHAR(100),
ADD COLUMN config_overrides JSONB DEFAULT '{}',
ADD COLUMN migrated_at TIMESTAMPTZ,
ADD COLUMN migration_status VARCHAR(20) DEFAULT 'pending';
-- Step 3: component_ref 초기값 설정
UPDATE screen_layouts_v2
SET component_ref = properties->>'componentType'
WHERE properties->>'componentType' IS NOT NULL;
```
### 3.2 Phase 2: Zod 스키마 정의
각 컴포넌트별 스키마 파일 생성:
```
frontend/lib/schemas/components/
├── button-primary.schema.ts
├── text-input.schema.ts
├── table-list.schema.ts
├── select-basic.schema.ts
├── date-input.schema.ts
├── file-upload.schema.ts
├── tabs-widget.schema.ts
├── split-panel-layout.schema.ts
├── flow-widget.schema.ts
└── ... (50개)
```
### 3.3 Phase 3: 차이값 추출
```typescript
// 마이그레이션 스크립트 (backend-node)
async function extractConfigDiff(layoutId: number) {
const layout = await getLayoutById(layoutId);
const componentType = layout.properties?.componentType;
if (!componentType) {
return { status: 'skip', reason: 'no componentType' };
}
// 스키마에서 기본값 가져오기
const schema = getSchemaByType(componentType);
const defaults = schema.parse({});
// 현재 저장된 설정
const currentConfig = layout.properties?.componentConfig || {};
// 기본값과 다른 것만 추출
const overrides = extractDifferences(defaults, currentConfig);
return {
status: 'success',
component_ref: componentType,
config_overrides: overrides,
original_config: currentConfig
};
}
```
### 3.4 Phase 4: 렌더링 동일성 검증
```typescript
// 검증 스크립트
async function verifyRenderingEquality(layoutId: number) {
// 기존 방식으로 로드
const originalConfig = await loadOriginalConfig(layoutId);
// 새 방식으로 로드 (기본값 + overrides 병합)
const migratedConfig = await loadMigratedConfig(layoutId);
// 깊은 비교
const isEqual = deepEqual(originalConfig, migratedConfig);
if (!isEqual) {
const diff = getDifferences(originalConfig, migratedConfig);
console.error(`Layout ${layoutId} 불일치:`, diff);
return false;
}
return true;
}
```
---
## 4. 컴포넌트별 분석
### 4.1 상위 10개 컴포넌트 (우선 처리)
| 순위 | 컴포넌트 | 개수 | JSON 일관성 | 복잡도 |
|-----|---------|-----|------------|-------|
| 1 | button-primary | 1,527 | 100% | 낮음 |
| 2 | text-input | 700 | 95% | 낮음 |
| 3 | table-search-widget | 353 | 100% | 중간 |
| 4 | table-list | 280 | 84% | 높음 |
| 5 | file-upload | 143 | 100% | 중간 |
| 6 | select-basic | 129 | 100% | 낮음 |
| 7 | split-panel-layout | 129 | 100% | 높음 |
| 8 | date-input | 116 | 100% | 낮음 |
| 9 | v2-list | 97 | 100% | 높음 |
| 10 | number-input | 87 | 100% | 낮음 |
### 4.2 발견된 문제점
#### 문제 1: componentType ≠ componentConfig.type
```sql
-- 166개 불일치 발견
SELECT COUNT(*) FROM screen_layouts
WHERE properties->>'componentType' = 'text-input'
AND properties->'componentConfig'->>'type' != 'text-input';
```
**해결**: 마이그레이션 시 `componentConfig.type``componentType`으로 통일
#### 문제 2: 키 누락 (table-list)
```sql
-- 44개 (16%) pagination/checkbox 없음
SELECT COUNT(*) FROM screen_layouts
WHERE properties->>'componentType' = 'table-list'
AND properties->'componentConfig' ? 'pagination' = false;
```
**해결**: 누락된 키는 기본값으로 자동 채움 (Zod 스키마 활용)
---
## 5. Zod 스키마 예시
### 5.1 button-primary
```typescript
// frontend/lib/schemas/components/button-primary.schema.ts
import { z } from "zod";
export const buttonActionSchema = z.object({
type: z.enum([
"save", "modal", "openModalWithData", "edit", "delete",
"control", "excel_upload", "excel_download", "transferData",
"copy", "code_merge", "view_table_history", "quickInsert",
"openRelatedModal", "operation_control", "geolocation",
"update_field", "search", "submit", "cancel", "add",
"navigate", "empty_vehicle", "reset", "close"
]).default("save"),
targetScreenId: z.number().optional(),
successMessage: z.string().optional(),
errorMessage: z.string().optional(),
});
export const buttonPrimarySchema = z.object({
text: z.string().default("저장"),
type: z.literal("button-primary").default("button-primary"),
actionType: z.enum(["button", "submit", "reset"]).default("button"),
variant: z.enum(["primary", "secondary", "danger"]).default("primary"),
webType: z.literal("button").default("button"),
action: buttonActionSchema.optional(),
});
export type ButtonPrimaryConfig = z.infer<typeof buttonPrimarySchema>;
export const buttonPrimaryDefaults = buttonPrimarySchema.parse({});
```
### 5.2 table-list
```typescript
// frontend/lib/schemas/components/table-list.schema.ts
import { z } from "zod";
export const paginationSchema = z.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
showPageInfo: z.boolean().default(true),
pageSizeOptions: z.array(z.number()).default([10, 20, 50, 100]),
});
export const checkboxSchema = z.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.enum(["left", "right"]).default("left"),
selectAll: z.boolean().default(true),
});
export const tableListSchema = z.object({
type: z.literal("table-list").default("table-list"),
webType: z.literal("table").default("table"),
displayMode: z.enum(["table", "card"]).default("table"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(true),
autoLoad: z.boolean().default(true),
autoWidth: z.boolean().default(true),
stickyHeader: z.boolean().default(false),
height: z.enum(["auto", "fixed", "viewport"]).default("auto"),
columns: z.array(z.any()).default([]),
pagination: paginationSchema.default({}),
checkbox: checkboxSchema.default({}),
horizontalScroll: z.object({
enabled: z.boolean().default(false),
}).default({}),
filter: z.object({
enabled: z.boolean().default(false),
filters: z.array(z.any()).default([]),
}).default({}),
actions: z.object({
showActions: z.boolean().default(false),
actions: z.array(z.any()).default([]),
bulkActions: z.boolean().default(false),
bulkActionList: z.array(z.string()).default([]),
}).default({}),
tableStyle: z.object({
theme: z.enum(["default", "striped", "bordered", "minimal"]).default("default"),
headerStyle: z.enum(["default", "dark", "light"]).default("default"),
rowHeight: z.enum(["compact", "normal", "comfortable"]).default("normal"),
alternateRows: z.boolean().default(false),
hoverEffect: z.boolean().default(true),
borderStyle: z.enum(["none", "light", "heavy"]).default("light"),
}).default({}),
});
export type TableListConfig = z.infer<typeof tableListSchema>;
export const tableListDefaults = tableListSchema.parse({});
```
---
## 6. 렌더링 로직 변경
### 6.1 현재 방식
```typescript
// DynamicComponentRenderer.tsx (현재)
function renderComponent(layout: ScreenLayout) {
const config = layout.properties?.componentConfig || {};
return <Component config={config} />;
}
```
### 6.2 변경 후 방식
```typescript
// DynamicComponentRenderer.tsx (변경 후)
function renderComponent(layout: ScreenLayoutV2) {
const componentRef = layout.component_ref;
const overrides = layout.config_overrides || {};
// 스키마에서 기본값 가져오기
const schema = getSchemaByType(componentRef);
const defaults = schema.parse({});
// 기본값 + overrides 병합
const config = deepMerge(defaults, overrides);
return <Component config={config} />;
}
```
---
## 7. 테스트 계획
### 7.1 단위 테스트
```typescript
describe("ComponentMigration", () => {
test("button-primary 기본값 병합", () => {
const overrides = { text: "등록" };
const result = mergeWithDefaults("button-primary", overrides);
expect(result.text).toBe("등록"); // override 값
expect(result.variant).toBe("primary"); // 기본값
expect(result.actionType).toBe("button"); // 기본값
});
test("table-list 누락된 키 복구", () => {
const overrides = { columns: [...] }; // pagination 없음
const result = mergeWithDefaults("table-list", overrides);
expect(result.pagination.enabled).toBe(true);
expect(result.pagination.pageSize).toBe(20);
});
});
```
### 7.2 통합 테스트
```typescript
describe("RenderingEquality", () => {
test("모든 레이아웃 렌더링 동일성 검증", async () => {
const layouts = await getAllLayouts();
for (const layout of layouts) {
const original = await renderOriginal(layout);
const migrated = await renderMigrated(layout);
expect(migrated).toEqual(original);
}
});
});
```
---
## 8. 롤백 계획
### 8.1 즉시 롤백
```sql
-- 마이그레이션 실패 시 원래 properties 사용
UPDATE screen_layouts_v2
SET migration_status = 'rollback'
WHERE layout_id = ?;
```
### 8.2 전체 롤백
```sql
-- 기존 테이블로 복귀
DROP TABLE screen_layouts_v2;
-- 기존 screen_layouts 계속 사용
```
---
## 9. 작업 순서
### Step 1: 테이블 생성 및 데이터 복사
- [ ] `screen_layouts_v2` 테이블 생성
- [ ] 기존 데이터 복사
- [ ] 새 컬럼 추가
### Step 2: Zod 스키마 정의 (상위 10개)
- [ ] button-primary
- [ ] text-input
- [ ] table-search-widget
- [ ] table-list
- [ ] file-upload
- [ ] select-basic
- [ ] split-panel-layout
- [ ] date-input
- [ ] v2-list
- [ ] number-input
### Step 3: 마이그레이션 스크립트
- [ ] 차이값 추출 함수
- [ ] 렌더링 동일성 검증 함수
- [ ] 배치 마이그레이션 스크립트
### Step 4: 테스트
- [ ] 단위 테스트
- [ ] 통합 테스트
- [ ] 화면 렌더링 비교
### Step 5: 적용
- [ ] 프론트엔드 렌더링 로직 수정
- [ ] 백엔드 저장 로직 수정
- [ ] 기존 테이블 교체
---
## 10. 예상 일정
| 단계 | 작업 | 예상 기간 |
|-----|-----|---------|
| 1 | 테이블 생성 및 복사 | 1일 |
| 2 | 상위 10개 스키마 정의 | 3일 |
| 3 | 마이그레이션 스크립트 | 3일 |
| 4 | 테스트 및 검증 | 3일 |
| 5 | 나머지 40개 스키마 | 5일 |
| 6 | 전체 마이그레이션 | 2일 |
| 7 | 프론트엔드 적용 | 2일 |
| **총계** | | **약 19일 (4주)** |
---
## 11. 주의사항
1. **기존 DB 수정 금지**: 모든 테스트는 `screen_layouts_v2`에서만 진행
2. **화면 동일성 우선**: 렌더링 결과가 다르면 마이그레이션 중단
3. **단계별 검증**: 각 단계 완료 후 검증 통과해야 다음 단계 진행
4. **롤백 대비**: 언제든 기존 시스템으로 복귀 가능해야 함
---
## 12. 마이그레이션 실행 결과 (2026-01-27)
### 12.1 실행 환경
```
테이블: screen_layouts_v2 (테스트용)
백업: screen_layouts_backup_20260127
원본: screen_layouts (변경 없음)
```
### 12.2 마이그레이션 결과
| 상태 | 개수 | 비율 |
|-----|-----|-----|
| **success** | 5,805 | 81.0% |
| **skip** | 1,365 | 19.0% (metadata) |
| **pending** | 0 | 0% |
| **fail** | 0 | 0% |
### 12.3 데이터 절약량
| 항목 | 수치 |
|-----|-----|
| 원본 총 크기 | **5.81 MB** |
| config_overrides 총 크기 | **2.54 MB** |
| **절약량** | **3.27 MB (56.2%)** |
### 12.4 컴포넌트별 결과
| 컴포넌트 | 개수 | 원본(bytes) | override(bytes) | 절약률 |
|---------|-----|------------|-----------------|-------|
| text-input | 1,797 | 701 | 143 | **79.6%** |
| button-primary | 1,527 | 939 | 218 | **76.8%** |
| table-search-widget | 353 | 635 | 150 | **76.4%** |
| select-basic | 287 | 660 | 172 | **73.9%** |
| table-list | 280 | 2,690 | 2,020 | 24.9% |
| file-upload | 143 | 1,481 | 53 | **96.4%** |
| date-input | 137 | 628 | 111 | **82.3%** |
| split-panel-layout | 129 | 2,556 | 2,040 | 20.2% |
| number-input | 115 | 646 | 121 | **81.2%** |
### 12.5 config_overrides 구조
```json
{
"_originalKeys": ["text", "type", "action", "variant", "webType", "actionType"],
"text": "등록",
"action": {
"type": "modal",
"targetScreenId": 26
}
}
```
- `_originalKeys`: 원본에 있던 키 목록 (복원 시 사용)
- 나머지: 기본값과 다른 설정만 저장
### 12.6 렌더링 복원 로직
```typescript
function reconstructConfig(componentRef: string, overrides: any): any {
const defaults = getDefaultsByType(componentRef);
const originalKeys = overrides._originalKeys || Object.keys(defaults);
const result = {};
for (const key of originalKeys) {
if (overrides.hasOwnProperty(key) && key !== '_originalKeys') {
result[key] = overrides[key];
} else if (defaults.hasOwnProperty(key)) {
result[key] = defaults[key];
}
}
return result;
}
```
### 12.7 검증 결과
- **button-primary**: 1,527개 전체 검증 통과 (100%)
- **text-input**: 1,797개 전체 검증 통과 (100%)
- **table-list**: 280개 전체 검증 통과 (100%)
- **기타 모든 컴포넌트**: 전체 검증 통과 (100%)
### 12.8 다음 단계
1. [x] ~~Zod 스키마 파일 생성~~ ✅ 완료
2. [x] ~~백엔드 API에서 config_overrides 기반 응답 추가~~ ✅ 완료
3. [ ] 프론트엔드에서 V2 API 호출 테스트
4. [ ] 실제 화면에서 렌더링 테스트
5. [ ] screen_layouts 테이블 교체 (운영 적용)
---
## 13. Zod 스키마 파일 생성 완료 (2026-01-27)
### 13.1 생성된 파일 목록
```
frontend/lib/schemas/components/
├── index.ts # 메인 인덱스 + 복원 유틸리티
├── button-primary.ts # 버튼 스키마
├── text-input.ts # 텍스트 입력 스키마
├── table-list.ts # 테이블 리스트 스키마
├── select-basic.ts # 셀렉트 스키마
├── date-input.ts # 날짜 입력 스키마
├── file-upload.ts # 파일 업로드 스키마
└── number-input.ts # 숫자 입력 스키마
```
### 13.2 주요 유틸리티 함수
```typescript
// 컴포넌트 기본값 조회
import { getComponentDefaults } from "@/lib/schemas/components";
const defaults = getComponentDefaults("button-primary");
// 설정 복원 (기본값 + overrides 병합)
import { reconstructConfig } from "@/lib/schemas/components";
const fullConfig = reconstructConfig("button-primary", overrides);
// 차이값 추출 (저장 시 사용)
import { extractConfigDiff } from "@/lib/schemas/components";
const diff = extractConfigDiff("button-primary", currentConfig);
```
### 13.3 componentDefaults 레지스트리
50개 컴포넌트의 기본값이 `componentDefaults` 맵에 등록됨:
- button-primary, v2-button-primary
- text-input, number-input, date-input
- select-basic, checkbox-basic, radio-basic
- table-list, v2-table-list
- tabs-widget, v2-tabs-widget
- split-panel-layout, v2-split-panel-layout
- flow-widget, category-manager
- 기타 40+ 컴포넌트
---
## 14. 백엔드 API 추가 완료 (2026-01-27)
### 14.1 수정된 파일
| 파일 | 변경 내용 |
|-----|----------|
| `backend-node/src/utils/componentDefaults.ts` | 컴포넌트 기본값 + 복원 유틸리티 신규 생성 |
| `backend-node/src/services/screenManagementService.ts` | `getLayoutV2()` 함수 추가 |
| `backend-node/src/controllers/screenManagementController.ts` | `getLayoutV2` 컨트롤러 추가 |
| `backend-node/src/routes/screenManagementRoutes.ts` | `/screens/:screenId/layout-v2` 라우트 추가 |
### 14.2 새로운 API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v2
```
**응답 구조**: 기존 `getLayout`과 동일
**차이점**:
- `screen_layouts_v2` 테이블에서 조회
- `migration_status = 'success'`인 레코드는 `config_overrides` + 기본값 병합
- 마이그레이션 안 된 레코드는 기존 `properties.componentConfig` 사용
### 14.3 복원 로직 흐름
```
1. screen_layouts_v2에서 조회
2. migration_status 확인
├─ 'success': reconstructConfig(componentRef, configOverrides)
└─ 기타: 기존 properties.componentConfig 사용
3. 최신 inputType 정보 병합 (table_type_columns)
4. 전체 componentConfig 반환
```
### 14.4 테스트 방법
```bash
# 기존 API
curl "http://localhost:8080/api/screen-management/screens/1/layout" -H "Authorization: Bearer ..."
# V2 API
curl "http://localhost:8080/api/screen-management/screens/1/layout-v2" -H "Authorization: Bearer ..."
```
두 응답의 `components[].componentConfig`가 동일해야 함
---
*작성일: 2026-01-27*
*작성자: AI Assistant*
*버전: 1.1 (마이그레이션 실행 결과 추가)*

View File

@ -1,233 +0,0 @@
ㅡㄹ ㅣ # 컴포넌트 URL 시스템 구현 완료
## 실행 일시: 2026-01-27
## 1. 목표
- 컴포넌트 코드 수정 시 **모든 회사에 즉시 반영**
- 회사별 고유 설정은 **JSON으로 안전하게 관리** (Zod 검증) ✅
- 기존 화면 **100% 동일하게 렌더링** 보장 ✅
---
## 2. 완료된 작업
### 2.1 DB 테이블 생성
- `screen_layouts_v3` 테이블 생성 완료
- 4,414개 레코드 마이그레이션 완료
### 2.2 파일 생성/수정
| 파일 | 상태 |
|-----|-----|
| `frontend/lib/schemas/componentConfig.ts` | ✅ 신규 생성 |
| `backend-node/src/services/screenManagementService.ts` | ✅ getLayoutV3 추가 |
| `backend-node/src/controllers/screenManagementController.ts` | ✅ getLayoutV3 추가 |
| `backend-node/src/routes/screenManagementRoutes.ts` | ✅ 라우트 추가 |
### 2.3 API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v3
```
---
## 3. 핵심 구조
### 2.1 컴포넌트 코드 (파일 시스템)
```
frontend/lib/registry/components/{component-name}/
├── index.ts # 렌더링 로직, UI
├── schema.ts # Zod 스키마 + 기본값
└── types.ts # 타입 정의
```
### 2.2 DB 구조
```sql
screen_layouts_v3 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_id VARCHAR(100) UNIQUE NOT NULL,
-- 컴포넌트 URL (파일 경로)
component_url VARCHAR(200) NOT NULL,
-- 예: "@/lib/registry/components/split-panel-layout"
-- 회사별 커스텀 설정 (비즈니스 데이터만)
custom_config JSONB NOT NULL DEFAULT '{}',
-- 레이아웃 정보
parent_id VARCHAR(100),
position_x INTEGER NOT NULL DEFAULT 0,
position_y INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 100,
height INTEGER NOT NULL DEFAULT 100,
display_order INTEGER DEFAULT 0,
-- 기타
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
```
---
## 3. 대상 컴포넌트 (고수준)
| 컴포넌트 | 개수 | 우선순위 |
|---------|-----|---------|
| split-panel-layout | 129 | 높음 |
| tabs-widget | 74 | 높음 |
| modal-repeater-table | 68 | 높음 |
| category-manager | 69 | 중간 |
| flow-widget | 11 | 중간 |
| table-list | 280 | 높음 |
| table-search-widget | 353 | 높음 |
| conditional-container | 53 | 중간 |
| selected-items-detail-input | 83 | 중간 |
---
## 4. 작업 단계
### Phase 1: 스키마 정의
- [ ] split-panel-layout/schema.ts
- [ ] tabs-widget/schema.ts
- [ ] modal-repeater-table/schema.ts
- [ ] table-list/schema.ts
- [ ] table-search-widget/schema.ts
- [ ] 기타 컴포넌트들
### Phase 2: DB 테이블 생성
- [ ] screen_layouts_v3 테이블 생성
- [ ] 인덱스 생성
### Phase 3: 마이그레이션
- [ ] 기존 데이터에서 component_url 추출
- [ ] 기존 데이터에서 custom_config 분리
- [ ] 검증 (기존 화면과 동일 렌더링)
### Phase 4: 백엔드 수정
- [ ] getLayoutV3 API 추가
- [ ] saveLayoutV3 API 추가
### Phase 5: 프론트엔드 수정
- [ ] 렌더링 로직에 스키마 병합 적용
- [ ] 화면 디자이너 저장 로직 수정
---
## 5. Zod 스키마 설계 원칙
### 5.1 기본값 (코드에서 관리)
```typescript
// 컴포넌트 UI/동작 관련 - 코드 수정 시 전체 반영
const baseDefaults = {
resizable: true,
splitRatio: 30,
syncSelection: true,
};
```
### 5.2 커스텀 설정 (DB에서 관리)
```typescript
// 비즈니스 데이터 - 회사별 개별 관리
const customConfigSchema = z.object({
leftPanel: z.object({
title: z.string().optional(),
tableName: z.string(),
columns: z.array(z.any()).default([]),
}).passthrough(),
rightPanel: z.object({
title: z.string().optional(),
tableName: z.string(),
relation: z.any().optional(),
}).passthrough(),
}).passthrough();
```
### 5.3 병합 로직
```typescript
function mergeConfig(baseDefaults: any, customConfig: any) {
// 1. 스키마로 customConfig 파싱 (없는 필드는 기본값)
const parsed = customConfigSchema.parse(customConfig);
// 2. 기본값과 병합
return { ...baseDefaults, ...parsed };
}
```
---
## 6. 렌더링 흐름
```
1. DB 조회
├─ component_url: "@/lib/registry/components/split-panel-layout"
└─ custom_config: { leftPanel: { tableName: "sales_order_mng", ... } }
2. 컴포넌트 로드
└─ ComponentRegistry.get("split-panel-layout")
3. 스키마 로드
└─ import { schema, baseDefaults } from "./schema"
4. 설정 병합
└─ baseDefaults + schema.parse(custom_config)
5. 렌더링
└─ <SplitPanelLayout config={mergedConfig} />
```
---
## 7. 마이그레이션 전략
### 7.1 component_url 추출
```sql
-- properties.componentType → component_url 변환
UPDATE screen_layouts_v3
SET component_url = '@/lib/registry/components/' || (properties->>'componentType')
WHERE properties->>'componentType' IS NOT NULL;
```
### 7.2 custom_config 분리
```javascript
// 기존 componentConfig에서 비즈니스 데이터만 추출
function extractCustomConfig(componentType, componentConfig) {
const baseKeys = getBaseKeys(componentType); // 코드 기본값 키들
const customConfig = {};
for (const key of Object.keys(componentConfig)) {
if (!baseKeys.includes(key)) {
customConfig[key] = componentConfig[key];
}
}
return customConfig;
}
```
### 7.3 검증
```javascript
// 기존 렌더링과 동일한지 확인
function verify(original, migrated) {
const originalRender = renderWithConfig(original.componentConfig);
const migratedRender = renderWithConfig(
merge(baseDefaults, migrated.custom_config)
);
return deepEqual(originalRender, migratedRender);
}
```
---
## 8. 체크리스트
- [ ] 컴포넌트 코드 수정 → 전체 회사 즉시 반영 확인
- [ ] 기존 고유 설정 100% 유지 확인
- [ ] 새 필드 추가 시 기본값 자동 적용 확인
- [ ] 기존 화면 렌더링 동일성 확인
- [ ] 화면 디자이너 저장/로드 정상 동작 확인

View File

@ -1,436 +0,0 @@
# 방안 1: 컴포넌트 URL 참조 + Zod 스키마 관리
## 1. 현재 문제점 정리
### 1.1 JSON 구조 불일치
```
현재 상태:
┌─────────────────────────────────────────────────────────────┐
│ v2-table-list 컴포넌트 │
│ 화면 A: { pageSize: 20, showCheckbox: true } │
│ 화면 B: { pagination: { size: 20 }, checkbox: true } │
│ 화면 C: { paging: { pageSize: 20 }, hasCheckbox: true } │
│ │
│ → 같은 설정인데 키 이름이 다름 │
│ → 타입 검증 없음 (런타임 에러 발생) │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 컴포넌트 수정 시 마이그레이션 필요
```
컴포넌트 구조 변경:
pageSize → pagination.pageSize 로 변경하면?
→ 100개 화면의 JSON 전부 마이그레이션 필요
→ 테스트 공수 발생
→ 누락 시 런타임 에러
```
---
## 2. 방안 1 + Zod 아키텍처
### 2.1 전체 구조
```
┌─────────────────────────────────────────────────────────────┐
│ 1. 컴포넌트 코드 + Zod 스키마 (프론트엔드) │
│ │
@/lib/registry/components/v2-table-list/ │
│ ├── index.ts # 컴포넌트 등록 │
│ ├── TableListRenderer.tsx # 렌더링 로직 │
│ ├── schema.ts # ⭐ Zod 스키마 정의 │
│ └── defaults.ts # ⭐ 기본값 정의 │
│ │
│ 코드 수정 → 빌드 → 전 회사 즉시 적용 │
└─────────────────────────────────────────────────────────────┘
│ URL로 참조
┌─────────────────────────────────────────────────────────────┐
│ 2. DB (최소한의 차이점만 저장) │
│ │
│ screen_layouts.properties = { │
│ "componentUrl": "@/registry/v2-table-list", │
│ "config": { │
│ "pageSize": 50 ← 기본값(20)과 다른 것만 │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
│ 설정 병합
┌─────────────────────────────────────────────────────────────┐
│ 3. 런타임: 기본값 + 오버라이드 병합 + Zod 검증 │
│ │
│ 최종 설정 = deepMerge(기본값, 오버라이드) │
│ 검증된 설정 = schema.parse(최종 설정) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Zod 스키마 예시
```typescript
// @/lib/registry/components/v2-table-list/schema.ts
import { z } from "zod";
// 컬럼 설정 스키마
const columnSchema = z.object({
columnName: z.string(),
displayName: z.string(),
visible: z.boolean().default(true),
sortable: z.boolean().default(true),
width: z.number().optional(),
align: z.enum(["left", "center", "right"]).default("left"),
format: z.enum(["text", "number", "date", "currency"]).default("text"),
order: z.number().default(0),
});
// 페이지네이션 스키마
const paginationSchema = z.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
pageSizeOptions: z.array(z.number()).default([10, 20, 50, 100]),
});
// 체크박스 스키마
const checkboxSchema = z.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.enum(["left", "right"]).default("left"),
});
// 테이블 리스트 전체 스키마
export const tableListSchema = z.object({
tableName: z.string(),
columns: z.array(columnSchema).default([]),
pagination: paginationSchema.default({}),
checkbox: checkboxSchema.default({}),
showHeader: z.boolean().default(true),
autoLoad: z.boolean().default(true),
});
// 타입 자동 추론
export type TableListConfig = z.infer<typeof tableListSchema>;
```
### 2.3 기본값 정의
```typescript
// @/lib/registry/components/v2-table-list/defaults.ts
import { TableListConfig } from "./schema";
export const defaultConfig: Partial<TableListConfig> = {
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
},
showHeader: true,
autoLoad: true,
};
```
### 2.4 설정 로드 로직
```typescript
// @/lib/registry/utils/configLoader.ts
import { deepMerge } from "@/lib/utils";
export function loadComponentConfig<T>(
componentUrl: string,
overrideConfig: Partial<T>
): T {
// 1. 컴포넌트 모듈에서 스키마와 기본값 가져오기
const { schema, defaultConfig } = getComponentModule(componentUrl);
// 2. 기본값 + 오버라이드 병합
const mergedConfig = deepMerge(defaultConfig, overrideConfig);
// 3. Zod 스키마로 검증 + 기본값 자동 적용
const validatedConfig = schema.parse(mergedConfig);
return validatedConfig;
}
```
---
## 3. 현재 시스템 적응도 분석
### 3.1 변경이 필요한 부분
| 영역 | 현재 | 변경 후 | 공수 |
|-----|-----|--------|-----|
| **컴포넌트 폴더 구조** | types.ts만 있음 | schema.ts, defaults.ts 추가 | 중간 |
| **screen_layouts** | 모든 설정 저장 | URL + 차이점만 저장 | 중간 |
| **화면 저장 로직** | JSON 통째로 저장 | 차이점 추출 후 저장 | 중간 |
| **화면 로드 로직** | JSON 그대로 사용 | 기본값 병합 + Zod 검증 | 낮음 |
| **기존 데이터** | - | 마이그레이션 필요 | 높음 |
### 3.2 기존 코드와의 호환성
```
현재 Zod 사용 현황:
✅ zod v4.1.5 이미 설치됨
@hookform/resolvers 설치됨 (react-hook-form + Zod 연동)
✅ 공통코드 관리에 Zod 스키마 사용 중 (lib/schemas/commonCode.ts)
→ Zod 패턴이 이미 프로젝트에 존재함
→ 동일한 패턴으로 컴포넌트 스키마 추가 가능
```
### 3.3 점진적 마이그레이션 가능 여부
```
Phase 1: 새 컴포넌트만 적용
- 신규 컴포넌트는 schema.ts + defaults.ts 구조로 생성
- 기존 컴포넌트는 그대로 유지
Phase 2: 핵심 컴포넌트 마이그레이션
- v2-table-list, v2-button-primary 등 자주 사용하는 것 먼저
- 기존 JSON 데이터 → 차이점만 남기고 정리
Phase 3: 전체 마이그레이션
- 나머지 컴포넌트 순차 적용
→ 점진적 적용 가능 ✅
```
---
## 4. 향후 장점
### 4.1 컴포넌트 수정 시
```
변경 전:
컴포넌트 수정 → 100개 화면 JSON 마이그레이션 → 테스트 → 배포
변경 후:
컴포넌트 수정 → 빌드 → 배포 → 끝
왜?
- 기본값/로직은 코드에 있음
- DB에는 "다른 것만" 저장되어 있음
- 코드 변경이 자동으로 모든 화면에 적용됨
```
### 4.2 새 설정 추가 시
```
변경 전:
1. types.ts 수정
2. 100개 화면 JSON에 새 필드 추가 (마이그레이션)
3. 기본값 없으면 에러 발생
변경 후:
1. schema.ts에 필드 추가 + .default() 설정
2. 끝. 기존 데이터는 자동으로 기본값 적용됨
// 예시
const schema = z.object({
// 기존 필드
pageSize: z.number().default(20),
// 🆕 새 필드 추가 - 기본값 있으면 마이그레이션 불필요
showRowNumber: z.boolean().default(false),
});
```
### 4.3 타입 안정성
```typescript
// 현재: 타입 검증 없음
const config = component.componentConfig; // any 타입
config.pageSize; // 있을 수도, 없을 수도...
config.pagination.pageSize; // 구조가 다를 수도...
// 변경 후: Zod로 검증 + TypeScript 타입 추론
const config = tableListSchema.parse(rawConfig);
config.pagination.pageSize; // ✅ 타입 보장
config.unknownField; // ❌ 컴파일 에러
```
### 4.4 런타임 에러 방지
```typescript
// Zod 검증 실패 시 명확한 에러 메시지
try {
const config = tableListSchema.parse(rawConfig);
} catch (error) {
if (error instanceof z.ZodError) {
console.error("설정 오류:", error.errors);
// [
// { path: ["pagination", "pageSize"], message: "Expected number, received string" },
// { path: ["columns", 0, "align"], message: "Invalid enum value" }
// ]
}
}
```
### 4.5 문서화 자동화
```typescript
// Zod 스키마에서 자동으로 문서 생성 가능
import { zodToJsonSchema } from "zod-to-json-schema";
const jsonSchema = zodToJsonSchema(tableListSchema);
// → JSON Schema 형식으로 변환 → 문서화 도구에서 사용
```
---
## 5. 유지보수 측면
### 5.1 컴포넌트 개발자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 새 컴포넌트 생성 | types.ts 작성 (선택) | schema.ts + defaults.ts 작성 (필수) |
| 설정 구조 변경 | 마이그레이션 스크립트 작성 | schema 수정 + 기본값 설정 |
| 타입 체크 | 수동 검증 | Zod가 자동 검증 |
| 디버깅 | console.log로 추적 | Zod 에러 메시지로 바로 파악 |
### 5.2 화면 개발자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 화면 생성 | 모든 설정 직접 지정 | 필요한 것만 오버라이드 |
| 설정 실수 | 런타임 에러 | 저장 시 Zod 검증 에러 |
| 기본값 확인 | 코드 뒤져보기 | defaults.ts 확인 |
### 5.3 운영자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 일괄 설정 변경 | 100개 JSON 수정 | defaults.ts 수정 → 전체 적용 |
| 회사별 기본값 | 불가능 | 회사별 defaults 테이블 추가 가능 |
| 오류 추적 | 어려움 | Zod 검증 로그 확인 |
---
## 6. 데이터 마이그레이션 계획
### 6.1 차이점 추출 스크립트
```typescript
// 기존 JSON에서 기본값과 다른 것만 추출
async function extractDiff(componentUrl: string, fullConfig: any): Promise<any> {
const { defaultConfig } = getComponentModule(componentUrl);
function getDiff(defaults: any, current: any): any {
const diff: any = {};
for (const key of Object.keys(current)) {
if (defaults[key] === undefined) {
// 기본값에 없는 키 = 그대로 유지
diff[key] = current[key];
} else if (typeof current[key] === 'object' && !Array.isArray(current[key])) {
// 중첩 객체 = 재귀 비교
const nestedDiff = getDiff(defaults[key], current[key]);
if (Object.keys(nestedDiff).length > 0) {
diff[key] = nestedDiff;
}
} else if (JSON.stringify(defaults[key]) !== JSON.stringify(current[key])) {
// 값이 다름 = 저장
diff[key] = current[key];
}
// 값이 같음 = 저장 안 함 (기본값 사용)
}
return diff;
}
return getDiff(defaultConfig, fullConfig);
}
```
### 6.2 마이그레이션 순서
```
1. 컴포넌트별 schema.ts, defaults.ts 작성
2. 기존 데이터 분석 (어떤 설정이 자주 사용되는지)
3. 가장 많이 사용되는 값을 기본값으로 설정
4. 차이점 추출 스크립트 실행
5. 새 구조로 데이터 업데이트
6. 테스트
```
---
## 7. 예상 공수
| 단계 | 작업 | 예상 공수 |
|-----|-----|---------|
| **Phase 1** | 아키텍처 설계 + 유틸리티 함수 | 1주 |
| **Phase 2** | 핵심 컴포넌트 5개 스키마 작성 | 1주 |
| **Phase 3** | 데이터 마이그레이션 스크립트 | 1주 |
| **Phase 4** | 테스트 + 버그 수정 | 1주 |
| **Phase 5** | 나머지 컴포넌트 순차 적용 | 2-3주 |
| **총계** | | **6-7주** |
---
## 8. 위험 요소 및 대응
### 8.1 위험 요소
| 위험 | 영향 | 대응 |
|-----|-----|-----|
| 기존 데이터 손실 | 높음 | 마이그레이션 전 백업 필수 |
| 스키마 설계 실수 | 중간 | 충분한 리뷰 + 테스트 |
| 런타임 성능 저하 | 낮음 | Zod는 충분히 빠름 |
| 개발자 학습 비용 | 낮음 | Zod는 직관적, 이미 사용 중 |
### 8.2 롤백 계획
```
문제 발생 시:
1. 기존 JSON 구조로 데이터 복원 (백업에서)
2. 새 로직 비활성화 (feature flag)
3. 원인 분석 후 재시도
```
---
## 9. 결론
### 9.1 방안 1 + Zod 조합의 평가
| 항목 | 점수 | 이유 |
|-----|-----|-----|
| **현재 시스템 적응도** | ★★★★☆ | Zod 이미 사용 중, 점진적 적용 가능 |
| **향후 확장성** | ★★★★★ | 새 설정 추가 용이, 타입 안정성 |
| **유지보수성** | ★★★★★ | 코드 수정 → 전 회사 적용, 명확한 에러 |
| **마이그레이션 공수** | ★★★☆☆ | 6-7주 소요, 점진적 적용으로 리스크 분산 |
| **안정성** | ★★★★☆ | Zod 검증으로 런타임 에러 방지 |
### 9.2 최종 권장
```
✅ 방안 1 (URL 참조 + Zod 스키마) 적용 권장
이유:
1. 컴포넌트 수정 → 코드만 변경 → 전 회사 자동 적용
2. Zod로 JSON 구조 일관성 보장
3. 타입 안정성 + 런타임 검증
4. 기존 시스템과 호환 (Zod 이미 사용 중)
5. 점진적 마이그레이션 가능
```
### 9.3 다음 단계
1. 핵심 컴포넌트 1개로 PoC (Proof of Concept)
2. 팀 리뷰 및 피드백
3. 표준 패턴 확정
4. 순차적 적용

View File

@ -1,278 +0,0 @@
# DB 정리 작업 로그 (2026-01-20)
## 작업 개요
- **작업일**: 2026-01-20
- **작업자**: AI Assistant (Claude)
- **대상 DB**: postgresql://39.117.244.52:11132/plm
- **백업 파일**: `/db/plm_full_backup_20260120_182421.dump` (5.3MB)
---
## 작업 결과 요약
| 구분 | 정리 전 | 정리 후 | 변동 |
|------|---------|---------|------|
| 테이블 수 | 336개 | 206개 | -130개 |
| table_type_columns | 3,307개 | 3,307개 | 0 (복원됨) |
| **FK 제약조건** | **119개** | **0개** | **-119개** |
---
## 삭제된 테이블 목록 (130개)
### 1. 백업/날짜 패턴 테이블 (6개)
```
item_info_20251202
item_info_20251202_log
order_table_20251201
purchase_order_master_241216
q20251001
sales_bom_report_part_241218
```
### 2. 테스트 테이블 (3개)
```
copy_table
my_custom_table
writer_test_table
```
### 3. PMS 레거시 (14개)
```
pms_invest_cost_mng
pms_pjt_concept_info
pms_pjt_info
pms_pjt_year_goal
pms_rel_pjt_concept_milestone
pms_rel_pjt_concept_prod
pms_rel_pjt_prod
pms_rel_prod_ref_dept
pms_wbs_task
pms_wbs_task_confirm
pms_wbs_task_info
pms_wbs_task_standard
pms_wbs_task_standard2
pms_wbs_template
```
### 4. profit_loss 관련 (12개)
```
profit_loss
profit_loss_coefficient
profit_loss_coolingtime
profit_loss_depth
profit_loss_lossrate
profit_loss_machine
profit_loss_pretime
profit_loss_srrate
profit_loss_total
profit_loss_total_addlist
profit_loss_total_addlist2
profit_loss_weight
```
### 5. OEM 관련 (3개)
```
oem_factory_mng
oem_milestone_mng
oem_mng
```
### 6. 기타 레거시 (4개)
```
chartmgmt
counselingmgmt
inboxtask
klbom_tbl
nswos100_tbl (table_type_columns에 등록되어 있었으나 2개 컬럼뿐이라 유지 안함)
```
### 7. 미사용 비즈니스 테이블 (약 90개)
계약/견적, 고객/서비스, 자재/제품, 주문/발주, 생산/BOM, 출하/배송, 영업, 공급업체 관련 테이블들
---
## 복원된 테이블 (7개)
`table_type_columns`에 등록되어 있어서 복원한 테이블:
| 테이블 | 컬럼 정의 수 | 데이터 |
|--------|-------------|--------|
| purchase_order_master | 112개 | 0건 |
| production_record | 24개 | 0건 |
| dtg_maintenance_history | 30개 | 0건 |
| inspection_equipment_mng | 12개 | 0건 |
| shipment_instruction | 21개 | 0건 |
| work_order | 24개 | 0건 |
| work_orders | 42개 | 0건 |
---
## FK 제약조건 전체 제거 (119개)
### 제거 이유
1. **로우코드 플랫폼 특성**: 동적으로 테이블/관계 생성되므로 DB FK가 방해됨
2. **앱 레벨 관계 관리**: `cascading_relation`, `screen_field_joins`에서 관리
3. **코드에서 JOIN 처리**: SQL JOIN으로 직접 처리
4. **삭제 유연성**: MES 공정 등에서 FK로 인한 삭제 불가 문제 해결
### 제거된 FK 유형
- `→ company_mng.company_code`: 약 30개 (멀티테넌시용)
- `flow_*` 관련: 약 15개
- `screen_*` 관련: 약 15개
- `batch_*`, `cascading_*`, `dashboard_*` 등 시스템용: 약 60개
### 주의사항
- 앱 레벨에서 참조 무결성 체크 필요
- 고아 데이터 관리 로직 필요
- `cascading_relation` 활용 권장
---
## 중요 유의사항
### 1. table_type_columns 관련
- **절대 함부로 정리하지 말 것!**
- 이 테이블은 **로우코드 플랫폼의 가상 테이블 정의**를 저장
- 실제 DB 테이블과 **무관한 독립적인 메타데이터**
- `/admin/systemMng/tableMngList` 페이지에서 관리하는 데이터
- 잘못 삭제 후 덤프에서 복원함 (3,307개 레코드)
### 2. 삭제 전 체크리스트
테이블 삭제 전 반드시 확인할 것:
1. **table_type_columns에 등록 여부** - 등록되어 있으면 삭제 금지
2. **screen_definitions에서 사용 여부** - 화면에서 사용 중이면 삭제 금지
3. **백엔드 코드 사용 여부** - Grep 검색으로 확인
4. **프론트엔드 코드 사용 여부** - Grep 검색으로 확인
5. **wace 작성자 데이터 여부** - 신규 시스템에서 생성된 데이터인지 확인
6. **덕일 DB 비교** - 덕일에 있으면 레거시 가능성 높음
### 3. 덕일 DB 정보
- 구시스템 (Java 기반)
- 연결 정보: `jdbc:postgresql://59.13.244.189:5432/duckil`
- 322개 테이블 보유
- 현재 DB와 교집합: 17개 테이블 (핵심 시스템 테이블)
### 4. 복원 방법
```bash
# 전체 복원
docker run --rm --network host -v /Users/gbpark/ERP-node/db:/backup postgres:16 \
pg_restore --clean --if-exists --no-owner --no-privileges \
-d "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
/backup/plm_full_backup_20260120_182421.dump
# 특정 테이블만 복원
docker run --rm --network host -v /Users/gbpark/ERP-node/db:/backup postgres:16 \
pg_restore -t "테이블명" --no-owner --no-privileges \
-d "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
/backup/plm_full_backup_20260120_182421.dump
```
---
## 현재 DB 현황
### 테이블 분류
- **총 테이블**: 206개
- **table_type_columns 등록**: 98개
- **화면에서 사용**: 약 70개
- **wace 데이터 있음**: 75개
### 추가 검토 필요 테이블
다음 테이블들은 데이터가 있지만 코드/화면에서 미사용:
- `sales_bom_part_qty` (404건) - 2022년 데이터
- `sales_bom_report` (1,116건)
- `sales_long_delivery_input` (1,588건)
- `sales_part_chg` (248건)
- `sales_request_part` (25건)
→ 삭제 전 업무 담당자 확인 필요
---
## 변경 이력
| 시간 | 작업 | 비고 |
|------|------|------|
| 18:21 | 스키마 덤프 생성 | plm_schema_20260120.sql |
| 18:24 | 전체 덤프 생성 | plm_full_backup_20260120_182421.dump |
| 18:25 | 1차 삭제 (115개) | 백업/테스트/레거시 테이블 |
| 18:26 | table_type_columns 정리 | 686개 레코드 삭제 (잘못된 작업) |
| 18:35 | 2차 삭제 (21개) | 미사용 비즈니스 테이블 |
| 18:36 | table_type_columns 추가 정리 | 153개 레코드 삭제 (잘못된 작업) |
| 18:50 | table_type_columns 복원 | 3,307개 레코드 복원 |
| 19:05 | 7개 테이블 복원 | table_type_columns에 등록된 테이블 복원 |
| 19:45 | **FK 전체 제거** | 119개 Foreign Key 제약조건 삭제 |
| 20:15 | **미사용 배치 테이블 삭제** | batch_jobs(5건), batch_schedules, batch_job_executions, batch_job_parameters |
| 20:25 | **중복 external_db 테이블 정리** | external_db_connection(단수형) 삭제 + flowExecutionService.ts 코드 수정 |
| 20:35 | **레거시 comm 테이블 삭제** | comm_code(752건), comm_code_history(1720건), comm_exchange_rate(4건) + referenceCacheService.ts 정리 |
| 20:50 | **미사용 0건 테이블 삭제** | defect_standard_mng_log, file_down_log, inspection_equipment_mng_log, sales_order_detail_log, work_instruction_log, work_instruction_detail_log, dashboard_shares, dashboard_slider_items, dashboard_sliders, category_column_mapping_test (10개) |
| 21:00 | **미사용 테이블 추가 삭제** | dataflow_external_calls, external_call_logs, mail_log (3개) |
| 21:10 | **미구현 기능 테이블 삭제** | flow_external_connection_permission |
| 21:20 | **미사용 테이블 삭제** | category_values_test(11건), ratecal_mgmt(2건) |
| 21:40 | **레거시 테이블 삭제 (13개)** | sales_*, drivers, dtg_*, time_sheet 등 (총 3,612건) |
| 22:00 | **미사용 0건 테이블 삭제 (6개)** | cascading_reverse_lookup, cascading_multi_parent*, category_values_test, screen_widgets, screen_group_members |
| 22:15 | **미사용 0건 테이블 삭제 (2개)** | collection_batch_executions, collection_batch_management |
| 22:30 | **레거시 테이블 삭제 (1개)** | customer_service_workingtime (5건, 2023년 데이터) |
---
## 삭제된 레거시 테이블 (2026-01-22 추가)
코드 미사용 + TTC/SD 미등록 + 레거시 데이터(wace 아님) 13개:
| 테이블 | 데이터 | 작성자 |
|--------|--------|--------|
| sales_long_delivery_input | 1,588건 | 레거시 |
| sales_bom_report | 1,116건 | plm_admin 등 |
| sales_bom_part_qty | 404건 | 레거시 |
| sales_part_chg | 248건 | hosang.park 등 |
| time_sheet | 155건 | 레거시 |
| sales_request_part | 25건 | plm_admin 등 |
| supply_mng | 24건 | 레거시 |
| work_request | 12건 | 레거시 |
| dtg_monthly_settlements | 10건 | admin |
| used_mng | 10건 | plm_admin |
| drivers | 9건 | 레거시 |
| input_resource | 8건 | plm_admin |
| dtg_contracts | 3건 | admin |
---
## 작업자 메모
1. `table_type_columns`는 로우코드 플랫폼의 핵심 메타데이터 테이블
2. 실제 DB 테이블 삭제와 `table_type_columns` 레코드는 별개로 관리해야 함
3. 앞으로 DB 정리 시 `table_type_columns` 등록 여부를 **가장 먼저** 확인할 것
4. 덤프 파일은 최소 1개월간 보관 권장
5. pg_stat_user_tables의 n_live_tup 값은 부정확할 수 있음 - 실제 COUNT(*) 확인 필수
### production_task (2026-01-22 22:50)
- **데이터**: 336건 (2021년 3월~5월)
- **작성자**: esshin, plm_admin (레거시)
- **TTC/SD**: 미등록/미사용
- **코드 사용**: 없음 (문서만)
- **삭제 사유**: 5년 전 레거시 데이터
---
## 2026-01-22 최종 정리 완료
### 미사용 테이블 분석 결과
- **0건 + TTC/SD 미등록 테이블**: 18개 → **전부 코드에서 사용 중** (삭제 불가)
- **현재 총 테이블**: 164개
- **추가 삭제 대상**: 없음
### 생성된 문서
- `DB_STRUCTURE_DIAGRAM.md`: 전체 DB 구조 및 ER 다이어그램
- 핵심 테이블 관계도 6개 섹션
- 코드 기반 JOIN 분석 완료
- Mermaid 다이어그램 포함
### 정리 완료 요약
| 항목 | 수치 |
|------|------|
| 삭제된 테이블 | 약 50개+ |
| 남은 테이블 | 164개 |
| 활성 테이블 비율 | 100% |

View File

@ -1,681 +0,0 @@
# DB 비효율성 분석 보고서
> 분석일: 2026-01-20 | 분석 기준: 코드 사용 빈도 + DB 설계 원칙 + 유지보수성
---
## 전체 요약
```mermaid
pie title 비효율성 분류
"🔴 즉시 개선" : 2
"🟡 검토 후 개선" : 2
"🟢 선택적 개선" : 2
```
| 심각도 | 개수 | 항목 |
|--------|------|------|
| 🔴 즉시 개선 | 2 | layout_metadata 미사용, user_dept 비정규화 |
| 🟡 검토 후 개선 | 2 | 히스토리 테이블 39개, cascading 미사용 3개 |
| 🟢 선택적 개선 | 2 | dept_info 중복, screen 테이블 통합 |
---
## 🔴 1. screen_definitions.layout_metadata (미사용 컬럼)
### 현재 구조
```mermaid
erDiagram
screen_definitions {
uuid screen_id PK
varchar screen_name
varchar table_name
jsonb layout_metadata "❌ 미사용"
}
screen_layouts {
int layout_id PK
uuid screen_id FK
jsonb properties "✅ 실제 사용"
jsonb layout_config "✅ 실제 사용"
jsonb zones_config "✅ 실제 사용"
}
screen_definitions ||--o{ screen_layouts : "screen_id"
```
### 문제점
| 항목 | 상세 |
|------|------|
| **중복 저장** | `screen_definitions.layout_metadata``screen_layouts.properties`가 유사 데이터 |
| **코드 증거** | `screenManagementService.ts:534` - "기존 layout_metadata도 확인 (하위 호환성) - **현재는 사용하지 않음**" |
| **사용 빈도** | 전체 코드에서 6회만 참조 (대부분 복사/마이그레이션용) |
| **저장 낭비** | JSONB 컬럼이 NULL 또는 빈 객체로 유지 |
### 코드 증거
```typescript
// screenManagementService.ts:534-535
// 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음
// 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함
```
### 영향도 분석
```mermaid
flowchart LR
A[layout_metadata 삭제] --> B{영향 범위}
B --> C[menuCopyService.ts]
B --> D[screenManagementService.ts]
C --> E[복사 시 해당 필드 제외]
D --> F[조회 시 해당 필드 제외]
E --> G[✅ 정상 동작]
F --> G
```
### 개선 방안
```sql
-- Step 1: 데이터 확인 (실행 전)
SELECT screen_id, screen_name,
CASE WHEN layout_metadata IS NULL THEN 'NULL'
WHEN layout_metadata = '{}' THEN 'EMPTY'
ELSE 'HAS_DATA' END as status
FROM screen_definitions
WHERE layout_metadata IS NOT NULL AND layout_metadata != '{}';
-- Step 2: 컬럼 삭제
ALTER TABLE screen_definitions DROP COLUMN layout_metadata;
```
### 예상 효과
- ✅ 스키마 단순화
- ✅ 데이터 정합성 혼란 제거
- ✅ 저장 공간 절약 (JSONB 오버헤드 제거)
---
## 🔴 2. user_dept 비정규화 (중복 저장)
### 현재 구조 (비효율)
```mermaid
erDiagram
user_info {
varchar user_id PK
varchar user_name "원본"
varchar dept_code
}
dept_info {
varchar dept_code PK
varchar dept_name "원본"
varchar company_code
}
user_dept {
varchar user_id FK
varchar dept_code FK
varchar dept_name "❌ 중복 (dept_info에서 JOIN)"
varchar user_name "❌ 중복 (user_info에서 JOIN)"
varchar position_name "❓ 별도 테이블 필요?"
boolean is_primary
}
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
```
### 문제점
| 항목 | 상세 |
|------|------|
| **데이터 불일치 위험** | 부서명 변경 시 `dept_info`만 수정하면 `user_dept.dept_name`은 구 데이터 유지 |
| **수정 비용** | 부서명 변경 시 모든 `user_dept` 레코드 UPDATE 필요 |
| **저장 낭비** | 동일 부서의 모든 사용자에게 부서명 반복 저장 |
| **사용 빈도** | 코드에서 `user_dept.dept_name` 직접 조회는 2회뿐 |
### 비정규화로 인한 데이터 불일치 시나리오
```mermaid
sequenceDiagram
participant Admin as 관리자
participant DI as dept_info
participant UD as user_dept
Admin->>DI: UPDATE dept_name = '개발2팀'<br/>WHERE dept_code = 'DEV'
Note over DI: dept_name = '개발2팀' ✅
Note over UD: dept_name = '개발1팀' ❌ 구 데이터
Admin->>UD: ⚠️ 수동으로 모든 레코드 UPDATE 필요
Note over UD: dept_name = '개발2팀' ✅
```
### 권장 구조 (정규화)
```mermaid
erDiagram
user_info {
varchar user_id PK
varchar user_name
varchar position_name "직위 (여기서 관리)"
}
dept_info {
varchar dept_code PK
varchar dept_name
}
user_dept {
varchar user_id FK
varchar dept_code FK
boolean is_primary
}
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
```
> **참고**: `position_info` 마스터 테이블은 현재 없음. `user_info.position_name`에 직접 저장 중.
> 직위 표준화 필요 시 별도 마스터 테이블 생성 검토.
### 개선 방안
```sql
-- Step 1: 중복 컬럼 삭제 준비 (조회 쿼리 수정 선행)
-- 기존: SELECT ud.dept_name FROM user_dept ud
-- 변경: SELECT di.dept_name FROM user_dept ud JOIN dept_info di ON ud.dept_code = di.dept_code
-- Step 2: 중복 컬럼 삭제
ALTER TABLE user_dept DROP COLUMN dept_name;
ALTER TABLE user_dept DROP COLUMN user_name;
-- position_name은 user_info에서 조회하도록 변경
ALTER TABLE user_dept DROP COLUMN position_name;
```
### 예상 효과
- ✅ 데이터 정합성 보장 (Single Source of Truth)
- ✅ 수정 비용 감소 (한 곳만 수정)
- ✅ 저장 공간 절약
---
## 🟡 3. 과도한 히스토리/로그 테이블 (39개)
### 현재 구조
```mermaid
graph TB
subgraph HISTORY["히스토리 테이블 (39개)"]
H1[authority_master_history]
H2[carrier_contract_mng_log]
H3[carrier_mng_log]
H4[carrier_vehicle_mng_log]
H5[comm_code_history]
H6[data_collection_history]
H7[ddl_execution_log]
H8[defect_standard_mng_log]
H9[delivery_history]
H10[...]
H11[user_info_history]
H12[vehicle_location_history]
H13[work_instruction_log]
end
subgraph PROBLEM["문제점"]
P1["스키마 변경 시<br/>모든 히스토리 테이블 수정"]
P2["테이블 수 폭증<br/>(원본 + 히스토리)"]
P3["관리 복잡도 증가"]
end
HISTORY --> PROBLEM
```
### 현재 테이블 목록 (39개)
| 카테고리 | 테이블명 | 용도 |
|----------|----------|------|
| 시스템 | authority_master_history | 권한 변경 이력 |
| 시스템 | user_info_history | 사용자 정보 이력 |
| 시스템 | dept_info_history | 부서 정보 이력 |
| 시스템 | login_access_log | 로그인 기록 |
| 시스템 | ddl_execution_log | DDL 실행 기록 |
| 물류 | carrier_mng_log | 운송사 변경 이력 |
| 물류 | carrier_contract_mng_log | 운송 계약 이력 |
| 물류 | carrier_vehicle_mng_log | 운송 차량 이력 |
| 물류 | delivery_history | 배송 이력 |
| 물류 | delivery_route_mng_log | 배송 경로 이력 |
| 물류 | logistics_cost_mng_log | 물류 비용 이력 |
| 물류 | vehicle_location_history | 차량 위치 이력 |
| 설비 | equipment_mng_log | 설비 변경 이력 |
| 설비 | equipment_consumable_log | 설비 소모품 이력 |
| 설비 | equipment_inspection_item_log | 설비 점검 이력 |
| 설비 | dtg_maintenance_history | DTG 유지보수 이력 |
| 설비 | dtg_management_log | DTG 관리 이력 |
| 생산 | defect_standard_mng_log | 불량 기준 이력 |
| 생산 | work_instruction_log | 작업 지시 이력 |
| 생산 | work_instruction_detail_log | 작업 지시 상세 이력 |
| 생산 | safety_inspections_log | 안전 점검 이력 |
| 영업 | supplier_mng_log | 공급사 이력 |
| 영업 | sales_order_detail_log | 판매 주문 이력 |
| 기타 | flow_audit_log | 플로우 감사 로그 ✅ 필요 |
| 기타 | flow_integration_log | 플로우 통합 로그 ✅ 필요 |
| 기타 | mail_log | 메일 발송 로그 ✅ 필요 |
| ... | ... | ... |
### 문제점 상세
```mermaid
flowchart TB
A[원본 테이블 컬럼 추가] --> B[히스토리 테이블도 수정 필요]
B --> C{수동 작업}
C -->|잊음| D[❌ 스키마 불일치]
C -->|수동 수정| E[⚠️ 추가 작업 비용]
F[테이블 39개 × 평균 15컬럼] --> G[약 585개 컬럼 관리]
```
### 권장 구조 (통합 감사 테이블)
```mermaid
erDiagram
audit_log {
bigint id PK
varchar table_name "원본 테이블명"
varchar record_id "레코드 식별자"
varchar action "INSERT|UPDATE|DELETE"
jsonb old_data "변경 전 전체 데이터"
jsonb new_data "변경 후 전체 데이터"
jsonb changed_fields "변경된 필드만"
varchar changed_by "변경자"
inet ip_address "IP 주소"
timestamp changed_at "변경 시각"
varchar company_code "회사 코드"
}
```
### 개선 방안
```sql
-- 통합 감사 테이블 생성
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
table_name varchar(100) NOT NULL,
record_id varchar(100) NOT NULL,
action varchar(10) NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
old_data jsonb,
new_data jsonb,
changed_fields jsonb, -- UPDATE 시 변경된 필드만
changed_by varchar(50),
ip_address inet,
changed_at timestamp DEFAULT now(),
company_code varchar(20)
);
-- 인덱스
CREATE INDEX idx_audit_log_table ON audit_log(table_name);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_time ON audit_log(changed_at);
CREATE INDEX idx_audit_log_company ON audit_log(company_code);
-- PostgreSQL 트리거 함수 (자동 감사)
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (table_name, record_id, action, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::text, 'INSERT', row_to_json(NEW)::jsonb,
current_setting('app.current_user', true), now());
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::text, 'UPDATE', row_to_json(OLD)::jsonb,
row_to_json(NEW)::jsonb, current_setting('app.current_user', true), now());
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (table_name, record_id, action, old_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, OLD.id::text, 'DELETE', row_to_json(OLD)::jsonb,
current_setting('app.current_user', true), now());
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
```
### 예상 효과
- ✅ 테이블 수 39개 → 1개로 감소
- ✅ 스키마 변경 시 히스토리 수정 불필요 (JSONB 저장)
- ✅ 통합 조회/분석 용이
- ⚠️ 주의: 기존 히스토리 데이터 마이그레이션 필요
---
## 🟡 4. Cascading 미사용 테이블 (3개)
### 현재 구조
```mermaid
graph TB
subgraph USED["✅ 사용 중 (9개)"]
U1[cascading_hierarchy_group]
U2[cascading_hierarchy_level]
U3[cascading_auto_fill_group]
U4[cascading_auto_fill_mapping]
U5[cascading_relation]
U6[cascading_condition]
U7[cascading_mutual_exclusion]
U8[category_value_cascading_group]
U9[category_value_cascading_mapping]
end
subgraph UNUSED["❌ 미사용 (3개)"]
X1[cascading_multi_parent]
X2[cascading_multi_parent_source]
X3[cascading_reverse_lookup]
end
UNUSED --> DELETE[삭제 검토]
```
### 코드 사용 분석
| 테이블 | 코드 참조 | 판정 |
|--------|----------|------|
| `cascading_hierarchy_group` | 다수 | ✅ 유지 |
| `cascading_hierarchy_level` | 다수 | ✅ 유지 |
| `cascading_auto_fill_group` | 다수 | ✅ 유지 |
| `cascading_auto_fill_mapping` | 다수 | ✅ 유지 |
| `cascading_relation` | 다수 | ✅ 유지 |
| `cascading_condition` | 7회 | ⚠️ 검토 |
| `cascading_mutual_exclusion` | 소수 | ⚠️ 검토 |
| `cascading_multi_parent` | **0회** | ❌ 삭제 |
| `cascading_multi_parent_source` | **0회** | ❌ 삭제 |
| `cascading_reverse_lookup` | **0회** | ❌ 삭제 |
| `category_value_cascading_group` | 다수 | ✅ 유지 |
| `category_value_cascading_mapping` | 다수 | ✅ 유지 |
### 개선 방안
```sql
-- Step 1: 데이터 확인
SELECT 'cascading_multi_parent' as tbl, count(*) FROM cascading_multi_parent
UNION ALL
SELECT 'cascading_multi_parent_source', count(*) FROM cascading_multi_parent_source
UNION ALL
SELECT 'cascading_reverse_lookup', count(*) FROM cascading_reverse_lookup;
-- Step 2: 데이터 없으면 삭제
DROP TABLE IF EXISTS cascading_multi_parent_source; -- 자식 먼저
DROP TABLE IF EXISTS cascading_multi_parent;
DROP TABLE IF EXISTS cascading_reverse_lookup;
```
---
## 🟢 5. dept_info.company_name 중복
### 현재 구조
```mermaid
erDiagram
company_mng {
varchar company_code PK
varchar company_name "원본"
}
dept_info {
varchar dept_code PK
varchar company_code FK
varchar company_name "❌ 중복"
varchar dept_name
}
company_mng ||--o{ dept_info : "company_code"
```
### 문제점
- `dept_info.company_name``company_mng.company_name`과 동일한 값
- 회사명 변경 시 두 테이블 모두 수정 필요
### 개선 방안
```sql
-- 중복 컬럼 삭제
ALTER TABLE dept_info DROP COLUMN company_name;
-- 조회 시 JOIN 사용
SELECT di.*, cm.company_name
FROM dept_info di
JOIN company_mng cm ON di.company_code = cm.company_code;
```
---
## 🟢 6. screen 관련 테이블 통합 가능성
### 현재 구조
```mermaid
erDiagram
screen_data_flows {
int id PK
uuid source_screen_id
uuid target_screen_id
varchar flow_type
}
screen_table_relations {
int id PK
uuid screen_id
varchar table_name
varchar relation_type
}
screen_field_joins {
int id PK
uuid screen_id
varchar source_field
varchar target_field
}
```
### 분석
| 테이블 | 용도 | 사용 빈도 |
|--------|------|----------|
| `screen_data_flows` | 화면 간 데이터 흐름 | 15회 (screenGroupController) |
| `screen_table_relations` | 화면-테이블 관계 | 일부 |
| `screen_field_joins` | 필드 조인 설정 | 일부 |
### 통합 가능성
- 세 테이블 모두 "화면 간 관계" 정의
- 하나의 `screen_relations` 테이블로 통합 가능
- **단, 현재 사용 중이므로 신중한 검토 필요**
---
## 실행 계획
```mermaid
gantt
title DB 개선 실행 계획
dateFormat YYYY-MM-DD
section 즉시 실행
layout_metadata 컬럼 삭제 :a1, 2026-01-21, 1d
미사용 cascading 테이블 삭제 :a2, 2026-01-21, 1d
section 단기 (1주)
user_dept 정규화 :b1, 2026-01-22, 5d
dept_info.company_name 삭제 :b2, 2026-01-22, 2d
section 장기 (1개월)
히스토리 테이블 통합 설계 :c1, 2026-01-27, 7d
히스토리 마이그레이션 :c2, after c1, 14d
```
---
## 즉시 실행 가능 SQL 스크립트
```sql
-- ============================================
-- 🔴 즉시 개선 항목
-- ============================================
-- 1. screen_definitions.layout_metadata 삭제
BEGIN;
-- 백업 (선택)
-- CREATE TABLE screen_definitions_backup AS SELECT * FROM screen_definitions;
ALTER TABLE screen_definitions DROP COLUMN IF EXISTS layout_metadata;
COMMIT;
-- 2. 미사용 cascading 테이블 삭제
BEGIN;
DROP TABLE IF EXISTS cascading_multi_parent_source;
DROP TABLE IF EXISTS cascading_multi_parent;
DROP TABLE IF EXISTS cascading_reverse_lookup;
COMMIT;
-- 3. dept_info.company_name 삭제 (선택)
BEGIN;
ALTER TABLE dept_info DROP COLUMN IF EXISTS company_name;
COMMIT;
```
---
## 7. 채번-카테고리 시스템 (범용화 완료)
### 현황
| 테이블 | 건수 | menu_objid | 상태 |
|--------|------|------------|------|
| `numbering_rules_test` | 108건 | ❌ 없음 | ✅ 범용화 완료 |
| `numbering_rule_parts_test` | 267건 | ❌ 없음 | ✅ 범용화 완료 |
| `category_values_test` | 3건 | ❌ 없음 | ✅ 범용화 완료 |
| `category_column_mapping_test` | 0건 | ❌ 없음 | 미사용 |
### 연결관계도
```mermaid
erDiagram
numbering_rules_test {
varchar rule_id PK "규칙 ID"
varchar rule_name "규칙명"
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar category_column "카테고리 컬럼"
int category_value_id FK "카테고리 값 ID"
varchar separator "구분자"
varchar reset_period "리셋 주기"
int current_sequence "현재 시퀀스"
date last_generated_date "마지막 생성일"
varchar company_code "회사코드"
}
numbering_rule_parts_test {
serial id PK "파트 ID"
varchar rule_id FK "규칙 ID"
int part_order "순서 (1-6)"
varchar part_type "유형"
varchar generation_method "생성방식"
jsonb auto_config "자동설정"
jsonb manual_config "수동설정"
varchar company_code "회사코드"
}
category_values_test {
serial value_id PK "값 ID"
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar value_code "코드"
varchar value_label "라벨"
int value_order "정렬순서"
int parent_value_id FK "부모 (계층)"
int depth "깊이"
varchar path "경로"
varchar color "색상"
varchar icon "아이콘"
bool is_active "활성"
bool is_default "기본값"
varchar company_code "회사코드"
}
numbering_rules_test ||--o{ numbering_rule_parts_test : "1:N"
numbering_rules_test }o--o| category_values_test : "카테고리 조건"
category_values_test ||--o{ category_values_test : "계층구조"
```
### 데이터 흐름
```
┌──────────────────────────────────────────────────────────────────────┐
│ 범용 채번 시스템 (menu_objid 제거 완료) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ category_values │ │ numbering_rules_test │ │
│ │ _test (3건) │◄─────────────│ (108건) │ │
│ ├────────────────────┤ FK ├─────────────────────────┤ │
│ │ table + column │ 조인 │ table + column 기준 │ │
│ │ 기준 카테고리 값 │ │ category_value_id로 │ │
│ │ │ │ 카테고리별 규칙 구분 │ │
│ └────────────────────┘ └───────────┬─────────────┘ │
│ │ │
│ │ 1:N │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ numbering_rule_parts │ │
│ │ _test (267건) │ │
│ ├─────────────────────────┤ │
│ │ 파트별 설정 (최대 6개) │ │
│ │ - prefix, sequence │ │
│ │ - date, year, month │ │
│ │ - custom │ │
│ └─────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
```
### 조회 흐름
```mermaid
sequenceDiagram
participant UI as 사용자 화면
participant CV as category_values_test
participant NR as numbering_rules_test
participant NRP as numbering_rule_parts_test
UI->>CV: 1. 카테고리 값 조회<br/>(table_name + column_name)
CV-->>UI: 카테고리 목록 반환
UI->>NR: 2. 채번 규칙 조회<br/>(table + column + category_value_id)
NR-->>UI: 규칙 반환
UI->>NRP: 3. 채번 파트 조회<br/>(rule_id)
NRP-->>UI: 파트 목록 반환 (1-6개)
UI->>UI: 4. 파트 조합하여 채번 생성<br/>"PREFIX-2026-0001"
```
### 범용화 전/후 비교
| 항목 | 기존 (menu_objid 의존) | 현재 (범용화) |
|------|------------------------|---------------|
| **식별 기준** | menu_objid (메뉴별) | table_name + column_name |
| **공유 범위** | 메뉴 단위 | 테이블 단위 (여러 메뉴에서 공유) |
| **중복 규칙** | 같은 테이블도 메뉴마다 별도 | 하나의 규칙을 공유 |
| **유지보수** | 메뉴 변경 시 규칙도 수정 | 테이블 기준으로 독립 |
---
## 참고
- 분석 대상: `/Users/gbpark/ERP-node/backend-node/src/**/*.ts`
- 스키마 파일: `/Users/gbpark/ERP-node/db/plm_schema_20260120.sql`
- 관련 문서: `DB_STRUCTURE_DIAGRAM.md`, `DB_CLEANUP_LOG_20260120.md`

View File

@ -1,468 +0,0 @@
# Vexplor 구조 다이어그램
> 생성일: 2026-01-22 | 총 테이블: 164개 | 코드 기반 관계 분석 완료
---
## 1. 테이블 JOIN 관계도 (핵심)
### 1-1. 사용자/권한 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `user_info``user_dept``authority_sub_user` | 사용자 생성 → 부서 배정 → 권한 부여 |
| **R** | `user_info` + `company_mng` + `authority_sub_user` + `authority_master` JOIN | 로그인/조회 시 회사+권한 JOIN |
| **U** | `user_info` / `user_dept` / `authority_sub_user` 개별 | 각 테이블 독립 수정 |
| **D** | 각각 독립 삭제 (별도 API) | user_dept, authority_sub_user, user_info 각각 삭제 |
```mermaid
erDiagram
company_mng {
varchar company_code PK "회사코드"
varchar company_name "회사명"
}
user_info {
varchar user_id PK "사용자ID"
varchar company_code "회사코드 (멀티테넌시)"
varchar user_name "사용자명"
varchar user_type "SUPER_ADMIN | COMPANY_ADMIN | USER"
}
dept_info {
varchar dept_code PK "부서코드"
varchar company_code "회사코드"
varchar dept_name "부서명"
}
user_dept {
varchar user_id "사용자ID"
varchar dept_code "부서코드"
varchar company_code "회사코드"
}
authority_master {
int objid PK "권한그룹ID"
varchar company_code "회사코드"
varchar auth_group_name "권한그룹명"
}
authority_sub_user {
int master_objid "권한그룹ID"
varchar user_id "사용자ID"
varchar company_code "회사코드"
}
company_mng ||--o{ user_info : "company_code = company_code"
company_mng ||--o{ dept_info : "company_code = company_code"
user_info ||--o{ user_dept : "user_id = user_id"
dept_info ||--o{ user_dept : "dept_code = dept_code"
authority_master ||--o{ authority_sub_user : "objid = master_objid"
user_info ||--o{ authority_sub_user : "user_id = user_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 사용자 권한 조회 (authService.ts:158)
SELECT am.auth_group_name, am.objid
FROM authority_sub_user asu
INNER JOIN authority_master am ON asu.master_objid = am.objid
WHERE asu.user_id = $1
```
### 1-2. 메뉴/권한 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `menu_info``rel_menu_auth` | 메뉴 생성 → 권한그룹에 메뉴 할당 |
| **R** | `authority_master``rel_menu_auth``menu_info` | 사용자 권한으로 접근 가능 메뉴 필터링 |
| **U** | `menu_info` 단독 / `rel_menu_auth` 삭제 후 재생성 | 메뉴 수정 or 권한 재할당 |
| **D** | `rel_menu_auth``menu_info` | 권한 매핑 먼저 삭제 → 메뉴 삭제 |
```mermaid
erDiagram
menu_info {
int objid PK "메뉴ID"
varchar company_code "회사코드"
varchar menu_name_kor "메뉴명"
varchar menu_url "메뉴URL"
int parent_obj_id "상위메뉴ID"
}
rel_menu_auth {
int menu_objid "메뉴ID"
int auth_objid "권한그룹ID"
varchar company_code "회사코드"
}
authority_master {
int objid PK "권한그룹ID"
varchar company_code "회사코드"
}
menu_info ||--o{ rel_menu_auth : "objid = menu_objid"
authority_master ||--o{ rel_menu_auth : "objid = auth_objid"
```
**실제 코드 JOIN 예시:**
```sql
-- 사용자 메뉴 조회 (adminService.ts)
SELECT mi.*
FROM menu_info mi
JOIN rel_menu_auth rma ON mi.objid = rma.menu_objid
WHERE rma.auth_objid IN (사용자권한목록)
AND mi.company_code = $companyCode
```
### 1-3. 화면 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `screen_definitions``screen_layouts``screen_menu_assignments` | 화면 정의 → 레이아웃 → 메뉴 연결 |
| **R** | `menu_info``screen_menu_assignments``screen_definitions` + `screen_layouts` JOIN | 메뉴에서 화면+레이아웃 JOIN |
| **U** | `screen_definitions` / `screen_layouts` 개별 (같은 screen_id) | 정의와 레이아웃 각각 수정 |
| **D** | `screen_layouts``screen_menu_assignments``screen_definitions` | 레이아웃 → 메뉴연결 → 정의 순서 |
> **그룹**: `screen_groups``screen_group_screens`는 별도 API로 관리 (복사/그룹화 용도)
```mermaid
erDiagram
screen_definitions {
uuid screen_id PK "화면ID"
varchar company_code "회사코드"
varchar screen_name "화면명"
varchar table_name "연결테이블"
}
screen_layouts {
uuid screen_id PK "화면ID"
jsonb layout_metadata "레이아웃JSON"
}
screen_menu_assignments {
uuid screen_id "화면ID"
int menu_objid "메뉴ID"
varchar company_code "회사코드"
}
screen_groups {
int id PK "그룹ID"
varchar company_code "회사코드"
varchar group_name "그룹명"
}
screen_group_screens {
int group_id "그룹ID"
uuid screen_id "화면ID"
varchar company_code "회사코드"
}
screen_definitions ||--|| screen_layouts : "screen_id = screen_id"
screen_definitions ||--o{ screen_menu_assignments : "screen_id = screen_id"
menu_info ||--o{ screen_menu_assignments : "objid = menu_objid"
screen_groups ||--o{ screen_group_screens : "id = group_id"
screen_definitions ||--o{ screen_group_screens : "screen_id = screen_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 화면 정의 + 레이아웃 조회 (screenGroupController.ts:1272)
SELECT sd.*, sl.layout_metadata
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = $1
```
### 1-4. 테이블 타입/메타데이터 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | 각 테이블 독립 생성 | DDL 실행 시 자동 생성, 또는 개별 등록 |
| **R** | `table_type_columns` + `table_labels` + `table_relationships` LEFT JOIN | 화면 로딩 시 메타데이터 조합 |
| **U** | 각 테이블 개별 (table_name + column_name + company_code 기준) | 컬럼 정의/라벨/관계 각각 수정 |
| **D** | 각 테이블 독립 삭제 | 테이블 삭제 시 관련 메타데이터 개별 삭제 |
> **코드값 조회**: `table_column_category_values``code_category``code_info` (드롭다운 옵션)
```mermaid
erDiagram
table_type_columns {
varchar table_name PK "테이블명"
varchar column_name PK "컬럼명"
varchar company_code PK "회사코드"
varchar display_name "표시명"
varchar data_type "데이터타입"
varchar reference_table "참조테이블"
varchar reference_column "참조컬럼"
}
table_labels {
varchar table_name PK "테이블명"
varchar company_code PK "회사코드"
varchar display_name "테이블표시명"
}
table_column_category_values {
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar category_code "카테고리코드"
varchar company_code "회사코드"
}
table_relationships {
varchar table_name "테이블명"
varchar source_column "소스컬럼"
varchar target_table "타겟테이블"
varchar target_column "타겟컬럼"
varchar company_code "회사코드"
}
code_category {
varchar category_code PK "카테고리코드"
varchar company_code PK "회사코드"
varchar category_name "카테고리명"
}
code_info {
varchar category_code "카테고리코드"
varchar code_value PK "코드값"
varchar company_code PK "회사코드"
varchar code_name "코드명"
}
table_type_columns ||--o{ table_labels : "table_name = table_name"
table_type_columns ||--o{ table_column_category_values : "table_name, column_name"
table_type_columns ||--o{ table_relationships : "table_name = table_name"
code_category ||--o{ code_info : "category_code = category_code"
table_column_category_values }o--|| code_category : "category_code = category_code"
```
**실제 코드 JOIN 예시:**
```sql
-- 테이블 컬럼 정보 조회 (tableManagementService.ts:210)
SELECT ttc.*, cl.display_name as column_label
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
WHERE ttc.table_name = $1
AND ttc.company_code = $2
```
### 1-5. 플로우 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `flow_definition``flow_step``flow_step_connection``flow_data_mapping` | 플로우 → 스텝 → 연결선 → 매핑 |
| **R** | `flow_definition` + `flow_step` + `flow_step_connection` JOIN | 플로우 화면 렌더링 |
| **U** | 각 테이블 개별 (definition_id/step_id 기준) | 정의/스텝/연결 각각 수정 |
| **D** | 각 테이블 독립 삭제 (DB CASCADE 의존) | step/connection/definition 각각 삭제 API |
> **데이터 이동**: `flow_data_mapping`(컬럼 변환) → 소스→타겟 INSERT → `flow_audit_log`(자동 기록)
```mermaid
erDiagram
flow_definition {
int id PK "플로우ID"
varchar company_code "회사코드"
varchar name "플로우명"
}
flow_step {
int id PK "스텝ID"
int definition_id "플로우ID"
varchar company_code "회사코드"
varchar step_name "스텝명"
varchar table_name "연결테이블"
int step_order "순서"
}
flow_step_connection {
int id PK "연결ID"
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
int definition_id "플로우ID"
}
flow_data_mapping {
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
varchar source_column "소스컬럼"
varchar target_column "타겟컬럼"
}
flow_audit_log {
int id PK "로그ID"
int definition_id "플로우ID"
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
int data_id "데이터ID"
timestamp moved_at "이동시간"
}
flow_definition ||--o{ flow_step : "id = definition_id"
flow_step ||--o{ flow_step_connection : "id = from_step_id"
flow_step ||--o{ flow_step_connection : "id = to_step_id"
flow_step ||--o{ flow_data_mapping : "id = from_step_id"
flow_step ||--o{ flow_audit_log : "id = from_step_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 플로우 감사로그 조회 (flowDataMoveService.ts:461)
SELECT fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.definition_id = $1
```
### 1-6. 배치/수집 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `external_db_connections``batch_configs``batch_mappings` | 외부DB 연결 → 배치 설정 → 매핑 규칙 |
| **R** | `batch_configs` + `external_db_connections` + `batch_mappings` JOIN | 배치 실행 시 전체 설정 조회 |
| **U** | `batch_mappings` 삭제 후 재생성 / `batch_configs` 개별 수정 | 매핑은 전체 교체 방식 |
| **D** | `batch_configs` 삭제 시 `batch_mappings` CASCADE 삭제 | 설정만 삭제하면 매핑 자동 삭제 |
> **실행 시**: 크론 → 외부DB 조회 → 내부 테이블 동기화 → `batch_execution_logs`(결과 기록)
```mermaid
erDiagram
external_db_connections {
int id PK "연결ID"
varchar company_code "회사코드"
varchar connection_name "연결명"
varchar db_type "postgresql|mysql|mssql"
varchar host "호스트"
int port "포트"
}
batch_configs {
int id PK "배치ID"
varchar company_code "회사코드"
varchar batch_name "배치명"
varchar cron_expression "크론식"
int connection_id "연결ID"
varchar is_active "Y|N"
}
batch_mappings {
int id PK "매핑ID"
int batch_config_id "배치ID"
varchar source_table "소스테이블"
varchar source_column "소스컬럼"
varchar target_table "타겟테이블"
varchar target_column "타겟컬럼"
}
batch_execution_logs {
int id PK "로그ID"
int batch_config_id "배치ID"
timestamp started_at "시작시간"
timestamp finished_at "종료시간"
varchar status "SUCCESS|FAILED"
}
external_db_connections ||--o{ batch_configs : "id = connection_id"
batch_configs ||--o{ batch_mappings : "id = batch_config_id"
batch_configs ||--o{ batch_execution_logs : "id = batch_config_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 배치 설정 + 매핑 조회 (batchService.ts:143)
SELECT bc.*, bm.*
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.id = $1
AND bc.company_code = $2
ORDER BY bm.mapping_order
```
---
## 2. 로직 플로우 요약
> 위 JOIN 관계가 **언제** 사용되는지 간략 설명
### 2-1. 로그인 → 화면 접근 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `user_info` | - | user_id, password 확인 |
| 2 | `user_info` | - | company_code 조회 → 멀티테넌시 분기 |
| 3 | `company_mng` | user_info.company_code = company_mng.company_code | 회사명 조회 |
| 4 | `authority_sub_user``authority_master` | asu.master_objid = am.objid | 사용자 권한 조회 |
| 5 | `menu_info``rel_menu_auth` | mi.objid = rma.menu_objid | 권한별 메뉴 필터 |
| 6 | `screen_menu_assignments``screen_definitions` | sma.screen_id = sd.screen_id | 메뉴-화면 연결 |
| 7 | `screen_definitions``screen_layouts` | sd.screen_id = sl.screen_id | 화면+레이아웃 |
| 8 | `table_type_columns` | WHERE table_name = $1 | 컬럼 메타데이터 |
### 2-2. 데이터 조회 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `table_type_columns` | - | 컬럼 정의 조회 |
| 2 | `table_labels` | ttc.table_name = tl.table_name | 테이블 표시명 |
| 3 | `table_column_category_values` | ttc.table_name, column_name | 카테고리 값 |
| 4 | `table_relationships` | ttc.table_name = tr.table_name | 참조 관계 |
| 5 | `code_category``code_info` | cc.category_code = ci.category_code | 코드값 조회 |
| 6 | 비즈니스 테이블 | LEFT JOIN (table_relationships 기반) | 실제 데이터 |
### 2-3. 플로우 데이터 이동 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `flow_definition` | - | 플로우 정의 |
| 2 | `flow_step` | fs.definition_id = fd.id | 스텝 목록 |
| 3 | `flow_step_connection` | fsc.from_step_id = fs.id | 연결 관계 |
| 4 | `flow_data_mapping` | fdm.from_step_id, to_step_id | 컬럼 매핑 |
| 5 | 소스 테이블 | - | 데이터 조회 |
| 6 | 타겟 테이블 | - | 데이터 INSERT |
| 7 | `flow_audit_log` | - | 이동 기록 |
### 2-4. 배치 실행 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `batch_configs` | - | 활성 배치 조회 |
| 2 | `external_db_connections` | bc.connection_id = edc.id | 외부 DB 정보 |
| 3 | `batch_mappings` | bm.batch_config_id = bc.id | 매핑 규칙 |
| 4 | 외부 DB | - | 데이터 조회 |
| 5 | 내부 테이블 | - | 데이터 동기화 |
| 6 | `batch_execution_logs` | bel.batch_config_id = bc.id | 실행 로그 |
---
## 3. 멀티테넌시 (company_code) 적용 요약
| 테이블 | company_code 필터 | 비고 |
|--------|------------------|------|
| `user_info` | O | 사용자별 회사 구분 |
| `menu_info` | O | 회사별 메뉴 |
| `screen_definitions` | O | 회사별 화면 |
| `table_type_columns` | O | 회사별 컬럼 정의 |
| `flow_definition` | O | 회사별 플로우 |
| `batch_configs` | O | 회사별 배치 |
| 모든 비즈니스 테이블 | O | 자동 필터 적용 |
| `company_mng` | X (PK) | 회사 마스터 |
**company_code = '*'**: 최고관리자, 모든 회사 데이터 접근 가능
---
## 4. 비효율성 분석
> 상세 내용: [DB_INEFFICIENCY_ANALYSIS.md](./DB_INEFFICIENCY_ANALYSIS.md)
| 심각도 | 항목 | 권장 조치 |
|--------|------|-----------|
| 🔴 | `screen_definitions.layout_metadata` | 미사용 컬럼 삭제 |
| 🔴 | `user_dept` 비정규화 | 정규화 리팩토링 |
| 🟡 | 히스토리 테이블 39개 | 통합 감사 테이블 |
| 🟡 | cascading 미사용 3개 | 테이블 삭제 |
| 🟢 | `dept_info.company_name` | 선택적 정규화 |

View File

@ -1,670 +0,0 @@
# 반응형 그리드 시스템 아키텍처
> 최종 업데이트: 2026-01-30
---
## 1. 개요
### 1.1 현재 문제
**컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원**
```json
// 현재 DB 저장 방식 (screen_layouts_v2.layout_data)
{
"position": { "x": 1753, "y": 88 },
"size": { "width": 158, "height": 40 }
}
```
| 화면 크기 | 결과 |
|-----------|------|
| 1920px (디자인 기준) | 정상 |
| 1280px (노트북) | 오른쪽 버튼 잘림 |
| 768px (태블릿) | 레이아웃 완전히 깨짐 |
| 375px (모바일) | 사용 불가 |
### 1.2 목표
| 목표 | 설명 |
|------|------|
| PC 대응 | 1280px ~ 1920px |
| 태블릿 대응 | 768px ~ 1024px |
| 모바일 대응 | 320px ~ 767px |
### 1.3 해결 방향
```
현재: 픽셀 좌표 → position: absolute → 고정 레이아웃
변경: 그리드 셀 번호 → CSS Grid + ResizeObserver → 반응형 레이아웃
```
---
## 2. 현재 시스템 분석
### 2.1 데이터 현황
```
총 레이아웃: 1,250개
총 컴포넌트: 5,236개
회사 수: 14개
테이블 크기: 약 3MB
```
### 2.2 컴포넌트 타입별 분포
| 컴포넌트 | 수량 | shadcn 사용 |
|----------|------|-------------|
| v2-input | 1,914 | ✅ `@/components/ui/input` |
| v2-button-primary | 1,549 | ✅ `@/components/ui/button` |
| v2-table-search-widget | 355 | ✅ shadcn 기반 |
| v2-select | 327 | ✅ `@/components/ui/select` |
| v2-table-list | 285 | ✅ `@/components/ui/table` |
| v2-media | 181 | ✅ shadcn 기반 |
| v2-date | 132 | ✅ `@/components/ui/calendar` |
| **v2-split-panel-layout** | **131** | ✅ shadcn 기반 (**반응형 필요**) |
| v2-tabs-widget | 75 | ✅ shadcn 기반 |
| 기타 | 287 | ✅ shadcn 기반 |
| **합계** | **5,236** | **전부 shadcn** |
### 2.3 현재 렌더링 방식
```tsx
// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248)
{components.map((child) => (
<div
style={{
position: "absolute", // 절대 위치
left: child.position.x, // 픽셀 고정
top: child.position.y, // 픽셀 고정
width: child.size.width, // 픽셀 고정
height: child.size.height, // 픽셀 고정
}}
>
{renderer.renderChild(child)}
</div>
))}
```
### 2.4 핵심 발견
```
✅ 이미 있는 것:
- 12컬럼 그리드 설정 (gridSettings.columns: 12)
- 그리드 스냅 기능 (snapToGrid: true)
- shadcn/ui 기반 컴포넌트 (전체)
❌ 없는 것:
- 그리드 셀 번호 저장 (현재 픽셀 저장)
- 반응형 브레이크포인트 설정
- CSS Grid 기반 렌더링
- 분할 패널 반응형 처리
```
---
## 3. 기술 결정
### 3.1 왜 Tailwind 동적 클래스가 아닌 CSS Grid + Inline Style인가?
**Tailwind 동적 클래스의 한계**:
```tsx
// ❌ 이건 안 됨 - Tailwind가 빌드 타임에 인식 못함
className={`col-start-${col} md:col-start-${mdCol}`}
// ✅ 이것만 됨 - 정적 클래스
className="col-start-1 md:col-start-3"
```
Tailwind는 **빌드 타임**에 클래스를 스캔하므로, 런타임에 동적으로 생성되는 클래스는 인식하지 못합니다.
**해결책: CSS Grid + Inline Style + ResizeObserver**:
```tsx
// ✅ 올바른 방법
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, 1fr)',
}}>
<div style={{
gridColumn: `${col} / span ${colSpan}`, // 동적 값 가능
}}>
{component}
</div>
</div>
```
### 3.2 역할 분담
| 영역 | 기술 | 설명 |
|------|------|------|
| **UI 컴포넌트** | shadcn/ui | 버튼, 인풋, 테이블 등 (이미 적용됨) |
| **레이아웃 배치** | CSS Grid + Inline Style | 컴포넌트 위치, 크기, 반응형 |
| **반응형 감지** | ResizeObserver | 화면 크기 감지 및 브레이크포인트 변경 |
```
┌─────────────────────────────────────────────────────────┐
│ ResponsiveGridLayout (CSS Grid) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ shadcn │ │ shadcn │ │ shadcn │ │
│ │ Button │ │ Input │ │ Select │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ shadcn Table │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 4. 데이터 구조 변경
### 4.1 현재 구조 (V2)
```json
{
"version": "2.0",
"components": [{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1753, "y": 88, "z": 1 },
"size": { "width": 158, "height": 40 },
"overrides": { ... }
}]
}
```
### 4.2 변경 후 구조 (V2 + 그리드)
```json
{
"version": "2.0",
"layoutMode": "grid",
"components": [{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1753, "y": 88, "z": 1 },
"size": { "width": 158, "height": 40 },
"grid": {
"col": 11,
"row": 2,
"colSpan": 1,
"rowSpan": 1
},
"responsive": {
"sm": { "col": 1, "colSpan": 12 },
"md": { "col": 7, "colSpan": 6 },
"lg": { "col": 11, "colSpan": 1 }
},
"overrides": { ... }
}],
"gridSettings": {
"columns": 12,
"rowHeight": 80,
"gap": 16
}
}
```
### 4.3 필드 설명
| 필드 | 타입 | 설명 |
|------|------|------|
| `layoutMode` | string | "grid" (반응형 그리드 사용) |
| `grid.col` | number | 시작 컬럼 (1-12) |
| `grid.row` | number | 시작 행 (1부터) |
| `grid.colSpan` | number | 차지하는 컬럼 수 |
| `grid.rowSpan` | number | 차지하는 행 수 |
| `responsive.sm` | object | 모바일 (< 768px) 설정 |
| `responsive.md` | object | 태블릿 (768px ~ 1024px) 설정 |
| `responsive.lg` | object | 데스크톱 (> 1024px) 설정 |
### 4.4 호환성
- `position`, `size` 필드는 유지 (디자인 모드 + 폴백용)
- `layoutMode`가 없으면 기존 방식(absolute) 사용
- 마이그레이션 후에도 기존 화면 정상 동작
---
## 5. 구현 상세
### 5.1 그리드 변환 유틸리티
```typescript
// frontend/lib/utils/gridConverter.ts
const DESIGN_WIDTH = 1920;
const COLUMNS = 12;
const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px
const ROW_HEIGHT = 80;
/**
* 픽셀 좌표를 그리드 셀 번호로 변환
*/
export function pixelToGrid(
position: { x: number; y: number },
size: { width: number; height: number }
): GridPosition {
return {
col: Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)),
row: Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1),
colSpan: Math.max(1, Math.round(size.width / COLUMN_WIDTH)),
rowSpan: Math.max(1, Math.round(size.height / ROW_HEIGHT)),
};
}
/**
* 기본 반응형 설정 생성
*/
export function getDefaultResponsive(grid: GridPosition): ResponsiveConfig {
return {
sm: { col: 1, colSpan: 12 }, // 모바일: 전체 너비
md: {
col: Math.max(1, Math.round(grid.col / 2)),
colSpan: Math.min(grid.colSpan * 2, 12)
}, // 태블릿: 2배 확장
lg: { col: grid.col, colSpan: grid.colSpan }, // 데스크톱: 원본
};
}
```
### 5.2 반응형 그리드 레이아웃 컴포넌트
```tsx
// frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx
import React, { useRef, useState, useEffect } from "react";
type Breakpoint = "sm" | "md" | "lg";
interface ResponsiveGridLayoutProps {
layout: LayoutData;
isDesignMode: boolean;
renderer: ComponentRenderer;
}
export function ResponsiveGridLayout({
layout,
isDesignMode,
renderer,
}: ResponsiveGridLayoutProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
// 화면 크기 감지
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
if (width < 768) setBreakpoint("sm");
else if (width < 1024) setBreakpoint("md");
else setBreakpoint("lg");
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const gridSettings = layout.gridSettings || { columns: 12, rowHeight: 80, gap: 16 };
return (
<div
ref={containerRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${gridSettings.columns}, 1fr)`,
gridAutoRows: `${gridSettings.rowHeight}px`,
gap: `${gridSettings.gap}px`,
minHeight: isDesignMode ? "600px" : "auto",
}}
>
{layout.components
.sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0))
.map((component) => {
// 반응형 설정 가져오기
const gridConfig = component.responsive?.[breakpoint] || component.grid;
const { col, colSpan } = gridConfig;
const rowSpan = component.grid?.rowSpan || 1;
return (
<div
key={component.id}
style={{
gridColumn: `${col} / span ${colSpan}`,
gridRow: `span ${rowSpan}`,
}}
>
{renderer.renderChild(component)}
</div>
);
})}
</div>
);
}
```
### 5.3 브레이크포인트 훅
```typescript
// frontend/lib/registry/layouts/responsive-grid/useBreakpoint.ts
import { useState, useEffect, RefObject } from "react";
type Breakpoint = "sm" | "md" | "lg";
export function useBreakpoint(containerRef: RefObject<HTMLElement>): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
if (width < 768) setBreakpoint("sm");
else if (width < 1024) setBreakpoint("md");
else setBreakpoint("lg");
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [containerRef]);
return breakpoint;
}
```
### 5.4 분할 패널 반응형 수정
```tsx
// frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
// 추가할 코드
const containerRef = useRef<HTMLDivElement>(null);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setIsMobile(width < 768);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// 렌더링 부분 수정
return (
<div
ref={containerRef}
className={cn(
"flex h-full",
isMobile ? "flex-col" : "flex-row" // 모바일: 상하, 데스크톱: 좌우
)}
>
<div style={{
width: isMobile ? "100%" : `${leftWidth}%`,
minHeight: isMobile ? "300px" : "auto"
}}>
{/* 좌측/상단 패널 */}
</div>
<div style={{
width: isMobile ? "100%" : `${100 - leftWidth}%`,
minHeight: isMobile ? "300px" : "auto"
}}>
{/* 우측/하단 패널 */}
</div>
</div>
);
```
---
## 6. 렌더링 분기 처리
```typescript
// frontend/lib/registry/DynamicComponentRenderer.tsx
function renderLayout(layout: LayoutData) {
// layoutMode에 따라 분기
if (layout.layoutMode === "grid") {
return <ResponsiveGridLayout layout={layout} renderer={this} />;
}
// 기존 방식 (폴백)
return <FlexboxLayout layout={layout} renderer={this} />;
}
```
---
## 7. 마이그레이션
### 7.1 백업
```sql
-- 마이그레이션 전 백업
CREATE TABLE screen_layouts_v2_backup_20260130 AS
SELECT * FROM screen_layouts_v2;
```
### 7.2 마이그레이션 스크립트
```sql
-- grid, responsive 필드 추가
UPDATE screen_layouts_v2
SET layout_data = (
SELECT jsonb_set(
jsonb_set(
layout_data,
'{layoutMode}',
'"grid"'
),
'{components}',
(
SELECT jsonb_agg(
comp || jsonb_build_object(
'grid', jsonb_build_object(
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
'row', GREATEST(1, ROUND((comp->'position'->>'y')::NUMERIC / 80) + 1),
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)),
'rowSpan', GREATEST(1, ROUND((comp->'size'->>'height')::NUMERIC / 80))
),
'responsive', jsonb_build_object(
'sm', jsonb_build_object('col', 1, 'colSpan', 12),
'md', jsonb_build_object(
'col', GREATEST(1, ROUND((ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1) / 2.0)),
'colSpan', LEAST(ROUND((comp->'size'->>'width')::NUMERIC / 160) * 2, 12)
),
'lg', jsonb_build_object(
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160))
)
)
)
)
FROM jsonb_array_elements(layout_data->'components') as comp
)
)
);
```
### 7.3 롤백
```sql
-- 문제 발생 시 롤백
DROP TABLE screen_layouts_v2;
ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
```
---
## 8. 동작 흐름
### 8.1 데스크톱 (> 1024px)
```
┌────────────────────────────────────────────────────────────┐
│ 1 2 3 4 5 6 7 8 9 10 │ 11 12 │ │
│ │ [버튼] │ │
├────────────────────────────────────────────────────────────┤
│ │
│ 테이블 (12컬럼) │
│ │
└────────────────────────────────────────────────────────────┘
```
### 8.2 태블릿 (768px ~ 1024px)
```
┌─────────────────────────────────────┐
│ 1 2 3 4 5 6 │ 7 8 9 10 11 12 │
│ │ [버튼] │
├─────────────────────────────────────┤
│ │
│ 테이블 (12컬럼) │
│ │
└─────────────────────────────────────┘
```
### 8.3 모바일 (< 768px)
```
┌──────────────────┐
│ [버튼] │ ← 12컬럼 (전체 너비)
├──────────────────┤
│ │
│ 테이블 (스크롤) │ ← 12컬럼 (전체 너비)
│ │
└──────────────────┘
```
### 8.4 분할 패널 (반응형)
**데스크톱**:
```
┌─────────────────────────┬─────────────────────────┐
│ 좌측 패널 (60%) │ 우측 패널 (40%) │
└─────────────────────────┴─────────────────────────┘
```
**모바일**:
```
┌─────────────────────────┐
│ 상단 패널 (이전 좌측) │
├─────────────────────────┤
│ 하단 패널 (이전 우측) │
└─────────────────────────┘
```
---
## 9. 수정 파일 목록
### 9.1 새로 생성
| 파일 | 설명 |
|------|------|
| `lib/utils/gridConverter.ts` | 픽셀 → 그리드 변환 유틸리티 |
| `lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx` | CSS Grid 레이아웃 |
| `lib/registry/layouts/responsive-grid/useBreakpoint.ts` | ResizeObserver 훅 |
| `lib/registry/layouts/responsive-grid/index.ts` | 모듈 export |
### 9.2 수정
| 파일 | 수정 내용 |
|------|-----------|
| `lib/registry/DynamicComponentRenderer.tsx` | layoutMode 분기 추가 |
| `components/screen/ScreenDesigner.tsx` | 저장 시 grid/responsive 생성 |
| `v2-split-panel-layout/SplitPanelLayoutComponent.tsx` | 반응형 처리 추가 |
### 9.3 수정 없음
| 파일 | 이유 |
|------|------|
| `v2-input/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-button-primary/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-table-list/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-select/*` | 레이아웃과 무관 (shadcn 그대로) |
| **...모든 v2 컴포넌트** | **수정 불필요** |
---
## 10. 작업 일정
| Phase | 작업 | 파일 | 시간 |
|-------|------|------|------|
| **1** | 그리드 변환 유틸리티 | `gridConverter.ts` | 2시간 |
| **1** | 브레이크포인트 훅 | `useBreakpoint.ts` | 1시간 |
| **2** | ResponsiveGridLayout | `ResponsiveGridLayout.tsx` | 4시간 |
| **2** | 렌더링 분기 처리 | `DynamicComponentRenderer.tsx` | 1시간 |
| **3** | 저장 로직 수정 | `ScreenDesigner.tsx` | 2시간 |
| **3** | 분할 패널 반응형 | `SplitPanelLayoutComponent.tsx` | 3시간 |
| **4** | 마이그레이션 스크립트 | SQL | 2시간 |
| **4** | 마이그레이션 실행 | - | 1시간 |
| **5** | 테스트 및 버그 수정 | - | 4시간 |
| | **합계** | | **약 2.5일** |
---
## 11. 체크리스트
### 개발 전
- [ ] screen_layouts_v2 백업 완료
- [ ] 개발 환경에서 테스트 데이터 준비
### Phase 1: 유틸리티
- [ ] `gridConverter.ts` 생성
- [ ] `useBreakpoint.ts` 생성
- [ ] 단위 테스트 작성
### Phase 2: 레이아웃
- [ ] `ResponsiveGridLayout.tsx` 생성
- [ ] `DynamicComponentRenderer.tsx` 분기 추가
- [ ] 기존 화면 정상 동작 확인
### Phase 3: 저장/수정
- [ ] `ScreenDesigner.tsx` 저장 로직 수정
- [ ] `SplitPanelLayoutComponent.tsx` 반응형 추가
- [ ] 디자인 모드 테스트
### Phase 4: 마이그레이션
- [ ] 마이그레이션 스크립트 테스트 (개발 DB)
- [ ] 운영 DB 백업
- [ ] 마이그레이션 실행
- [ ] 검증
### Phase 5: 테스트
- [ ] PC (1920px, 1280px) 테스트
- [ ] 태블릿 (768px, 1024px) 테스트
- [ ] 모바일 (375px, 414px) 테스트
- [ ] 분할 패널 화면 테스트
---
## 12. 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 |
| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) |
| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 |
---
## 13. 참고
- [COMPONENT_LAYOUT_V2_ARCHITECTURE.md](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) - V2 아키텍처
- [CSS Grid Layout - MDN](https://developer.mozilla.org/ko/docs/Web/CSS/CSS_Grid_Layout)
- [ResizeObserver - MDN](https://developer.mozilla.org/ko/docs/Web/API/ResizeObserver)
- [shadcn/ui](https://ui.shadcn.com/) - 컴포넌트 라이브러리

View File

@ -1,524 +0,0 @@
# 화면 복제 로직 V2 마이그레이션 계획서
> 작성일: 2026-01-28
## 1. 현황 분석
### 1.1 현재 복제 방식 (Legacy)
```
테이블: screen_layouts (다중 레코드)
방식: 화면당 N개 레코드 (컴포넌트 수만큼)
저장: properties에 전체 설정 "박제"
```
**데이터 구조:**
```sql
-- 화면당 여러 레코드
SELECT * FROM screen_layouts WHERE screen_id = 123;
-- layout_id | screen_id | component_type | component_id | properties (전체 설정)
-- 1 | 123 | table-list | comp_001 | {"tableName": "user", "columns": [...], ...}
-- 2 | 123 | button | comp_002 | {"label": "저장", "variant": "default", ...}
```
### 1.2 V2 방식
```
테이블: screen_layouts_v2 (1개 레코드)
방식: 화면당 1개 레코드 (JSONB)
저장: url + overrides (차이값만)
```
**데이터 구조:**
```sql
-- 화면당 1개 레코드
SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = 123;
-- {
-- "version": "2.0",
-- "components": [
-- { "id": "comp_001", "url": "@/lib/registry/components/table-list", "overrides": {...} },
-- { "id": "comp_002", "url": "@/lib/registry/components/button-primary", "overrides": {...} }
-- ]
-- }
```
---
## 2. 현재 복제 로직 분석
### 2.1 복제 진입점 (2곳)
| 경로 | 파일 | 함수 | 용도 |
|-----|------|------|-----|
| 단일 화면 복제 | `screenManagementService.ts` | `copyScreen()` | 화면 관리에서 개별 화면 복제 |
| 메뉴 일괄 복제 | `menuCopyService.ts` | `copyScreens()` | 메뉴 복제 시 연결된 화면들 복제 |
### 2.2 screenManagementService.copyScreen() 흐름
```
1. screen_definitions 조회 (원본)
2. screen_definitions INSERT (대상)
3. screen_layouts 조회 (원본) ← Legacy
4. flowId 수집 및 복제 (회사 간 복제 시)
5. numberingRuleId 수집 및 복제 (회사 간 복제 시)
6. componentId 재생성 (idMapping)
7. properties 내 참조 업데이트 (flowId, ruleId)
8. screen_layouts INSERT (대상) ← Legacy
```
**V2 처리: ❌ 없음**
### 2.3 menuCopyService.copyScreens() 흐름
```
1단계: screen_definitions 처리
- 기존 복사본 존재 시: 업데이트
- 없으면: 신규 생성
- screenIdMap 생성
2단계: screen_layouts 처리
- 원본 조회
- componentIdMap 생성
- properties 내 참조 업데이트 (screenId, flowId, ruleId, menuId)
- 배치 INSERT
```
**V2 처리: ❌ 없음**
### 2.4 복제 시 처리되는 참조 ID들
| 참조 ID | 설명 | 매핑 방식 |
|--------|-----|----------|
| `componentId` | 컴포넌트 고유 ID | 새로 생성 (`comp_xxx`) |
| `parentId` | 부모 컴포넌트 ID | componentIdMap으로 매핑 |
| `flowId` | 노드 플로우 ID | flowIdMap으로 매핑 (회사 간 복제 시) |
| `numberingRuleId` | 채번 규칙 ID | ruleIdMap으로 매핑 (회사 간 복제 시) |
| `screenId` (탭) | 탭에서 참조하는 화면 ID | screenIdMap으로 매핑 |
| `menuObjid` | 메뉴 ID | menuIdMap으로 매핑 |
---
## 3. V2 마이그레이션 시 변경 필요 사항
### 3.1 핵심 변경점
| 항목 | Legacy | V2 |
|-----|--------|-----|
| 읽기 테이블 | `screen_layouts` | `screen_layouts_v2` |
| 쓰기 테이블 | `screen_layouts` | `screen_layouts_v2` |
| 데이터 형태 | N개 레코드 | 1개 JSONB |
| ID 매핑 위치 | 각 레코드의 컬럼 | JSONB 내부 순회 |
| 참조 업데이트 | `properties` JSON | `overrides` JSON |
### 3.2 수정해야 할 함수들
#### screenManagementService.ts
| 함수 | 변경 내용 |
|-----|----------|
| `copyScreen()` | screen_layouts_v2 복제 로직 추가 |
| `collectFlowIdsFromLayouts()` | V2 JSONB 구조에서 flowId 수집 |
| `collectNumberingRuleIdsFromLayouts()` | V2 JSONB 구조에서 ruleId 수집 |
| `updateFlowIdsInProperties()` | V2 overrides 내 flowId 업데이트 |
| `updateNumberingRuleIdsInProperties()` | V2 overrides 내 ruleId 업데이트 |
#### menuCopyService.ts
| 함수 | 변경 내용 |
|-----|----------|
| `copyScreens()` | screen_layouts_v2 복제 로직 추가 |
| `hasLayoutChanges()` | V2 JSONB 비교 로직 |
| `updateReferencesInProperties()` | V2 overrides 내 참조 업데이트 |
### 3.3 새로 추가할 함수들
```typescript
// V2 레이아웃 복제 (공통)
async copyLayoutV2(
sourceScreenId: number,
targetScreenId: number,
targetCompanyCode: string,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap: Map<number, number>;
ruleIdMap: Map<string, string>;
screenIdMap: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void>
// V2 JSONB에서 참조 ID 수집
collectReferencesFromLayoutV2(layoutData: any): {
flowIds: Set<number>;
ruleIds: Set<string>;
screenIds: Set<number>;
}
// V2 JSONB 내 참조 업데이트
updateReferencesInLayoutV2(
layoutData: any,
mappings: { ... }
): any
```
---
## 4. 마이그레이션 전략
### 4.1 전략: V2 완전 전환
```
결정: V2만 복제 (Legacy 복제 제거)
이유: 깔끔한 코드, 유지보수 용이, V2 아키텍처 일관성
전제: 기존 화면들은 이미 screen_layouts_v2로 마이그레이션 완료 (1,347개 100%)
```
### 4.2 단계별 계획
#### Phase 1: V2 복제 로직 구현 및 전환
```
목표: Legacy 복제를 V2 복제로 완전 교체
영향: 복제 시 screen_layouts_v2 테이블만 사용
작업:
1. copyLayoutV2() 공통 함수 구현
2. screenManagementService.copyScreen() - Legacy → V2 교체
3. menuCopyService.copyScreens() - Legacy → V2 교체
4. 테스트 및 검증
```
#### Phase 2: Legacy 코드 정리
```
목표: 불필요한 Legacy 복제 코드 제거
영향: 코드 간소화
작업:
1. screen_layouts 관련 복제 코드 제거
2. 관련 헬퍼 함수 정리 (collectFlowIdsFromLayouts 등)
3. 코드 리뷰 및 정리
```
#### Phase 3: Legacy 테이블 정리 (선택, 추후)
```
목표: 불필요한 테이블 제거
영향: 데이터 정리
작업:
1. screen_layouts 테이블 데이터 백업
2. screen_layouts 테이블 삭제 (또는 보관)
3. 관련 코드 정리
```
---
## 5. 상세 구현 계획
### 5.1 Phase 1 작업 목록
| # | 작업 | 파일 | 예상 공수 |
|---|-----|------|---------|
| 1 | `copyLayoutV2()` 공통 함수 구현 | screenManagementService.ts | 2시간 |
| 2 | `collectReferencesFromLayoutV2()` 구현 | screenManagementService.ts | 1시간 |
| 3 | `updateReferencesInLayoutV2()` 구현 | screenManagementService.ts | 2시간 |
| 4 | `copyScreen()` - Legacy 제거, V2로 교체 | screenManagementService.ts | 2시간 |
| 5 | `copyScreens()` - Legacy 제거, V2로 교체 | menuCopyService.ts | 3시간 |
| 6 | 단위 테스트 | - | 2시간 |
| 7 | 통합 테스트 | - | 2시간 |
**총 예상 공수: 14시간 (약 2일)**
### 5.2 주요 변경 포인트
#### copyScreen() 변경 전후
**Before (Legacy):**
```typescript
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayoutsResult = await client.query<any>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
[sourceScreenId]
);
// ... N개 레코드 순회하며 INSERT
```
**After (V2):**
```typescript
// 4. 원본 V2 레이아웃 조회
const sourceLayoutV2 = await client.query(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceCompanyCode]
);
// ... JSONB 변환 후 1개 레코드 INSERT
```
#### copyScreens() 변경 전후
**Before (Legacy):**
```typescript
// 레이아웃 배치 INSERT
await client.query(
`INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`,
layoutParams
);
```
**After (V2):**
```typescript
// V2 레이아웃 UPSERT
await this.copyLayoutV2(
originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode,
{ componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap },
client
);
```
### 5.2 copyLayoutV2() 구현 방안
```typescript
private async copyLayoutV2(
sourceScreenId: number,
targetScreenId: number,
sourceCompanyCode: string,
targetCompanyCode: string,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap?: Map<number, number>;
ruleIdMap?: Map<string, string>;
screenIdMap?: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void> {
// 1. 원본 V2 레이아웃 조회
const sourceResult = await client.query(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceCompanyCode]
);
if (sourceResult.rows.length === 0) {
// V2 레이아웃 없으면 스킵 (Legacy만 있는 경우)
return;
}
const layoutData = sourceResult.rows[0].layout_data;
// 2. components 배열 순회하며 ID 매핑
const updatedComponents = layoutData.components.map((comp: any) => {
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
// overrides 내 참조 업데이트
let updatedOverrides = { ...comp.overrides };
// flowId 매핑
if (mappings.flowIdMap && updatedOverrides.flowId) {
const newFlowId = mappings.flowIdMap.get(updatedOverrides.flowId);
if (newFlowId) updatedOverrides.flowId = newFlowId;
}
// numberingRuleId 매핑
if (mappings.ruleIdMap && updatedOverrides.numberingRuleId) {
const newRuleId = mappings.ruleIdMap.get(updatedOverrides.numberingRuleId);
if (newRuleId) updatedOverrides.numberingRuleId = newRuleId;
}
// screenId 매핑 (탭 컴포넌트 등)
if (mappings.screenIdMap && updatedOverrides.screenId) {
const newScreenId = mappings.screenIdMap.get(updatedOverrides.screenId);
if (newScreenId) updatedOverrides.screenId = newScreenId;
}
// tabs 배열 내 screenId 매핑
if (mappings.screenIdMap && Array.isArray(updatedOverrides.tabs)) {
updatedOverrides.tabs = updatedOverrides.tabs.map((tab: any) => ({
...tab,
screenId: mappings.screenIdMap.get(tab.screenId) || tab.screenId
}));
}
return {
...comp,
id: newId,
overrides: updatedOverrides
};
});
const newLayoutData = {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString()
};
// 3. 대상 V2 레이아웃 저장 (UPSERT)
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[targetScreenId, targetCompanyCode, JSON.stringify(newLayoutData)]
);
}
```
---
## 6. 테스트 계획
### 6.1 단위 테스트
| 테스트 케이스 | 설명 |
|-------------|------|
| V2 레이아웃 복제 - 기본 | 단순 컴포넌트 복제 |
| V2 레이아웃 복제 - flowId 매핑 | 회사 간 복제 시 flowId 변경 확인 |
| V2 레이아웃 복제 - ruleId 매핑 | 회사 간 복제 시 ruleId 변경 확인 |
| V2 레이아웃 복제 - 탭 screenId 매핑 | 탭 컴포넌트의 screenId 변경 확인 |
| V2 레이아웃 없는 경우 | Legacy만 있는 화면 복제 시 스킵 확인 |
### 6.2 통합 테스트
| 테스트 케이스 | 설명 |
|-------------|------|
| 단일 화면 복제 (같은 회사) | copyScreen() - 동일 회사 내 복제 |
| 단일 화면 복제 (다른 회사) | copyScreen() - 회사 간 복제 |
| 메뉴 일괄 복제 | copyScreens() - 여러 화면 동시 복제 |
| 모달 포함 복제 | copyScreenWithModals() - 메인 + 모달 복제 |
### 6.3 검증 항목
```
복제 후 확인:
- [ ] screen_layouts_v2에 레코드 생성됨
- [ ] componentId가 새로 생성됨
- [ ] flowId가 정확히 매핑됨
- [ ] numberingRuleId가 정확히 매핑됨
- [ ] 탭 컴포넌트의 screenId가 정확히 매핑됨
- [ ] screen_layouts(Legacy)는 복제되지 않음
- [ ] 복제된 화면이 프론트엔드에서 정상 로드됨
- [ ] 복제된 화면 편집/저장 정상 동작
```
---
## 7. 영향 분석
### 7.1 영향 받는 기능
| 기능 | 영향 | 비고 |
|-----|-----|-----|
| 화면 관리 - 화면 복제 | 직접 영향 | copyScreen() |
| 화면 관리 - 그룹 복제 | 직접 영향 | copyScreenWithModals() |
| 메뉴 복제 | 직접 영향 | menuCopyService.copyScreens() |
| 화면 디자이너 | 간접 영향 | 복제된 화면 로드 시 V2 사용 |
### 7.2 롤백 계획
```
V2 전환 롤백 (필요시):
1. Git에서 이전 버전 복원 (copyScreen, copyScreens)
2. Legacy 복제 코드 복원
3. 테스트 후 배포
주의사항:
- V2로 복제된 화면들은 screen_layouts_v2에만 데이터 존재
- 롤백 시 해당 화면들은 screen_layouts에 데이터 없음
- 필요시 V2 → Legacy 역변환 스크립트 실행
```
---
## 8. 관련 파일
### 8.1 수정 대상
| 파일 | 변경 내용 |
|-----|----------|
| `backend-node/src/services/screenManagementService.ts` | copyLayoutV2(), copyScreen() 수정 |
| `backend-node/src/services/menuCopyService.ts` | copyScreens() 수정 |
### 8.2 참고 파일
| 파일 | 설명 |
|-----|-----|
| `docs/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | V2 아키텍처 문서 |
| `frontend/lib/api/screen.ts` | getLayoutV2, saveLayoutV2 |
| `frontend/lib/utils/layoutV2Converter.ts` | V2 변환 유틸리티 |
---
## 9. 체크리스트
### 9.1 개발 전
- [ ] V2 아키텍처 문서 숙지
- [ ] 현재 복제 로직 코드 리뷰
- [ ] 테스트 데이터 준비 (V2 레이아웃이 있는 화면)
### 9.2 Phase 1 완료 조건
- [x] copyLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] collectReferencesFromLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] updateReferencesInLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] copyScreen() - Legacy 제거, V2로 교체 ✅ 2026-01-28
- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28
- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28
- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28
- [ ] 단위 테스트 통과
- [ ] 통합 테스트 통과
- [ ] V2 전용 복제 동작 확인
### 9.3 Phase 2 완료 조건
- [ ] Legacy 관련 헬퍼 함수 정리
- [ ] 불필요한 코드 제거
- [ ] 코드 리뷰 완료
- [ ] 회귀 테스트 통과
---
## 10. 시뮬레이션 검증 결과
### 10.1 검증된 시나리오
| 시나리오 | 결과 | 비고 |
|---------|------|------|
| 같은 회사 내 복제 | ✅ 정상 | componentId만 새로 생성 |
| 회사 간 복제 (flowId 매핑) | ✅ 정상 | flowIdMap 적용됨 |
| 회사 간 복제 (ruleId 매핑) | ✅ 정상 | ruleIdMap 적용됨 |
| 탭 컴포넌트 screenId 매핑 | ✅ 정상 | updateTabScreenReferences V2 지원 추가 |
| V2 레이아웃 없는 화면 | ✅ 정상 | 스킵 처리 |
### 10.2 발견 및 수정된 문제
| 문제 | 해결 |
|-----|------|
| updateTabScreenReferences가 V2 미지원 | V2 처리 로직 추가 완료 |
### 10.3 Zod 활용 가능성
프론트엔드에 이미 훌륭한 Zod 유틸리티 존재:
- `deepMerge()` - 깊은 병합
- `extractCustomConfig()` - 차이값 추출
- `loadComponentV2()` / `saveComponentV2()` - V2 로드/저장
향후 백엔드에도 Zod 추가 시:
- 타입 안전성 향상
- 프론트/백엔드 스키마 공유 가능
- 범용 참조 탐색 로직으로 하드코딩 제거 가능
---
## 11. 변경 이력
| 날짜 | 변경 내용 | 작성자 |
|-----|----------|-------|
| 2026-01-28 | 초안 작성 | Claude |
| 2026-01-28 | V2 완전 전환 전략으로 변경 (병행 운영 → V2 전용) | Claude |
| 2026-01-28 | Phase 1 구현 완료 - V2 복제 함수들 구현 및 Legacy 교체 | Claude |
| 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude |
| 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude |
| 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude |

Some files were not shown because too many files have changed in this diff Show More