Compare commits

..

No commits in common. "main" and "fix/modalError" have entirely different histories.

828 changed files with 24061 additions and 261032 deletions

View File

@ -278,117 +278,4 @@ const hiddenColumns = new Set([
---
## 11. 화면관리 시스템 위젯 개발 가이드
### 위젯 크기 설정의 핵심 원칙
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
#### ✅ 올바른 크기 설정 패턴
```tsx
// 위젯 컴포넌트 내부
export function YourWidget({ component }: YourWidgetProps) {
return (
<div
className="flex h-full w-full items-center justify-between gap-2"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
}}
>
{/* 위젯 내용 */}
</div>
);
}
```
#### ❌ 잘못된 크기 설정 패턴
```tsx
// 이렇게 하면 안 됩니다!
<div
style={{
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
}}
>
```
### 이유
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
```tsx
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: getWidth(), // size.width 사용
height: getHeight(), // size.height 사용
};
```
2. 위젯 내부에서 크기를 다시 설정하면:
- 중복 설정으로 인한 충돌
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
### 위젯이 관리해야 할 스타일
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
- ✅ `padding`: 내부 여백
- ✅ `backgroundColor`: 배경색
- ✅ `border`, `borderRadius`: 테두리
- ✅ `gap`: 자식 요소 간격
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
### 위젯 등록 시 defaultSize
```tsx
ComponentRegistry.registerComponent({
id: "your-widget",
name: "위젯 이름",
category: "utility",
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
component: YourWidget,
defaultProps: {
style: {
padding: "0.75rem",
// width, height는 defaultSize로 제어되므로 여기 불필요
},
},
});
```
### 레이아웃 구조
```tsx
// 전체 높이를 차지하고 내부 요소를 정렬
<div className="flex h-full w-full items-center justify-between gap-2">
{/* 왼쪽 컨텐츠 */}
<div className="flex items-center gap-3">{/* ... */}</div>
{/* 오른쪽 버튼들 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
</div>
</div>
```
### 체크리스트
위젯 개발 시 다음을 확인하세요:
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
- [ ] `defaultSize`에 적절한 기본 크기 설정
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
---
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**

View File

@ -1,559 +0,0 @@
# 다국어 지원 컴포넌트 개발 가이드
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
---
## 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

@ -1,310 +0,0 @@
# TableListComponent 개발 가이드
## 개요
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
---
## 핵심 기능 목록
### 1. 인라인 편집 (Inline Editing)
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
- 직접 타이핑으로도 편집 모드 진입 가능
- Enter로 저장, Escape로 취소
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
```typescript
// ColumnConfig에서 editable 속성 사용
interface ColumnConfig {
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
}
```
**편집 불가 컬럼 체크 필수 위치**:
1. `handleCellDoubleClick` - 더블클릭 편집
2. `onKeyDown` F2 케이스 - 키보드 편집
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
4. 컨텍스트 메뉴 "셀 편집" 옵션
### 2. 배치 편집 (Batch Editing)
- 여러 셀 수정 후 일괄 저장/취소
- `pendingChanges` Map으로 변경사항 추적
- 저장 전 유효성 검증
### 3. 데이터 유효성 검증 (Validation)
```typescript
type ValidationRule = {
required?: boolean;
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
customMessage?: string;
validate?: (value: any, row: any) => string | null;
};
```
### 4. 컬럼 헤더 필터 (Header Filter)
- 각 컬럼 헤더에 필터 아이콘
- 고유값 목록에서 다중 선택 필터링
- `headerFilters` Map으로 필터 상태 관리
### 5. 필터 빌더 (Filter Builder)
```typescript
interface FilterCondition {
id: string;
column: string;
operator: "equals" | "notEquals" | "contains" | "notContains" |
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
value: string;
}
interface FilterGroup {
id: string;
logic: "AND" | "OR";
conditions: FilterCondition[];
}
```
### 6. 검색 패널 (Search Panel)
- 전체 데이터 검색
- 검색어 하이라이팅
- `searchHighlights` Map으로 하이라이트 위치 관리
### 7. 엑셀 내보내기 (Excel Export)
- `xlsx` 라이브러리 사용
- 현재 표시 데이터 또는 전체 데이터 내보내기
```typescript
import * as XLSX from "xlsx";
// 사용 예시
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
```
### 8. 클립보드 복사 (Copy to Clipboard)
- 선택된 행 또는 전체 데이터 복사
- 탭 구분자로 엑셀 붙여넣기 호환
### 9. 컨텍스트 메뉴 (Context Menu)
- 우클릭으로 메뉴 표시
- 셀 편집, 행 복사, 행 삭제 등 옵션
- 편집 불가 컬럼은 "(잠김)" 표시
### 10. 키보드 네비게이션
| 키 | 동작 |
|---|---|
| Arrow Keys | 셀 이동 |
| Tab | 다음 셀 |
| Shift+Tab | 이전 셀 |
| F2 | 편집 모드 |
| Enter | 저장 후 아래로 이동 |
| Escape | 편집 취소 |
| Ctrl+C | 복사 |
| Delete | 셀 값 삭제 |
### 11. 컬럼 리사이징
- 컬럼 헤더 경계 드래그로 너비 조절
- `columnWidths` 상태로 관리
- localStorage에 저장
### 12. 컬럼 순서 변경
- 드래그 앤 드롭으로 컬럼 순서 변경
- `columnOrder` 상태로 관리
- localStorage에 저장
### 13. 상태 영속성 (State Persistence)
```typescript
// localStorage 키 패턴
const stateKey = `tableState_${tableName}_${userId}`;
// 저장되는 상태
interface TableState {
columnWidths: Record<string, number>;
columnOrder: string[];
sortBy: string;
sortOrder: "asc" | "desc";
frozenColumns: string[];
columnVisibility: Record<string, boolean>;
}
```
### 14. 그룹화 및 그룹 소계
```typescript
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
summary?: Record<string, { sum: number; avg: number; count: number }>;
}
```
### 15. 총계 요약 (Total Summary)
- 숫자 컬럼의 합계, 평균, 개수 표시
- 테이블 하단에 요약 행 렌더링
---
## 캐싱 전략
```typescript
// 테이블 컬럼 캐시
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
// API 호출 디바운싱
const debouncedApiCall = <T extends any[], R>(
key: string,
fn: (...args: T) => Promise<R>,
delay: number = 300
) => { ... };
```
---
## 필수 Import
```typescript
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import * as XLSX from "xlsx";
import { toast } from "sonner";
```
---
## 주요 상태 (State)
```typescript
// 데이터 관련
const [tableData, setTableData] = useState<any[]>([]);
const [filteredData, setFilteredData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 편집 관련
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
colIndex: number;
columnName: string;
originalValue: any;
} | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
// 필터 관련
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
const [globalSearchText, setGlobalSearchText] = useState("");
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
// 컬럼 관련
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 선택 관련
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
// 정렬 관련
const [sortBy, setSortBy] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [totalCount, setTotalCount] = useState(0);
```
---
## 편집 불가 컬럼 구현 체크리스트
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
- [ ] `column.editable === false` 체크 추가
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
```typescript
// 표준 편집 불가 체크 패턴
const column = visibleColumns.find((col) => col.columnName === columnName);
if (column?.editable === false) {
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
return;
}
```
---
## 시각적 표시
### 편집 불가 컬럼 표시
```tsx
// 헤더에 잠금 아이콘
{column.editable === false && (
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
)}
// 셀 배경색
className={cn(
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
)}
```
---
## 성능 최적화
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
2. **useCallback 사용**: 이벤트 핸들러 함수들
3. **디바운싱**: API 호출, 검색, 필터링
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
---
## 주의사항
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
---
## 관련 파일
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블

View File

@ -1,592 +0,0 @@
# 테이블 타입 관리 SQL 작성 가이드
테이블 타입 관리에서 테이블 생성 시 적용되는 컬럼, 타입, 메타데이터 등록 로직을 기반으로 한 SQL 작성 가이드입니다.
## 핵심 원칙
1. **모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일**: 날짜 타입 외 모든 컬럼은 `VARCHAR(500)`
2. **날짜/시간 컬럼만 `TIMESTAMP` 사용**: `created_date`, `updated_date` 등
3. **기본 컬럼 5개 자동 포함**: 모든 테이블에 id, created_date, updated_date, writer, company_code 필수
4. **3개 메타데이터 테이블 등록 필수**: `table_labels`, `column_labels`, `table_type_columns`
---
## 1. 테이블 생성 DDL 템플릿
### 기본 구조
```sql
CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
"컬럼1" varchar(500),
"컬럼2" varchar(500),
"컬럼3" varchar(500)
);
```
### 예시: 고객 테이블 생성
```sql
CREATE TABLE "customer_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"customer_name" varchar(500),
"customer_code" varchar(500),
"phone" varchar(500),
"email" varchar(500),
"address" varchar(500),
"status" varchar(500),
"registration_date" varchar(500)
);
```
---
## 2. 메타데이터 테이블 등록
테이블 생성 시 반드시 아래 3개 테이블에 메타데이터를 등록해야 합니다.
### 2.1 table_labels (테이블 메타데이터)
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('테이블명', '테이블 라벨', '테이블 설명', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### 2.2 table_type_columns (컬럼 타입 정보)
**필수 컬럼**: `table_name`, `column_name`, `company_code`, `input_type`, `display_order`
```sql
-- 기본 컬럼 등록 (display_order: -5 ~ -1)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('테이블명', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('테이블명', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('테이블명', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('테이블명', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
display_order = EXCLUDED.display_order,
updated_date = now();
-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작)
INSERT INTO table_type_columns (
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES
('테이블명', '컬럼1', '*', 'text', '{}', 'Y', 0, now(), now()),
('테이블명', '컬럼2', '*', 'number', '{}', 'Y', 1, now(), now()),
('테이블명', '컬럼3', '*', 'code', '{"codeCategory":"카테고리코드"}', 'Y', 2, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
display_order = EXCLUDED.display_order,
updated_date = now();
```
### 2.3 column_labels (레거시 호환용 - 필수)
```sql
-- 기본 컬럼 등록
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES
('테이블명', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, now(), now()),
('테이블명', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('테이블명', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('테이블명', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('테이블명', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
-- 사용자 정의 컬럼 등록
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', '컬럼1 라벨', 'text', '{}', '컬럼1 설명', 0, true, now(), now()),
('테이블명', '컬럼2', '컬럼2 라벨', 'number', '{}', '컬럼2 설명', 1, true, now(), now())
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = EXCLUDED.column_label,
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
description = EXCLUDED.description,
display_order = EXCLUDED.display_order,
is_visible = EXCLUDED.is_visible,
updated_date = now();
```
---
## 3. Input Type 정의
### 지원되는 Input Type 목록
| input_type | 설명 | DB 저장 타입 | UI 컴포넌트 |
| ---------- | ------------- | ------------ | -------------------- |
| `text` | 텍스트 입력 | VARCHAR(500) | Input |
| `number` | 숫자 입력 | VARCHAR(500) | Input (type=number) |
| `date` | 날짜/시간 | VARCHAR(500) | DatePicker |
| `code` | 공통코드 선택 | VARCHAR(500) | Select (코드 목록) |
| `entity` | 엔티티 참조 | VARCHAR(500) | Select (테이블 참조) |
| `select` | 선택 목록 | VARCHAR(500) | Select |
| `checkbox` | 체크박스 | VARCHAR(500) | Checkbox |
| `radio` | 라디오 버튼 | VARCHAR(500) | RadioGroup |
| `textarea` | 긴 텍스트 | VARCHAR(500) | Textarea |
| `file` | 파일 업로드 | VARCHAR(500) | FileUpload |
### WebType → InputType 변환 규칙
```
text, textarea, email, tel, url, password → text
number, decimal → number
date, datetime, time → date
select, dropdown → select
checkbox, boolean → checkbox
radio → radio
code → code
entity → entity
file → text
button → text
```
---
## 4. Detail Settings 설정
### 4.1 Code 타입 (공통코드 참조)
```json
{
"codeCategory": "코드_카테고리_ID"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'code', '{"codeCategory":"STATUS_CODE"}', ...);
```
### 4.2 Entity 타입 (테이블 참조)
```json
{
"referenceTable": "참조_테이블명",
"referenceColumn": "참조_컬럼명(보통 id)",
"displayColumn": "표시할_컬럼명"
}
```
```sql
INSERT INTO table_type_columns (..., input_type, detail_settings, ...)
VALUES (..., 'entity', '{"referenceTable":"user_info","referenceColumn":"id","displayColumn":"user_name"}', ...);
```
### 4.3 Select 타입 (정적 옵션)
```json
{
"options": [
{ "label": "옵션1", "value": "value1" },
{ "label": "옵션2", "value": "value2" }
]
}
```
---
## 5. 전체 예시: 주문 테이블 생성
### Step 1: DDL 실행
```sql
CREATE TABLE "order_info" (
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500),
"order_no" varchar(500),
"order_date" varchar(500),
"customer_id" varchar(500),
"total_amount" varchar(500),
"status" varchar(500),
"notes" varchar(500)
);
```
### Step 2: table_labels 등록
```sql
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ('order_info', '주문 정보', '주문 관리 테이블', now(), now())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = now();
```
### Step 3: table_type_columns 등록
```sql
-- 기본 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'id', '*', 'text', '{}', 'Y', -5, now(), now()),
('order_info', 'created_date', '*', 'date', '{}', 'Y', -4, now(), now()),
('order_info', 'updated_date', '*', 'date', '{}', 'Y', -3, now(), now()),
('order_info', 'writer', '*', 'text', '{}', 'Y', -2, now(), now()),
('order_info', 'company_code', '*', 'text', '{}', 'Y', -1, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES
('order_info', 'order_no', '*', 'text', '{}', 'Y', 0, now(), now()),
('order_info', 'order_date', '*', 'date', '{}', 'Y', 1, now(), now()),
('order_info', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'Y', 2, now(), now()),
('order_info', 'total_amount', '*', 'number', '{}', 'Y', 3, now(), now()),
('order_info', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 4, now(), now()),
('order_info', 'notes', '*', 'textarea', '{}', 'Y', 5, now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, display_order = EXCLUDED.display_order, updated_date = now();
```
### Step 4: column_labels 등록 (레거시 호환)
```sql
-- 기본 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'id', 'ID', 'text', '{}', '기본키', -5, true, now(), now()),
('order_info', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, now(), now()),
('order_info', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, now(), now()),
('order_info', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, now(), now()),
('order_info', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
-- 사용자 정의 컬럼
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES
('order_info', 'order_no', '주문번호', 'text', '{}', '주문 식별 번호', 0, true, now(), now()),
('order_info', 'order_date', '주문일자', 'date', '{}', '주문 발생 일자', 1, true, now(), now()),
('order_info', 'customer_id', '고객', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '주문 고객', 2, true, now(), now()),
('order_info', 'total_amount', '총금액', 'number', '{}', '주문 총 금액', 3, true, now(), now()),
('order_info', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '주문 상태', 4, true, now(), now()),
('order_info', 'notes', '비고', 'textarea', '{}', '추가 메모', 5, true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, updated_date = now();
```
---
## 6. 컬럼 추가 시
### DDL
```sql
ALTER TABLE "테이블명" ADD COLUMN "새컬럼명" varchar(500);
```
### 메타데이터 등록
```sql
-- table_type_columns
INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '*', 'text', '{}', 'Y', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM table_type_columns WHERE table_name = '테이블명'), now(), now())
ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, display_order = EXCLUDED.display_order, updated_date = now();
-- column_labels
INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date)
VALUES ('테이블명', '새컬럼명', '새컬럼 라벨', 'text', '{}', '새컬럼 설명', (SELECT COALESCE(MAX(display_order), 0) + 1 FROM column_labels WHERE table_name = '테이블명'), true, now(), now())
ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, updated_date = now();
```
---
## 7. 로그 테이블 생성 (선택사항)
변경 이력 추적이 필요한 테이블에는 로그 테이블을 생성할 수 있습니다.
### 7.1 로그 테이블 DDL 템플릿
```sql
-- 로그 테이블 생성
CREATE TABLE 테이블명_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL, -- INSERT/UPDATE/DELETE
original_id VARCHAR(100), -- 원본 테이블 PK 값
changed_column VARCHAR(100), -- 변경된 컬럼명
old_value TEXT, -- 변경 전 값
new_value TEXT, -- 변경 후 값
changed_by VARCHAR(50), -- 변경자 ID
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
ip_address VARCHAR(50), -- 변경 요청 IP
user_agent TEXT, -- User Agent
full_row_before JSONB, -- 변경 전 전체 행
full_row_after JSONB -- 변경 후 전체 행
);
-- 인덱스 생성
CREATE INDEX idx_테이블명_log_original_id ON 테이블명_log(original_id);
CREATE INDEX idx_테이블명_log_changed_at ON 테이블명_log(changed_at);
CREATE INDEX idx_테이블명_log_operation ON 테이블명_log(operation_type);
-- 코멘트 추가
COMMENT ON TABLE 테이블명_log IS '테이블명 테이블 변경 이력';
```
### 7.2 트리거 함수 DDL 템플릿
```sql
CREATE OR REPLACE FUNCTION 테이블명_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '테이블명'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO 테이블명_log (
operation_type, original_id, changed_column, old_value, new_value,
changed_by, ip_address, full_row_before, full_row_after
)
VALUES (
'UPDATE', NEW.id, v_column_name, v_old_value, v_new_value,
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO 테이블명_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
```
### 7.3 트리거 DDL 템플릿
```sql
CREATE TRIGGER 테이블명_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON 테이블명
FOR EACH ROW EXECUTE FUNCTION 테이블명_log_trigger_func();
```
### 7.4 로그 설정 등록
```sql
INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_by, created_at
) VALUES (
'테이블명', '테이블명_log', '테이블명_audit_trigger',
'테이블명_log_trigger_func', 'Y', '생성자ID', now()
);
```
### 7.5 table_labels에 use_log_table 플래그 설정
```sql
UPDATE table_labels
SET use_log_table = 'Y', updated_date = now()
WHERE table_name = '테이블명';
```
### 7.6 전체 예시: order_info 로그 테이블 생성
```sql
-- Step 1: 로그 테이블 생성
CREATE TABLE order_info_log (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_order_info_log_original_id ON order_info_log(original_id);
CREATE INDEX idx_order_info_log_changed_at ON order_info_log(changed_at);
CREATE INDEX idx_order_info_log_operation ON order_info_log(operation_type);
COMMENT ON TABLE order_info_log IS 'order_info 테이블 변경 이력';
-- Step 2: 트리거 함수 생성
CREATE OR REPLACE FUNCTION order_info_log_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ('INSERT', NEW.id, v_user_id, v_ip_address, row_to_json(NEW)::jsonb);
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name FROM information_schema.columns
WHERE table_name = 'order_info' AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
INSERT INTO order_info_log (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ('UPDATE', NEW.id, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb);
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO order_info_log (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ('DELETE', OLD.id, v_user_id, v_ip_address, row_to_json(OLD)::jsonb);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Step 3: 트리거 생성
CREATE TRIGGER order_info_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_info
FOR EACH ROW EXECUTE FUNCTION order_info_log_trigger_func();
-- Step 4: 로그 설정 등록
INSERT INTO table_log_config (original_table_name, log_table_name, trigger_name, trigger_function_name, is_active, created_by, created_at)
VALUES ('order_info', 'order_info_log', 'order_info_audit_trigger', 'order_info_log_trigger_func', 'Y', 'system', now());
-- Step 5: table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'Y', updated_date = now() WHERE table_name = 'order_info';
```
### 7.7 로그 테이블 삭제
```sql
-- 트리거 삭제
DROP TRIGGER IF EXISTS 테이블명_audit_trigger ON 테이블명;
-- 트리거 함수 삭제
DROP FUNCTION IF EXISTS 테이블명_log_trigger_func();
-- 로그 테이블 삭제
DROP TABLE IF EXISTS 테이블명_log;
-- 로그 설정 삭제
DELETE FROM table_log_config WHERE original_table_name = '테이블명';
-- table_labels 플래그 업데이트
UPDATE table_labels SET use_log_table = 'N', updated_date = now() WHERE table_name = '테이블명';
```
---
## 8. 체크리스트
### 테이블 생성/수정 시 반드시 확인할 사항:
- [ ] DDL에 기본 5개 컬럼 포함 (id, created_date, updated_date, writer, company_code)
- [ ] 모든 비즈니스 컬럼은 `VARCHAR(500)` 타입 사용
- [ ] `table_labels`에 테이블 메타데이터 등록
- [ ] `table_type_columns`에 모든 컬럼 등록 (company_code = '\*')
- [ ] `column_labels`에 모든 컬럼 등록 (레거시 호환)
- [ ] 기본 컬럼 display_order: -5 ~ -1
- [ ] 사용자 정의 컬럼 display_order: 0부터 순차
- [ ] code/entity 타입은 detail_settings에 참조 정보 포함
- [ ] ON CONFLICT 절로 중복 시 UPDATE 처리
### 로그 테이블 생성 시 확인할 사항 (선택):
- [ ] 로그 테이블 생성 (`테이블명_log`)
- [ ] 인덱스 3개 생성 (original_id, changed_at, operation_type)
- [ ] 트리거 함수 생성 (`테이블명_log_trigger_func`)
- [ ] 트리거 생성 (`테이블명_audit_trigger`)
- [ ] `table_log_config`에 로그 설정 등록
- [ ] `table_labels.use_log_table = 'Y'` 업데이트
---
## 9. 금지 사항
1. **DB 타입 직접 지정 금지**: NUMBER, INTEGER, DATE 등 DB 타입 직접 사용 금지
2. **VARCHAR 길이 변경 금지**: 반드시 `VARCHAR(500)` 사용
3. **기본 컬럼 누락 금지**: id, created_date, updated_date, writer, company_code 필수
4. **메타데이터 미등록 금지**: 3개 테이블 모두 등록 필수
5. **web_type 사용 금지**: 레거시 컬럼이므로 `input_type` 사용
---
## 참조 파일
- `backend-node/src/services/ddlExecutionService.ts`: DDL 실행 서비스
- `backend-node/src/services/tableManagementService.ts`: 로그 테이블 생성 서비스
- `backend-node/src/types/ddl.ts`: DDL 타입 정의
- `backend-node/src/controllers/ddlController.ts`: DDL API 컨트롤러
- `backend-node/src/controllers/tableManagementController.ts`: 로그 테이블 API 컨트롤러

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

104
PLAN.MD
View File

@ -1,104 +0,0 @@
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
### 1. 단일 화면 복제
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
### 2. 그룹(폴더) 전체 복제
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] 정렬 순서(display_order) 유지
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
### 3. 고급 옵션: 이름 일괄 변경
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
- [x] 미리보기 기능
### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
### 6. 테이블 설정 기능 (TableSettingModal)
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 7. 회사 코드 지원 (최고 관리자)
- [x] 대상 회사 선택 가능
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `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
## 진행 상태
- [완료] 단일 화면 복제 + 새로고침
- [완료] 그룹 전체 복제 (재귀적)
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [완료] 화면 수정 (이름/그룹/역할/순서)
- [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
---
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
## 핵심 기능
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
2. **백엔드 로직 개선**:
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
## 테스트 계획
### 1단계: 기본 기능 및 DB 마이그레이션
- [x] DB 마이그레이션 스크립트 작성 및 실행
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
### 3단계: 프론트엔드 구현
- [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태
- [완료] 모든 단계 구현 완료

View File

@ -1,680 +0,0 @@
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
## 1. 개요
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
### 현재 컴포넌트 현황 (AS-IS)
| 카테고리 | 파일 수 | 주요 파일들 |
| :------------- | :-----: | :------------------------------------------------------------------ |
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
---
## 2. 통합 전략: 9 Core Widgets
### A. 입력 위젯 (Input Widgets) - 5종
단순 데이터 입력 필드를 통합합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<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종
레이아웃 배치와 데이터 시각화를 담당합니다.
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
| **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 통합 전략 (핵심)
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
| AS-IS | TO-BE | 방식 |
| :-------------------- | :--------------------- | :------------------------------- |
| TextConfigPanel.tsx | | |
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
| ... 24개 더 | | |
---
## 3. 구현 시나리오 (속성 기반 변신)
### Case 1: "테이블을 카드 리스트로 변경"
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table``Card`로 변경하면 즉시 반영.
### Case 2: "단일 선택을 라디오 버튼으로 변경"
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown``Radio`로 변경.
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
---
## 4. 실행 로드맵 (Action Plan)
### Phase 0: 준비 단계 (1주)
통합 작업 전 필수 분석 및 설계를 진행합니다.
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
- [ ] `sys_input_type` 테이블 JSON Schema 설계
- [ ] DynamicConfigPanel 프로토타입 설계
### Phase 1: 입력 위젯 통합 (2주)
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
### Phase 2: Config Panel 통합 (2주)
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
### Phase 4: 안정화 및 마이그레이션 (2주)
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
- [ ] 마이그레이션 테스트 (스테이징 환경)
- [ ] 문서화 및 개발 가이드 작성
### Phase 5: 레거시 정리 (추후 결정)
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
- [ ] 미전환 화면 목록 정리
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
---
## 5. 데이터 마이그레이션 전략
### 5.1 위젯 타입 매핑 테이블
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
| :-------------- | :------------ | :------------------------------ |
| `text` | UnifiedInput | `type: "text"` |
| `number` | UnifiedInput | `type: "number"` |
| `email` | UnifiedInput | `type: "text", format: "email"` |
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
| `select` | UnifiedSelect | `mode: "dropdown"` |
| `radio` | UnifiedSelect | `mode: "radio"` |
| `checkbox` | UnifiedSelect | `mode: "check"` |
| `date` | UnifiedDate | `type: "date"` |
| `datetime` | UnifiedDate | `type: "datetime"` |
| `textarea` | UnifiedText | `mode: "simple"` |
| `file` | UnifiedMedia | `type: "file"` |
| `image` | UnifiedMedia | `type: "image"` |
### 5.2 마이그레이션 원칙
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
---
## 6. 기대 효과
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
---
## 7. 리스크 및 대응 방안
| 리스크 | 영향도 | 대응 방안 |
| :----------------------- | :----: | :-------------------------------- |
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
---
## 8. 현재 컴포넌트 매핑 분석
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
#### UnifiedInput으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :------------- |
| text-input | `type: "text"` | |
| number-input | `type: "number"` | |
| slider-basic | `type: "slider"` | 속성 추가 필요 |
| button-primary | `type: "button"` | 별도 검토 |
#### UnifiedSelect로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------------ | :----------------------------------- | :------------- |
| select-basic | `mode: "dropdown"` | |
| checkbox-basic | `mode: "check"` | |
| radio-basic | `mode: "radio"` | |
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
| entity-search-input | `source: "entity"` | |
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
| location-swap-selector | `mode: "swap"` | 특수 UI |
#### UnifiedDate로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------- | :--- |
| date-input | `type: "date"` | |
#### UnifiedText로 통합 (1개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------- | :--------------- | :--- |
| textarea-basic | `mode: "simple"` | |
#### UnifiedMedia로 통합 (3개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------ | :------------------------------ | :--- |
| file-upload | `type: "file"` | |
| image-widget | `type: "image"` | |
| image-display | `type: "image", readonly: true` | |
#### UnifiedList로 통합 (8개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------------------ | :------------ |
| table-list | `viewMode: "table"` | |
| card-display | `viewMode: "card"` | |
| repeater-field-group | `editable: true` | |
| modal-repeater-table | `viewMode: "table", modal: true` | |
| simple-repeater-table | `viewMode: "table", simple: true` | |
| repeat-screen-modal | `viewMode: "card", modal: true` | |
| table-search-widget | `viewMode: "table", searchable: true` | |
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
#### UnifiedLayout으로 통합 (4개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------ | :-------------------------- | :------------- |
| split-panel-layout | `type: "split"` | |
| split-panel-layout2 | `type: "split", version: 2` | |
| divider-line | `type: "divider"` | 속성 추가 필요 |
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
#### UnifiedGroup으로 통합 (5개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :------------------- | :--------------------- | :------------ |
| accordion-basic | `type: "accordion"` | |
| tabs | `type: "tabs"` | |
| section-paper | `type: "section"` | |
| section-card | `type: "card-section"` | |
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
#### UnifiedBiz로 통합 (7개)
| 현재 컴포넌트 | 매핑 속성 | 비고 |
| :-------------------- | :------------------------ | :--------------- |
| flow-widget | `type: "flow"` | 플로우 관리 |
| rack-structure | `type: "rack"` | 창고 렉 구조 |
| map | `type: "map"` | 지도 |
| numbering-rule | `type: "numbering"` | 채번 규칙 |
| category-manager | `type: "category"` | 카테고리 관리 |
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
#### 별도 검토 필요 (3개)
| 현재 컴포넌트 | 문제점 | 제안 |
| :-------------------------- | :------------------- | :------------------------------ |
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
### 8.2 매핑 분석 결과
```
┌─────────────────────────────────────────────────────────┐
│ 전체 44개 컴포넌트 분석 결과 │
├─────────────────────────────────────────────────────────┤
│ ✅ 즉시 통합 가능 : 36개 (82%) │
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
│ 🔄 별도 검토 필요 : 3개 (7%) │
└─────────────────────────────────────────────────────────┘
```
### 8.3 속성 확장 필요 사항
#### UnifiedInput 속성 확장
```typescript
// 기존
type: "text" | "number" | "password";
// 확장
type: "text" | "number" | "password" | "slider" | "color" | "button";
```
#### UnifiedSelect 속성 확장
```typescript
// 기존
mode: "dropdown" | "radio" | "check" | "tag";
// 확장
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
```
#### UnifiedLayout 속성 확장
```typescript
// 기존
type: "grid" | "split" | "flex";
// 확장
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
```
### 8.4 조건부 렌더링 공통화
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
```typescript
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
interface BaseUnifiedProps {
// ... 기존 속성
/** 조건부 렌더링 설정 */
conditional?: {
enabled: boolean;
field: string; // 참조할 필드명
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
value: any; // 비교 값
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
};
}
```
---
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
### 9.1 현재 계층 구조 지원 현황
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
| 타입 | 설명 | 예시 |
| :----------------- | :---------------------- | :--------------- |
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
| **TREE** | 일반 트리 | 카테고리 |
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
```typescript
interface UnifiedHierarchyProps {
/** 계층 유형 */
type: "tree" | "org" | "bom" | "cascading";
/** 표시 방식 */
viewMode: "tree" | "table" | "indent" | "dropdown";
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
source: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 정렬 가능 */
draggable?: boolean;
/** BOM 수량 표시 (BOM 타입 전용) */
showQty?: boolean;
/** 최대 레벨 제한 */
maxLevel?: number;
}
```
### 9.3 활용 예시
| 설정 | 결과 |
| :---------------------------------------- | :------------------------- |
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
| `type: "org", viewMode: "tree"` | 조직도 |
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
---
## 10. 최종 통합 컴포넌트 목록 (10개)
| # | 컴포넌트 | 역할 | 커버 범위 |
| :-: | :------------------- | :------------- | :----------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
---
## 11. 연쇄관계 관리 메뉴 통합 전략
### 11.1 현재 연쇄관계 관리 현황
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
| :--------------- | :--------------------------------------- | :---------: | :----: |
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
### 11.2 통합 방향: 속성 기반 vs 공통 정의
#### 판단 기준
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
| :--------------- | :---------: | :---------: | :----------------------- |
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
### 11.3 속성 통합 설계
#### 2단계 연쇄 → UnifiedSelect 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
source="db"
table="warehouse_location"
valueColumn="location_code"
labelColumn="location_name"
cascading={{
parentField: "warehouse_code", // 같은 화면 내 부모 필드
filterColumn: "warehouse_code", // 필터링할 컬럼
clearOnChange: true // 부모 변경 시 초기화
}}
/>
```
#### 조건부 필터 → 공통 conditional 속성
```typescript
// AS-IS: 별도 관리 메뉴에서 조건 정의
// cascading_condition 테이블에 저장
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
<UnifiedInput
conditional={{
enabled: true,
field: "order_type", // 참조할 필드
operator: "=", // 비교 연산자
value: "EXPORT", // 비교 값
action: "show", // show | hide | disable | enable
}}
/>
```
#### 자동 입력 → autoFill 속성
```typescript
// AS-IS: cascading_auto_fill_group 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedInput
autoFill={{
enabled: true,
sourceTable: "company_mng", // 조회할 테이블
filterColumn: "company_code", // 필터링 컬럼
userField: "companyCode", // 사용자 정보 필드
displayColumn: "company_name", // 표시할 컬럼
}}
/>
```
#### 상호 배제 → mutualExclusion 속성
```typescript
// AS-IS: cascading_mutual_exclusion 테이블에 정의
// TO-BE: 컴포넌트 속성에서 직접 정의
<UnifiedSelect
mutualExclusion={{
enabled: true,
targetField: "sub_category", // 상호 배제 대상 필드
type: "exclusive", // exclusive | inclusive
}}
/>
```
### 11.4 관리 메뉴 정리 계획
| 현재 메뉴 | TO-BE | 비고 |
| :-------------------------- | :----------------------- | :-------------------- |
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
### 11.5 DB 테이블 정리 (Phase 5)
| 테이블 | 조치 | 시점 |
| :--------------------------- | :----------------------- | :------ |
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
| `cascading_hierarchy_*` | **유지** | - |
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
### 11.6 마이그레이션 스크립트 필요 항목
```sql
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
-- 해당 컴포넌트의 cascading 속성으로 변환
-- 예시: WAREHOUSE_LOCATION 연쇄관계
-- 이 관계를 사용하는 화면의 컴포넌트에
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
-- 속성 추가
```
---
## 12. 최종 아키텍처 요약
### 12.1 통합 컴포넌트 (10개)
| # | 컴포넌트 | 역할 |
| :-: | :------------------- | :--------------------------------------- |
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
| 3 | **UnifiedDate** | 날짜/시간 입력 |
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
### 12.2 공통 속성 (모든 컴포넌트에 적용)
```typescript
interface BaseUnifiedProps {
// 기본 속성
id: string;
label?: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
// 스타일
style?: ComponentStyle;
className?: string;
// 조건부 렌더링 (conditional-container 대체)
conditional?: {
enabled: boolean;
field: string;
operator:
| "="
| "!="
| ">"
| "<"
| "in"
| "notIn"
| "isEmpty"
| "isNotEmpty";
value: any;
action: "show" | "hide" | "disable" | "enable";
};
// 자동 입력 (autoFill 대체)
autoFill?: {
enabled: boolean;
sourceTable: string;
filterColumn: string;
userField: "companyCode" | "userId" | "deptCode";
displayColumn: string;
};
// 유효성 검사
validation?: ValidationRule[];
}
```
### 12.3 UnifiedSelect 전용 속성
```typescript
interface UnifiedSelectProps extends BaseUnifiedProps {
// 표시 모드
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
// 데이터 소스
source: "static" | "code" | "db" | "api" | "entity";
// static 소스
options?: Array<{ value: string; label: string }>;
// db 소스
table?: string;
valueColumn?: string;
labelColumn?: string;
// code 소스
codeGroup?: string;
// 연쇄 관계 (cascading_relation 대체)
cascading?: {
parentField: string; // 부모 필드명
filterColumn: string; // 필터링할 컬럼
clearOnChange?: boolean; // 부모 변경 시 초기화
};
// 상호 배제 (mutual_exclusion 대체)
mutualExclusion?: {
enabled: boolean;
targetField: string; // 상호 배제 대상
type: "exclusive" | "inclusive";
};
// 다중 선택
multiple?: boolean;
maxSelect?: number;
}
```
### 12.4 관리 메뉴 정리 결과
| AS-IS | TO-BE |
| :---------------------------- | :----------------------------------- |
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
| - 조건부 필터 | → 공통 conditional 속성 |
| - 자동 입력 | → 공통 autoFill 속성 |
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
---
## 13. 주의사항
> **기존 컴포넌트 삭제 금지**
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
> **연쇄관계 마이그레이션 필수**
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.

View File

@ -1,58 +0,0 @@
# 프로젝트 진행 상황 (2025-11-20)
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
### 1. 핵심 변경 사항
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
### 2. 완료된 작업
#### 데이터베이스
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
- **스키마 변경**:
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
- 기존 하드코딩된 테이블 매핑 컬럼 제거
#### 백엔드 (Node.js)
- **API 추가/수정**:
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
- **컨트롤러 수정**:
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
#### 프론트엔드 (React)
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
- **유틸리티**: `spatialContainment.ts`
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
- `HierarchyConfigPanel` 적용
- 동적 데이터 로드 로직 구현
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
- 객체 이동 시 그룹 이동 적용
### 3. 현재 상태
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
- **DB**: 마이그레이션 스크립트 실행 완료
### 4. 다음 단계 (테스트 필요)
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
2. **배치 검증**:
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
- 위치를 구역 **외부**에 배치 (실패해야 함)
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
### 5. 관련 파일
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
- `backend-node/src/controllers/digitalTwinDataController.ts`
- `backend-node/src/routes/digitalTwinRoutes.ts`
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`

View File

@ -0,0 +1,19 @@
{
"id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e",
"sentAt": "2025-10-22T05:17:38.303Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㅏㅣ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\">\r\n <p><strong>---------- 전달된 메시지 ----------</strong></p>\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T06:36:10.876Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트",
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n",
"sentAt": "2025-10-22T07:49:50.811Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:49:50.811Z",
"deletedAt": "2025-10-22T07:50:14.211Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
"sentAt": "2025-10-22T07:13:30.905Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,29 @@
{
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
"sentAt": "2025-10-13T01:08:34.764Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
"sentAt": "2025-10-02T07:50:25.817Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅣ;ㅏㅓ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
"sentAt": "2025-10-22T07:18:18.240Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,19 @@
{
"id": "375f2326-ca86-468a-bfc3-2d4c3825577b",
"sentAt": "2025-10-22T04:57:39.706Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅏㅣ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<f085efa6-2668-0293-57de-88b1e7009dd1@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:04.666Z"
}

View File

@ -0,0 +1,41 @@
{
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
"sentAt": "2025-10-02T07:44:38.128Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "asd",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "웨이스-임직원-프로파일-이희진.pptx",
"originalName": "웨이스-임직원-프로파일-이희진.pptx",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
"mimetype": "image/jpeg"
}
],
"status": "success",
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,16 @@
{
"id": "386e334a-df76-440c-ae8a-9bf06982fdc8",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" &lt;zian9227@naver.com&gt;</p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
"sentAt": "2025-10-22T07:04:27.192Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:04:57.280Z",
"deletedAt": "2025-10-22T07:50:17.136Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "3d411dc4-69a6-4236-b878-9693dff881be",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:56:51.060Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:56:51.060Z",
"deletedAt": "2025-10-22T07:50:22.989Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "3e30a264-8431-44c7-96ef-eed551e66a11",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\"></p>\n </div>\n ",
"sentAt": "2025-10-22T06:57:53.335Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:00:23.394Z",
"deletedAt": "2025-10-22T07:50:20.510Z"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
{
"id": "4a32bab5-364e-4037-bb00-31d2905824db",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "테스트 마지가",
"htmlContent": "ㅁㄴㅇㄹ",
"sentAt": "2025-10-22T07:49:29.948Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:49:29.948Z",
"deletedAt": "2025-10-22T07:50:12.374Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "5bfb2acd-023a-4865-a738-2900179db5fb",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
"sentAt": "2025-10-22T07:03:09.080Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:03:39.150Z",
"deletedAt": "2025-10-22T07:50:19.035Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "683c1323-1895-403a-bb9a-4e111a8909f6",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:54:55.097Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:54:55.097Z",
"deletedAt": "2025-10-22T07:50:24.672Z"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
{
"id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㅏㅣ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
"sentAt": "2025-10-22T06:41:52.984Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:46:23.051Z",
"deletedAt": "2025-10-22T07:50:29.124Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
"sentAt": "2025-10-22T04:27:51.044Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,13 @@
{
"id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:17:31.379Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:17:31.379Z",
"deletedAt": "2025-10-22T07:50:30.736Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
"sentAt": "2025-10-22T03:49:48.461Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,13 @@
{
"id": "99703f2c-740c-492e-a866-a04289a9b699",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:20:08.450Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:20:08.450Z",
"deletedAt": "2025-10-22T06:36:07.797Z"
}

View File

@ -0,0 +1,19 @@
{
"id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e",
"sentAt": "2025-10-22T04:31:17.175Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:10.245Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㅏㅣ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:50:04.224Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:50:04.224Z",
"deletedAt": "2025-10-22T07:50:26.224Z"
}

View File

@ -0,0 +1,29 @@
{
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
"sentAt": "2025-10-13T00:53:55.193Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "한글.txt",
"originalName": "한글.txt",
"size": 0,
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
"mimetype": "text/plain"
}
],
"status": "success",
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
"sentAt": "2025-10-02T08:22:14.721Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
"sentAt": "2025-10-02T08:41:42.086Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
"sentAt": "2025-10-22T07:21:13.723Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,48 @@
{
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
"sentAt": "2025-10-02T08:57:48.412Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "b293e530-2b2d-4b8a-8081-d103fab5a13f",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: 수신메일확인용",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 13. 오전 10:40:30</p>\n <p><strong>제목:</strong> 수신메일확인용</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
"sentAt": "2025-10-22T06:47:53.815Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:48:53.876Z",
"deletedAt": "2025-10-22T07:50:27.706Z"
}

View File

@ -0,0 +1,41 @@
{
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
"sentAt": "2025-10-02T08:49:30.356Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글2",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
"sentAt": "2025-10-02T08:47:03.481Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트222",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,16 @@
{
"id": "cf892a77-1998-4165-bb9d-b390451465b2",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n",
"sentAt": "2025-10-22T07:06:11.620Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:07:11.749Z",
"deletedAt": "2025-10-22T07:50:15.739Z"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
{
"id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:15:02.128Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:15:02.128Z",
"deletedAt": "2025-10-22T07:08:43.543Z"
}

View File

@ -0,0 +1,27 @@
{
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
"sentAt": "2025-10-22T04:28:42.686Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"권은아\" <chna8137s@gmail.com>"
],
"subject": "Re: 매우 졸린 오후예요",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
"accepted": [
"chna8137s@gmail.com"
],
"rejected": []
}

View File

@ -0,0 +1,16 @@
{
"id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "메일 임시저장 테스트 4",
"htmlContent": "asd",
"sentAt": "2025-10-22T06:21:40.019Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:21:40.019Z",
"deletedAt": "2025-10-22T06:36:05.306Z"
}

View File

@ -0,0 +1,41 @@
{
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
"sentAt": "2025-10-02T08:48:29.740Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글한글",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,29 @@
{
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
"sentAt": "2025-10-13T00:21:51.799Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "test용입니다.",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"templateId": "template-1759302346758",
"templateName": "test",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
"mimetype": "application/x-iwork-keynote-sffkey"
}
],
"status": "success",
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
"sentAt": "2025-10-22T04:24:54.126Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"DHS\" <ddhhss0603@gmail.com>"
],
"subject": "Re: 안녕하세여",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
"accepted": [
"ddhhss0603@gmail.com"
],
"rejected": []
}

View File

@ -0,0 +1,28 @@
{
"id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082",
"sentAt": "2025-10-22T04:29:14.738Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107350246-298369766.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<e68a0501-f79a-8713-a625-e882f711b30d@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:12.907Z"
}

View File

@ -12,15 +12,12 @@
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
@ -42,7 +39,6 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
@ -1044,7 +1040,6 @@
"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",
@ -2261,93 +2256,6 @@
"node": ">= 8"
}
},
"node_modules/@oozcitak/dom": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.3.4"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.3",
"@oozcitak/util": "1.0.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "1.0.1"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@ -2372,7 +2280,6 @@
"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",
@ -3217,16 +3124,6 @@
"@types/node": "*"
}
},
"node_modules/@types/bwip-js": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
@ -3476,7 +3373,6 @@
"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"
}
@ -3713,7 +3609,6 @@
"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",
@ -3931,7 +3826,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4432,12 +4326,6 @@
"node": ">=8"
}
},
"node_modules/browser-split": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -4458,7 +4346,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -4558,15 +4445,6 @@
"node": ">=10.16.0"
}
},
"node_modules/bwip-js": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
"license": "MIT",
"bin": {
"bwip-js": "bin/bwip-js.js"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -4643,15 +4521,6 @@
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001745",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
@ -5333,56 +5202,6 @@
"node": ">=6.0.0"
}
},
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -5397,11 +5216,6 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -5535,27 +5349,6 @@
"node": ">=8.10.0"
}
},
"node_modules/ent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"punycode": "^1.4.1",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ent/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -5568,16 +5361,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
"dependencies": {
"camelize": "^1.0.0",
"string-template": "~0.2.0",
"xtend": "~4.0.0"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5669,7 +5452,6 @@
"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",
@ -5861,14 +5643,6 @@
"node": ">= 0.6"
}
},
"node_modules/ev-store": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
"dependencies": {
"individual": "^3.0.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -6505,16 +6279,6 @@
"node": "*"
}
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@ -6649,16 +6413,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6689,22 +6443,6 @@
"node": ">=16.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -6712,27 +6450,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-docx": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.6",
"@oozcitak/util": "8.3.4",
"color-name": "^1.1.4",
"html-entities": "^2.3.3",
"html-to-vdom": "^0.7.0",
"image-size": "^1.0.0",
"image-to-base64": "^2.2.0",
"jszip": "^3.7.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "^3.1.25",
"virtual-dom": "^2.1.1",
"xmlbuilder2": "2.1.2"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@ -6749,106 +6466,6 @@
"node": ">=14"
}
},
"node_modules/html-to-vdom": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
"license": "ISC",
"dependencies": {
"ent": "^2.0.0",
"htmlparser2": "^3.8.2"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"entities": "^2.0.0"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-vdom/node_modules/domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^3.1.1"
}
},
"node_modules/html-to-vdom/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -6973,30 +6590,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/image-to-base64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.0"
}
},
"node_modules/imap": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
@ -7033,12 +6626,6 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -7086,11 +6673,6 @@
"node": ">=0.8.19"
}
},
"node_modules/individual": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -7272,15 +6854,6 @@
"node": ">=0.12.0"
}
},
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -7432,7 +7005,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8124,18 +7696,6 @@
"npm": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -8252,15 +7812,6 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -8402,6 +7953,7 @@
"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"
},
@ -8625,21 +8177,6 @@
"node": ">=6"
}
},
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@ -8763,24 +8300,6 @@
"node": ">=12"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/native-duplexpair": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@ -8810,12 +8329,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/next-tick": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
"license": "MIT"
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@ -9157,12 +8670,6 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@ -9290,7 +8797,6 @@
"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",
@ -9673,15 +9179,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -10098,23 +9595,6 @@
],
"license": "MIT"
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -10130,17 +9610,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10269,12 +9744,6 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10551,11 +10020,6 @@
"node": ">=10"
}
},
"node_modules/string-template": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10949,7 +10413,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11055,7 +10518,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -11223,22 +10685,6 @@
"node": ">= 0.8"
}
},
"node_modules/virtual-dom": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
"license": "MIT",
"dependencies": {
"browser-split": "0.0.1",
"error": "^4.3.0",
"ev-store": "^7.0.0",
"global": "^4.3.0",
"is-object": "^1.0.1",
"next-tick": "^0.2.2",
"x-is-array": "0.1.0",
"x-is-string": "0.1.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -11416,80 +10862,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/x-is-array": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
},
"node_modules/x-is-string": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder2": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.5",
"@oozcitak/infra": "1.0.5",
"@oozcitak/util": "8.3.3"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -26,15 +26,12 @@
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"bwip-js": "^4.8.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
@ -56,7 +53,6 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bwip-js": "^3.2.3",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",

View File

@ -1,209 +0,0 @@
/**
* DB (DO_DY)
* READ-ONLY: SELECT
*/
import { Pool } from "pg";
import mysql from "mysql2/promise";
import { CredentialEncryption } from "../src/utils/credentialEncryption";
async function testDigitalTwinDb() {
// 내부 DB 연결 (연결 정보 저장용)
const internalPool = new Pool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME || "plm",
user: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD || "ph0909!!",
});
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
try {
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
// 디지털 트윈 외부 DB 연결 정보
const digitalTwinConnection = {
name: "디지털트윈_DO_DY",
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
host: "1.240.13.83",
port: 4307,
databaseName: "DO_DY",
username: "root",
password: "pohangms619!#",
sslEnabled: false,
isActive: true,
};
console.log("📝 연결 정보:");
console.log(` - 이름: ${digitalTwinConnection.name}`);
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
// 1. 외부 DB 직접 연결 테스트
console.log("🔍 외부 DB 직접 연결 테스트 중...");
const externalConnection = await mysql.createConnection({
host: digitalTwinConnection.host,
port: digitalTwinConnection.port,
database: digitalTwinConnection.databaseName,
user: digitalTwinConnection.username,
password: digitalTwinConnection.password,
connectTimeout: 10000,
});
console.log("✅ 외부 DB 연결 성공!\n");
// 2. SELECT 쿼리 실행
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
const query = `
SELECT
SKUMKEY --
, SKUDESC --
, SKUTHIC --
, SKUWIDT --
, SKULENG --
, SKUWEIG --
, STOTQTY --
, SUOMKEY --
FROM DO_DY.WSTKKY
LIMIT 10
`;
const [rows] = await externalConnection.execute(query);
console.log("✅ 쿼리 실행 성공!\n");
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}\n`);
if (Array.isArray(rows) && rows.length > 0) {
console.log("🔍 샘플 데이터 (첫 3건):\n");
rows.slice(0, 3).forEach((row: any, index: number) => {
console.log(`[${index + 1}]`);
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
console.log(` 길이(SKULENG): ${row.SKULENG}`);
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
});
// 전체 데이터 JSON 출력
console.log("📄 전체 데이터 (JSON):");
console.log(JSON.stringify(rows, null, 2));
console.log("\n");
}
await externalConnection.end();
// 3. 내부 DB에 연결 정보 저장
console.log("💾 내부 DB에 연결 정보 저장 중...");
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
// 중복 체크
const existingResult = await internalPool.query(
"SELECT id FROM flow_external_db_connection WHERE name = $1",
[digitalTwinConnection.name]
);
let connectionId: number;
if (existingResult.rows.length > 0) {
connectionId = existingResult.rows[0].id;
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
// 기존 연결 업데이트
await internalPool.query(
`UPDATE flow_external_db_connection
SET description = $1,
db_type = $2,
host = $3,
port = $4,
database_name = $5,
username = $6,
password_encrypted = $7,
ssl_enabled = $8,
is_active = $9,
updated_at = NOW(),
updated_by = 'system'
WHERE name = $10`,
[
digitalTwinConnection.description,
digitalTwinConnection.dbType,
digitalTwinConnection.host,
digitalTwinConnection.port,
digitalTwinConnection.databaseName,
digitalTwinConnection.username,
encryptedPassword,
digitalTwinConnection.sslEnabled,
digitalTwinConnection.isActive,
digitalTwinConnection.name,
]
);
console.log(`✅ 연결 정보 업데이트 완료`);
} else {
// 새 연결 추가
const result = await internalPool.query(
`INSERT INTO flow_external_db_connection (
name,
description,
db_type,
host,
port,
database_name,
username,
password_encrypted,
ssl_enabled,
is_active,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
RETURNING id`,
[
digitalTwinConnection.name,
digitalTwinConnection.description,
digitalTwinConnection.dbType,
digitalTwinConnection.host,
digitalTwinConnection.port,
digitalTwinConnection.databaseName,
digitalTwinConnection.username,
encryptedPassword,
digitalTwinConnection.sslEnabled,
digitalTwinConnection.isActive,
]
);
connectionId = result.rows[0].id;
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
}
console.log("\n✅ 모든 테스트 완료!");
console.log(`\n📌 연결 ID: ${connectionId}`);
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
} catch (error: any) {
console.error("\n❌ 오류 발생:", error.message);
console.error("상세 정보:", error);
throw error;
} finally {
await internalPool.end();
}
}
// 스크립트 실행
testDigitalTwinDb()
.then(() => {
console.log("\n🎉 스크립트 완료");
process.exit(0);
})
.catch((error) => {
console.error("\n💥 스크립트 실패:", error);
process.exit(1);
});

View File

@ -8,7 +8,6 @@ import path from "path";
import config from "./config/environment";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
@ -58,10 +57,8 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
@ -71,18 +68,6 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -177,10 +162,6 @@ const limiter = rateLimit({
});
app.use("/api/", limiter);
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트
app.get("/health", (req, res) => {
res.status(200).json({
@ -198,7 +179,6 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
@ -223,7 +203,6 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
@ -240,9 +219,8 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
@ -252,17 +230,6 @@ app.use("/api/departments", departmentRoutes); // 부서 관리
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/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@ -307,7 +274,7 @@ app.listen(PORT, HOST, async () => {
// 배치 스케줄러 초기화
try {
await BatchSchedulerService.initializeScheduler();
await BatchSchedulerService.initialize();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);

View File

@ -1,7 +1,4 @@
import { Response } from "express";
import https from "https";
import axios, { AxiosRequestConfig } from "axios";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService";
import {
@ -10,7 +7,6 @@ import {
DashboardListQuery,
} from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService";
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/**
*
@ -45,7 +41,6 @@ export class DashboardController {
isPublic = false,
tags,
category,
settings,
}: CreateDashboardRequest = req.body;
// 유효성 검증
@ -90,7 +85,6 @@ export class DashboardController {
elements,
tags,
category,
settings,
};
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
@ -419,7 +413,7 @@ export class DashboardController {
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string,
category: req.query.category as string,
// createdBy 제거 - 회사 대시보드 전체 표시
createdBy: userId, // 본인이 만든 대시보드만
};
const result = await DashboardService.getDashboards(
@ -594,14 +588,7 @@ export class DashboardController {
res: Response
): Promise<void> {
try {
const {
url,
method = "GET",
headers = {},
queryParams = {},
body,
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
} = req.body;
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({
@ -619,175 +606,85 @@ export class DashboardController {
}
});
// Axios 요청 설정
const requestConfig: AxiosRequestConfig = {
url: urlObj.toString(),
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
timeout: 60000, // 60초 타임아웃
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
};
// 연결 정보 (응답에 포함용)
let connectionInfo: { saveToHistory?: boolean } | null = null;
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
if (externalConnectionId) {
try {
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
let companyCode = req.user?.companyCode;
if (!companyCode) {
companyCode = "*";
}
// 커넥션 로드
const connectionResult =
await ExternalRestApiConnectionService.getConnectionById(
Number(externalConnectionId),
companyCode
);
if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data;
// 연결 정보 저장 (응답에 포함)
connectionInfo = {
saveToHistory: connection.save_to_history === "Y",
};
// 인증 헤더 생성 (DB 토큰 등)
const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type,
connection.auth_config,
connection.company_code
);
// 기존 헤더에 인증 헤더 병합
requestConfig.headers = {
...requestConfig.headers,
...authHeaders,
};
// API Key가 Query Param인 경우 처리
if (
connection.auth_type === "api-key" &&
connection.auth_config?.keyLocation === "query" &&
connection.auth_config?.keyName &&
connection.auth_config?.keyValue
) {
const currentUrl = new URL(requestConfig.url!);
currentUrl.searchParams.append(
connection.auth_config.keyName,
connection.auth_config.keyValue
);
requestConfig.url = currentUrl.toString();
}
}
} catch (connError) {
logger.error(
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
connError
);
}
}
// Body 처리
if (body) {
requestConfig.data = body;
}
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];
const hostname = urlObj.hostname;
const shouldBypassTls = bypassDomains.some((domain) =>
hostname.includes(domain)
);
if (shouldBypassTls) {
requestConfig.httpsAgent = new https.Agent({
rejectUnauthorized: false,
// 외부 API 호출 (타임아웃 30초)
// @ts-ignore - node-fetch dynamic import
const fetch = (await import("node-fetch")).default;
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
const controller = new (global as any).AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
let response;
try {
response = await fetch(urlObj.toString(), {
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
...headers,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (err: any) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
}
throw err;
}
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
if (isKmaApi) {
requestConfig.responseType = "arraybuffer";
}
const response = await axios(requestConfig);
if (response.status >= 400) {
if (!response.ok) {
throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}`
);
}
let data = response.data;
const contentType = response.headers["content-type"];
// Content-Type에 따라 응답 파싱
const contentType = response.headers.get("content-type");
let data: any;
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
if (isKmaApi && Buffer.isBuffer(data)) {
const iconv = require("iconv-lite");
const buffer = Buffer.from(data);
const utf8Text = buffer.toString("utf-8");
// UTF-8로 정상 디코딩되었는지 확인
if (
utf8Text.includes("특보") ||
utf8Text.includes("경보") ||
utf8Text.includes("주의보") ||
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
) {
data = { text: utf8Text, contentType, encoding: "utf-8" };
} else {
// EUC-KR로 디코딩
const eucKrText = iconv.decode(buffer, "EUC-KR");
data = { text: eucKrText, contentType, encoding: "euc-kr" };
// 한글 인코딩 처리 (EUC-KR → UTF-8)
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
urlObj.hostname.includes('data.go.kr');
if (isKoreanApi) {
// 한국 정부 API는 EUC-KR 인코딩 사용
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder('euc-kr');
const text = decoder.decode(buffer);
try {
data = JSON.parse(text);
} catch {
data = { text, contentType };
}
} else if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else if (contentType && contentType.includes("text/")) {
// 텍스트 응답 (CSV, 일반 텍스트 등)
const text = await response.text();
data = { text, contentType };
} else {
// 기타 응답 (JSON으로 시도)
try {
data = await response.json();
} catch {
const text = await response.text();
data = { text, contentType };
}
}
// 텍스트 응답인 경우 포맷팅
else if (typeof data === "string") {
data = { text: data, contentType };
}
res.status(200).json({
success: true,
data,
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
});
} catch (error: any) {
const status = error.response?.status || 500;
const message = error.response?.statusText || error.message;
logger.error("외부 API 호출 오류:", {
message,
status,
data: error.response?.data,
});
} catch (error) {
res.status(500).json({
success: false,
message: "외부 API 호출 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
? message
? (error as Error).message
: "외부 API 호출 오류",
});
}

File diff suppressed because it is too large Load Diff

View File

@ -141,110 +141,6 @@ export class AuthController {
}
}
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
static async switchCompany(req: Request, res: Response): Promise<void> {
try {
const { companyCode } = req.body;
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
res.status(401).json({
success: false,
message: "인증 토큰이 필요합니다.",
error: { code: "TOKEN_MISSING" },
});
return;
}
// 현재 사용자 정보 확인
const currentUser = JwtUtils.verifyToken(token);
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
if (currentUser.userType !== "SUPER_ADMIN") {
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
res.status(403).json({
success: false,
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
error: { code: "FORBIDDEN" },
});
return;
}
// 전환할 회사 코드 검증
if (!companyCode || companyCode.trim() === "") {
res.status(400).json({
success: false,
message: "전환할 회사 코드가 필요합니다.",
error: { code: "INVALID_INPUT" },
});
return;
}
logger.info(`=== WACE 관리자 회사 전환 ===`, {
userId: currentUser.userId,
originalCompanyCode: currentUser.companyCode,
targetCompanyCode: companyCode,
});
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
if (companyCode !== "*") {
const { query } = await import("../database/db");
const companies = await query<any>(
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
[companyCode]
);
if (companies.length === 0) {
res.status(404).json({
success: false,
message: "존재하지 않는 회사 코드입니다.",
error: { code: "COMPANY_NOT_FOUND" },
});
return;
}
}
// 새로운 JWT 토큰 발급 (company_code만 변경)
const newPersonBean: PersonBean = {
...currentUser,
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
};
const newToken = JwtUtils.generateToken(newPersonBean);
logger.info(`✅ 회사 전환 성공: ${currentUser.userId}${companyCode}`);
res.status(200).json({
success: true,
message: "회사 전환 완료",
data: {
token: newToken,
companyCode: companyCode.trim(),
},
});
} catch (error) {
logger.error(
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
);
res.status(500).json({
success: false,
message: "회사 전환 중 오류가 발생했습니다.",
error: {
code: "SERVER_ERROR",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/auth/logout
* Java ApiLoginController.logout()
@ -330,14 +226,13 @@ export class AuthController {
}
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
const userInfoResponse: any = {
userId: dbUserInfo.userId,
userName: dbUserInfo.userName || "",
deptName: dbUserInfo.deptName || "",
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
companyCode: dbUserInfo.companyCode || "ILSHIN",
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
userType: dbUserInfo.userType || "USER",
userTypeName: dbUserInfo.userTypeName || "일반사용자",
email: dbUserInfo.email || "",
photo: dbUserInfo.photo,
@ -489,69 +384,4 @@ export class AuthController {
});
}
}
/**
* POST /api/auth/signup
* API
*/
static async signup(req: Request, res: Response): Promise<void> {
try {
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
// 입력값 검증
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
res.status(400).json({
success: false,
message: "필수 입력값이 누락되었습니다.",
error: {
code: "INVALID_INPUT",
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
},
});
return;
}
// 회원가입 처리
const signupResult = await AuthService.signupDriver({
userId,
password,
userName,
phoneNumber,
licenseNumber,
vehicleNumber,
vehicleType,
});
if (signupResult.success) {
logger.info(`공차중계 회원가입 성공: ${userId}`);
res.status(201).json({
success: true,
message: "회원가입이 완료되었습니다.",
});
} else {
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
res.status(400).json({
success: false,
message: signupResult.message || "회원가입에 실패했습니다.",
error: {
code: "SIGNUP_FAILED",
details: signupResult.message,
},
});
}
} catch (error) {
logger.error("공차중계 회원가입 API 오류:", error);
res.status(500).json({
success: false,
message: "회원가입 처리 중 오류가 발생했습니다.",
error: {
code: "SIGNUP_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
});
}
}
}

View File

@ -4,7 +4,6 @@
import { Request, Response } from "express";
import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import {
BatchConfigFilter,
CreateBatchConfigRequest,
@ -64,7 +63,7 @@ export class BatchController {
res: Response
) {
try {
const result = await BatchExternalDbService.getAvailableConnections();
const result = await BatchService.getAvailableConnections();
if (result.success) {
res.json(result);
@ -100,8 +99,8 @@ export class BatchController {
}
const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getTables(
type as "internal" | "external",
const result = await BatchService.getTablesFromConnection(
type,
connectionId
);
@ -143,10 +142,10 @@ export class BatchController {
}
const connectionId = type === "external" ? Number(id) : undefined;
const result = await BatchService.getColumns(
tableName,
type as "internal" | "external",
connectionId
const result = await BatchService.getTableColumns(
type,
connectionId,
tableName
);
if (result.success) {
@ -170,18 +169,22 @@ export class BatchController {
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const result = await BatchService.getBatchConfigById(Number(id));
const userCompanyCode = req.user?.companyCode;
const batchConfig = await BatchService.getBatchConfigById(
Number(id),
userCompanyCode
);
if (!result.success || !result.data) {
if (!batchConfig) {
return res.status(404).json({
success: false,
message: result.message || "배치 설정을 찾을 수 없습니다.",
message: "배치 설정을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.data,
data: batchConfig,
});
} catch (error) {
console.error("배치 설정 조회 오류:", error);

View File

@ -62,11 +62,6 @@ export class BatchExecutionLogController {
try {
const data: CreateBatchExecutionLogRequest = req.body;
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
if (!data.company_code) {
data.company_code = req.user?.companyCode || "*";
}
const result = await BatchExecutionLogService.createExecutionLog(data);
if (result.success) {

View File

@ -1,7 +1,7 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import {
BatchManagementService,
@ -13,7 +13,6 @@ import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
import { query } from "../database/db";
export class BatchManagementController {
/**
@ -266,12 +265,8 @@ export class BatchManagementController {
try {
// 실행 로그 생성
const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
const logResult = await BatchExecutionLogService.createExecutionLog({
executionLog = await BatchService.createExecutionLog({
batch_config_id: Number(id),
company_code: batchConfig.company_code,
execution_status: "RUNNING",
start_time: startTime,
total_records: 0,
@ -279,14 +274,6 @@ export class BatchManagementController {
failed_records: 0,
});
if (!logResult.success || !logResult.data) {
throw new Error(
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
);
}
executionLog = logResult.data;
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
const { BatchSchedulerService } = await import(
"../services/batchSchedulerService"
@ -303,7 +290,7 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime();
// 실행 로그 업데이트 (성공)
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
await BatchService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
end_time: endTime,
duration_ms: duration,
@ -332,11 +319,8 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime();
// executionLog가 정의되어 있는지 확인
if (typeof executionLog !== "undefined" && executionLog) {
const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
if (typeof executionLog !== "undefined") {
await BatchService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
end_time: endTime,
duration_ms: duration,
@ -422,70 +406,22 @@ export class BatchManagementController {
paramName,
paramValue,
paramSource,
requestBody,
authServiceName, // DB에서 토큰 가져올 서비스명
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
} = req.body;
// apiUrl, endpoint는 항상 필수
if (!apiUrl || !endpoint) {
if (!apiUrl || !apiKey || !endpoint) {
return res.status(400).json({
success: false,
message: "API URL 엔드포인트는 필수입니다.",
message: "API URL, API Key, 엔드포인트는 필수입니다.",
});
}
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
let finalApiKey = apiKey || "";
if (authServiceName) {
const companyCode = req.user?.companyCode;
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
let tokenQuery: string;
let tokenParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName];
} else {
// 일반 회사: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [authServiceName, companyCode];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
finalApiKey = tokenResult[0].access_token;
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
} else {
return res.status(400).json({
success: false,
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
});
}
}
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
console.log("REST API 미리보기 요청:", {
console.log("🔍 REST API 미리보기 요청:", {
apiUrl,
endpoint,
method,
paramType,
paramName,
paramValue,
paramSource,
requestBody: requestBody ? "Included" : "None",
authServiceName: authServiceName || "직접 입력",
dataArrayPath: dataArrayPath || "전체 응답",
});
// RestApiConnector 사용하여 데이터 조회
@ -493,7 +429,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: finalApiKey,
apiKey: apiKey,
timeout: 30000,
});
@ -520,78 +456,17 @@ export class BatchManagementController {
console.log("🔗 최종 엔드포인트:", finalEndpoint);
// Request Body 파싱
let parsedBody = undefined;
if (requestBody && typeof requestBody === "string") {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
console.warn("Request Body JSON 파싱 실패:", e);
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
// 여기서는 경고 로그 남기고 진행
}
} else if (requestBody) {
parsedBody = requestBody;
}
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
const result = await connector.executeRequest(
finalEndpoint,
method as "GET" | "POST" | "PUT" | "DELETE",
parsedBody
);
console.log(`[previewRestApiData] executeRequest 결과:`, {
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
const result = await connector.executeQuery(finalEndpoint, method);
console.log(`[previewRestApiData] executeQuery 결과:`, {
rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : "undefined",
firstRow:
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
});
// 데이터 배열 추출 헬퍼 함수
const getValueByPath = (obj: any, path: string): any => {
if (!path) return obj;
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
current = current[key];
}
return current;
};
// dataArrayPath가 있으면 해당 경로에서 배열 추출
let extractedData: any[] = [];
if (dataArrayPath) {
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
const arrayData = getValueByPath(rawData, dataArrayPath);
if (Array.isArray(arrayData)) {
extractedData = arrayData;
console.log(
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
);
} else {
console.warn(
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
typeof arrayData
);
// 배열이 아니면 단일 객체로 처리
if (arrayData) {
extractedData = [arrayData];
}
}
} else {
// dataArrayPath가 없으면 기존 로직 사용
extractedData = result.rows;
}
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
console.log(
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
data
);
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
if (data.length > 0) {
// 첫 번째 객체에서 필드명 추출
@ -603,9 +478,9 @@ export class BatchManagementController {
data: {
fields: fields,
samples: data,
totalCount: extractedData.length,
totalCount: result.rowCount || data.length,
},
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
});
} else {
return res.json({
@ -633,17 +508,8 @@ export class BatchManagementController {
*/
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
try {
const {
batchName,
batchType,
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
} = req.body;
const { batchName, batchType, cronSchedule, description, apiMappings } =
req.body;
if (
!batchName ||
@ -664,36 +530,22 @@ export class BatchManagementController {
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
});
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
// BatchService를 사용하여 배치 설정 저장
const batchConfig: CreateBatchConfigRequest = {
batchName: batchName,
description: description || "",
cronSchedule: cronSchedule,
isActive: "Y",
companyCode,
authServiceName: authServiceName || undefined,
dataArrayPath: dataArrayPath || undefined,
saveMode: saveMode || "INSERT",
conflictKey: conflictKey || undefined,
mappings: apiMappings,
};
const result = await BatchService.createBatchConfig(batchConfig, userId);
const result = await BatchService.createBatchConfig(batchConfig);
if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅
try {
await BatchSchedulerService.scheduleBatch(result.data);
await BatchSchedulerService.scheduleBatchConfig(result.data);
console.log(
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
);
@ -721,51 +573,4 @@ export class BatchManagementController {
});
}
}
/**
*
*/
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
// 멀티테넌시: company_code 필터링
let queryText: string;
let queryParams: any[] = [];
if (companyCode === "*") {
// 최고 관리자: 모든 서비스 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
ORDER BY service_name`;
} else {
// 일반 회사: 자신의 회사 서비스만 조회
queryText = `SELECT DISTINCT service_name
FROM auth_tokens
WHERE service_name IS NOT NULL
AND company_code = $1
ORDER BY service_name`;
queryParams = [companyCode];
}
const result = await query<{ service_name: string }>(
queryText,
queryParams
);
const serviceNames = result.map((row) => row.service_name);
return res.json({
success: true,
data: serviceNames,
});
} catch (error) {
console.error("인증 서비스 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -1,606 +0,0 @@
/**
* (Auto-Fill)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 자동 입력 그룹 CRUD
// =====================================================
/**
*
*/
export const getAutoFillGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT
g.*,
COUNT(m.mapping_id) as mapping_count
FROM cascading_auto_fill_group g
LEFT JOIN cascading_auto_fill_mapping m
ON g.group_code = m.group_code AND g.company_code = m.company_code
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("자동 입력 그룹 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* ( )
*/
export const getAutoFillGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `
SELECT * FROM cascading_auto_fill_group
WHERE group_code = $1
`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const groupResult = await queryOne(groupSql, groupParams);
if (!groupResult) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order, mapping_id
`;
const mappingResult = await query(mappingSql, [
groupCode,
groupResult.company_code,
]);
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...groupResult,
mappings: mappingResult,
},
});
} catch (error: any) {
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateAutoFillGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "AF";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
mappings = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !masterTable || !masterValueColumn) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateAutoFillGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
INSERT INTO cascading_auto_fill_group (
group_code, group_name, description,
master_table, master_value_column, master_label_column,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const groupResult = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
masterTable,
masterValueColumn,
masterLabelColumn || null,
companyCode,
]);
// 매핑 생성
if (mappings.length > 0) {
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
companyCode,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
res.status(201).json({
success: true,
message: "자동 입력 그룹이 생성되었습니다.",
data: groupResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
mappings,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 그룹 업데이트
const updateSql = `
UPDATE cascading_auto_fill_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
master_table = COALESCE($3, master_table),
master_value_column = COALESCE($4, master_value_column),
master_label_column = COALESCE($5, master_label_column),
is_active = COALESCE($6, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $7 AND company_code = $8
RETURNING *
`;
const updateResult = await queryOne(updateSql, [
groupName,
description,
masterTable,
masterValueColumn,
masterLabelColumn,
isActive,
groupCode,
existing.company_code,
]);
// 매핑 업데이트 (전체 교체 방식)
if (mappings !== undefined) {
// 기존 매핑 삭제
await query(
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
[groupCode, existing.company_code]
);
// 새 매핑 추가
for (let i = 0; i < mappings.length; i++) {
const m = mappings[i];
await query(
`INSERT INTO cascading_auto_fill_mapping (
group_code, company_code, source_column, target_field, target_label,
is_editable, is_required, default_value, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
groupCode,
existing.company_code,
m.sourceColumn,
m.targetField,
m.targetLabel || null,
m.isEditable || "Y",
m.isRequired || "N",
m.defaultValue || null,
m.sortOrder || i + 1,
]
);
}
}
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 수정되었습니다.",
data: updateResult,
});
} catch (error: any) {
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
const deleteParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING group_code`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
res.json({
success: true,
message: "자동 입력 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 자동 입력 데이터 조회 (실제 사용)
// =====================================================
/**
*
*
*/
export const getAutoFillMasterOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 마스터 테이블에서 옵션 조회
const labelColumn = group.master_label_column || group.master_value_column;
let optionsSql = `
SELECT
${group.master_value_column} as value,
${labelColumn} as label
FROM ${group.master_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
if (companyCode !== "*") {
// company_code 컬럼 존재 여부 확인
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${paramIndex++}`;
optionsParams.push(companyCode);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("자동 입력 마스터 옵션 조회", {
groupCode,
count: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*
*/
export const getAutoFillData = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const { masterValue } = req.query;
const companyCode = req.user?.companyCode || "*";
if (!masterValue) {
return res.status(400).json({
success: false,
message: "masterValue 파라미터가 필요합니다.",
});
}
// 그룹 정보 조회
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "자동 입력 그룹을 찾을 수 없습니다.",
});
}
// 매핑 정보 조회
const mappingSql = `
SELECT * FROM cascading_auto_fill_mapping
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order
`;
const mappings = await query(mappingSql, [groupCode, group.company_code]);
if (mappings.length === 0) {
return res.json({
success: true,
data: {},
mappings: [],
});
}
// 마스터 테이블에서 데이터 조회
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
let dataSql = `
SELECT ${sourceColumns}
FROM ${group.master_table}
WHERE ${group.master_value_column} = $1
`;
const dataParams: any[] = [masterValue];
let paramIndex = 2;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[group.master_table]
);
if (columnCheck) {
dataSql += ` AND company_code = $${paramIndex++}`;
dataParams.push(companyCode);
}
}
const dataResult = await queryOne(dataSql, dataParams);
// 결과를 target_field 기준으로 변환
const autoFillData: Record<string, any> = {};
const mappingInfo: any[] = [];
for (const mapping of mappings) {
const sourceValue = dataResult?.[mapping.source_column];
const finalValue =
sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
autoFillData[mapping.target_field] = finalValue;
mappingInfo.push({
targetField: mapping.target_field,
targetLabel: mapping.target_label,
value: finalValue,
isEditable: mapping.is_editable === "Y",
isRequired: mapping.is_required === "Y",
});
}
logger.info("자동 입력 데이터 조회", {
groupCode,
masterValue,
fieldCount: mappingInfo.length,
});
res.json({
success: true,
data: autoFillData,
mappings: mappingInfo,
});
} catch (error: any) {
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "자동 입력 데이터 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -1,562 +0,0 @@
/**
* (Conditional Cascading)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 조건부 연쇄 규칙 CRUD
// =====================================================
/**
*
*/
export const getConditions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, relationCode, relationType } = req.query;
let sql = `
SELECT * FROM cascading_condition
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND is_active = $${paramIndex++}`;
params.push(isActive);
}
// 관계 코드 필터
if (relationCode) {
sql += ` AND relation_code = $${paramIndex++}`;
params.push(relationCode);
}
// 관계 유형 필터 (RELATION / HIERARCHY)
if (relationType) {
sql += ` AND relation_type = $${paramIndex++}`;
params.push(relationType);
}
sql += ` ORDER BY relation_code, priority, condition_name`;
const result = await query(sql, params);
logger.info("조건부 연쇄 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getConditionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const params: any[] = [Number(conditionId)];
if (companyCode !== "*") {
sql += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await queryOne(sql, params);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const createCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
relationType = "RELATION",
relationCode,
conditionName,
conditionField,
conditionOperator = "EQ",
conditionValue,
filterColumn,
filterValues,
priority = 0,
} = req.body;
// 필수 필드 검증
if (
!relationCode ||
!conditionName ||
!conditionField ||
!conditionValue ||
!filterColumn ||
!filterValues
) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
});
}
const insertSql = `
INSERT INTO cascading_condition (
relation_type, relation_code, condition_name,
condition_field, condition_operator, condition_value,
filter_column, filter_values, priority,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
relationType,
relationCode,
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
companyCode,
]);
logger.info("조건부 연쇄 규칙 생성", {
conditionId: result?.condition_id,
relationCode,
companyCode,
});
res.status(201).json({
success: true,
message: "조건부 연쇄 규칙이 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
const checkParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_condition SET
condition_name = COALESCE($1, condition_name),
condition_field = COALESCE($2, condition_field),
condition_operator = COALESCE($3, condition_operator),
condition_value = COALESCE($4, condition_value),
filter_column = COALESCE($5, filter_column),
filter_values = COALESCE($6, filter_values),
priority = COALESCE($7, priority),
is_active = COALESCE($8, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE condition_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
conditionName,
conditionField,
conditionOperator,
conditionValue,
filterColumn,
filterValues,
priority,
isActive,
Number(conditionId),
]);
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
const deleteParams: any[] = [Number(conditionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING condition_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
});
}
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
res.json({
success: true,
message: "조건부 연쇄 규칙이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 조건부 필터링 적용 API (실제 사용)
// =====================================================
/**
*
*
*/
export const getFilteredOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { relationCode } = req.params;
const { conditionFieldValue, parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 1. 기본 연쇄 관계 정보 조회
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
const relationParams: any[] = [relationCode];
if (companyCode !== "*") {
relationSql += ` AND company_code = $2`;
relationParams.push(companyCode);
}
const relation = await queryOne(relationSql, relationParams);
if (!relation) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 2. 해당 관계에 적용되는 조건 규칙 조회
let conditionSql = `
SELECT * FROM cascading_condition
WHERE relation_code = $1 AND is_active = 'Y'
`;
const conditionParams: any[] = [relationCode];
let conditionParamIndex = 2;
if (companyCode !== "*") {
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
conditionParams.push(companyCode);
}
conditionSql += ` ORDER BY priority DESC`;
const conditions = await query(conditionSql, conditionParams);
// 3. 조건에 맞는 규칙 찾기
let matchedCondition: any = null;
if (conditionFieldValue) {
for (const cond of conditions) {
const isMatch = evaluateCondition(
conditionFieldValue as string,
cond.condition_operator,
cond.condition_value
);
if (isMatch) {
matchedCondition = cond;
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
}
}
}
// 4. 옵션 조회 쿼리 생성
let optionsSql = `
SELECT
${relation.child_value_column} as value,
${relation.child_label_column} as label
FROM ${relation.child_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (기본 연쇄)
if (parentValue) {
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 조건부 필터 적용
if (matchedCondition) {
const filterValues = matchedCondition.filter_values
.split(",")
.map((v: string) => v.trim());
const placeholders = filterValues
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
optionsParams.push(...filterValues);
optionsParamIndex += filterValues.length;
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.child_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (relation.child_order_column) {
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${relation.child_label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("조건부 필터링 옵션 조회", {
relationCode,
conditionFieldValue,
parentValue,
matchedCondition: matchedCondition?.condition_name,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
appliedCondition: matchedCondition
? {
conditionId: matchedCondition.condition_id,
conditionName: matchedCondition.condition_name,
}
: null,
});
} catch (error: any) {
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "조건부 필터링 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
function evaluateCondition(
actualValue: string,
operator: string,
expectedValue: string
): boolean {
const actual = actualValue.toLowerCase().trim();
const expected = expectedValue.toLowerCase().trim();
switch (operator.toUpperCase()) {
case "EQ":
case "=":
case "EQUALS":
return actual === expected;
case "NEQ":
case "!=":
case "<>":
case "NOT_EQUALS":
return actual !== expected;
case "CONTAINS":
case "LIKE":
return actual.includes(expected);
case "NOT_CONTAINS":
case "NOT_LIKE":
return !actual.includes(expected);
case "STARTS_WITH":
return actual.startsWith(expected);
case "ENDS_WITH":
return actual.endsWith(expected);
case "IN":
const inValues = expected.split(",").map((v) => v.trim());
return inValues.includes(actual);
case "NOT_IN":
const notInValues = expected.split(",").map((v) => v.trim());
return !notInValues.includes(actual);
case "GT":
case ">":
return parseFloat(actual) > parseFloat(expected);
case "GTE":
case ">=":
return parseFloat(actual) >= parseFloat(expected);
case "LT":
case "<":
return parseFloat(actual) < parseFloat(expected);
case "LTE":
case "<=":
return parseFloat(actual) <= parseFloat(expected);
case "IS_NULL":
case "NULL":
return actual === "" || actual === "null" || actual === "undefined";
case "IS_NOT_NULL":
case "NOT_NULL":
return actual !== "" && actual !== "null" && actual !== "undefined";
default:
logger.warn(`알 수 없는 연산자: ${operator}`);
return false;
}
}

View File

@ -1,772 +0,0 @@
/**
* (Hierarchy)
* > > /
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 계층 그룹 CRUD
// =====================================================
/**
*
*/
export const getHierarchyGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, hierarchyType } = req.query;
let sql = `
SELECT g.*,
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
FROM cascading_hierarchy_group g
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*") {
sql += ` AND g.company_code = $${paramIndex++}`;
params.push(companyCode);
}
if (isActive) {
sql += ` AND g.is_active = $${paramIndex++}`;
params.push(isActive);
}
if (hierarchyType) {
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
params.push(hierarchyType);
}
sql += ` ORDER BY g.group_name`;
const result = await query(sql, params);
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
* ( )
*/
export const getHierarchyGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 그룹 조회
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
groupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
const group = await queryOne(groupSql, groupParams);
if (!group) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
// 레벨 조회
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
levelSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
levelSql += ` ORDER BY level_order`;
const levels = await query(levelSql, levelParams);
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
res.json({
success: true,
data: {
...group,
levels: levels,
},
});
} catch (error: any) {
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateHierarchyGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "HG";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
hierarchyType = "MULTI_TABLE",
maxLevels,
isFixedLevels = "Y",
// Self-reference 설정
selfRefTable,
selfRefIdColumn,
selfRefParentColumn,
selfRefValueColumn,
selfRefLabelColumn,
selfRefLevelColumn,
selfRefOrderColumn,
// BOM 설정
bomTable,
bomParentColumn,
bomChildColumn,
bomItemTable,
bomItemIdColumn,
bomItemLabelColumn,
bomQtyColumn,
bomLevelColumn,
// 메시지
emptyMessage,
noOptionsMessage,
loadingMessage,
// 레벨 (MULTI_TABLE 타입인 경우)
levels = [],
} = req.body;
// 필수 필드 검증
if (!groupName || !hierarchyType) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
});
}
// 그룹 코드 자동 생성
const groupCode = await generateHierarchyGroupCode(companyCode);
// 그룹 생성
const insertGroupSql = `
INSERT INTO cascading_hierarchy_group (
group_code, group_name, description, hierarchy_type,
max_levels, is_fixed_levels,
self_ref_table, self_ref_id_column, self_ref_parent_column,
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
bom_table, bom_parent_column, bom_child_column,
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
empty_message, no_options_message, loading_message,
company_code, is_active, created_by, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
RETURNING *
`;
const group = await queryOne(insertGroupSql, [
groupCode,
groupName,
description || null,
hierarchyType,
maxLevels || null,
isFixedLevels,
selfRefTable || null,
selfRefIdColumn || null,
selfRefParentColumn || null,
selfRefValueColumn || null,
selfRefLabelColumn || null,
selfRefLevelColumn || null,
selfRefOrderColumn || null,
bomTable || null,
bomParentColumn || null,
bomChildColumn || null,
bomItemTable || null,
bomItemIdColumn || null,
bomItemLabelColumn || null,
bomQtyColumn || null,
bomLevelColumn || null,
emptyMessage || "선택해주세요",
noOptionsMessage || "옵션이 없습니다",
loadingMessage || "로딩 중...",
companyCode,
userId,
]);
// 레벨 생성 (MULTI_TABLE 타입인 경우)
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
for (const level of levels) {
await query(
`INSERT INTO cascading_hierarchy_level (
group_code, company_code, level_order, level_name, level_code,
table_name, value_column, label_column, parent_key_column,
filter_column, filter_value, order_column, order_direction,
placeholder, is_required, is_searchable, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
[
groupCode,
companyCode,
level.levelOrder,
level.levelName,
level.levelCode || null,
level.tableName,
level.valueColumn,
level.labelColumn,
level.parentKeyColumn || null,
level.filterColumn || null,
level.filterValue || null,
level.orderColumn || null,
level.orderDirection || "ASC",
level.placeholder || `${level.levelName} 선택`,
level.isRequired || "Y",
level.isSearchable || "N",
]
);
}
}
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
res.status(201).json({
success: true,
message: "계층 그룹이 생성되었습니다.",
data: group,
});
} catch (error: any) {
logger.error("계층 그룹 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
} = req.body;
// 기존 그룹 확인
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
const checkParams: any[] = [groupCode];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_hierarchy_group SET
group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
max_levels = COALESCE($3, max_levels),
is_fixed_levels = COALESCE($4, is_fixed_levels),
empty_message = COALESCE($5, empty_message),
no_options_message = COALESCE($6, no_options_message),
loading_message = COALESCE($7, loading_message),
is_active = COALESCE($8, is_active),
updated_by = $9,
updated_date = CURRENT_TIMESTAMP
WHERE group_code = $10 AND company_code = $11
RETURNING *
`;
const result = await queryOne(updateSql, [
groupName,
description,
maxLevels,
isFixedLevels,
emptyMessage,
noOptionsMessage,
loadingMessage,
isActive,
userId,
groupCode,
existing.company_code,
]);
logger.info("계층 그룹 수정", { groupCode, companyCode });
res.json({
success: true,
message: "계층 그룹이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 그룹 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
// 레벨 먼저 삭제
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
const levelParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteLevelsSql += ` AND company_code = $2`;
levelParams.push(companyCode);
}
await query(deleteLevelsSql, levelParams);
// 그룹 삭제
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
const groupParams: any[] = [groupCode];
if (companyCode !== "*") {
deleteGroupSql += ` AND company_code = $2`;
groupParams.push(companyCode);
}
deleteGroupSql += ` RETURNING group_code`;
const result = await queryOne(deleteGroupSql, groupParams);
if (!result) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
logger.info("계층 그룹 삭제", { groupCode, companyCode });
res.json({
success: true,
message: "계층 그룹이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("계층 그룹 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "계층 그룹 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 계층 레벨 관리
// =====================================================
/**
*
*/
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelOrder,
levelName,
levelCode,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection = "ASC",
placeholder,
isRequired = "Y",
isSearchable = "N",
} = req.body;
// 그룹 존재 확인
const groupCheck = await queryOne(
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
[groupCode, companyCode]
);
if (!groupCheck) {
return res.status(404).json({
success: false,
message: "계층 그룹을 찾을 수 없습니다.",
});
}
const insertSql = `
INSERT INTO cascading_hierarchy_level (
group_code, company_code, level_order, level_name, level_code,
table_name, value_column, label_column, parent_key_column,
filter_column, filter_value, order_column, order_direction,
placeholder, is_required, is_searchable, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
groupCode,
groupCheck.company_code,
levelOrder,
levelName,
levelCode || null,
tableName,
valueColumn,
labelColumn,
parentKeyColumn || null,
filterColumn || null,
filterValue || null,
orderColumn || null,
orderDirection,
placeholder || `${levelName} 선택`,
isRequired,
isSearchable,
]);
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
res.status(201).json({
success: true,
message: "레벨이 추가되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 레벨 추가 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 추가에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
} = req.body;
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
const checkParams: any[] = [Number(levelId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_hierarchy_level SET
level_name = COALESCE($1, level_name),
table_name = COALESCE($2, table_name),
value_column = COALESCE($3, value_column),
label_column = COALESCE($4, label_column),
parent_key_column = COALESCE($5, parent_key_column),
filter_column = COALESCE($6, filter_column),
filter_value = COALESCE($7, filter_value),
order_column = COALESCE($8, order_column),
order_direction = COALESCE($9, order_direction),
placeholder = COALESCE($10, placeholder),
is_required = COALESCE($11, is_required),
is_searchable = COALESCE($12, is_searchable),
is_active = COALESCE($13, is_active),
updated_date = CURRENT_TIMESTAMP
WHERE level_id = $14
RETURNING *
`;
const result = await queryOne(updateSql, [
levelName,
tableName,
valueColumn,
labelColumn,
parentKeyColumn,
filterColumn,
filterValue,
orderColumn,
orderDirection,
placeholder,
isRequired,
isSearchable,
isActive,
Number(levelId),
]);
logger.info("계층 레벨 수정", { levelId });
res.json({
success: true,
message: "레벨이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("계층 레벨 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
const deleteParams: any[] = [Number(levelId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING level_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
logger.info("계층 레벨 삭제", { levelId });
res.json({
success: true,
message: "레벨이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("계층 레벨 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "레벨 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 계층 옵션 조회 API (실제 사용)
// =====================================================
/**
*
*/
export const getLevelOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode, levelOrder } = req.params;
const { parentValue } = req.query;
const companyCode = req.user?.companyCode || "*";
// 레벨 정보 조회
let levelSql = `
SELECT l.*, g.hierarchy_type
FROM cascading_hierarchy_level l
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
`;
const levelParams: any[] = [groupCode, Number(levelOrder)];
if (companyCode !== "*") {
levelSql += ` AND l.company_code = $3`;
levelParams.push(companyCode);
}
const level = await queryOne(levelSql, levelParams);
if (!level) {
return res.status(404).json({
success: false,
message: "레벨을 찾을 수 없습니다.",
});
}
// 옵션 조회
let optionsSql = `
SELECT
${level.value_column} as value,
${level.label_column} as label
FROM ${level.table_name}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 부모 값 필터 (레벨 2 이상)
if (level.parent_key_column && parentValue) {
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
optionsParams.push(parentValue);
}
// 고정 필터
if (level.filter_column && level.filter_value) {
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
optionsParams.push(level.filter_value);
}
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[level.table_name]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 정렬
if (level.order_column) {
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
} else {
optionsSql += ` ORDER BY ${level.label_column}`;
}
const optionsResult = await query(optionsSql, optionsParams);
logger.info("계층 레벨 옵션 조회", {
groupCode,
levelOrder,
parentValue,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
levelInfo: {
levelId: level.level_id,
levelName: level.level_name,
placeholder: level.placeholder,
isRequired: level.is_required,
isSearchable: level.is_searchable,
},
});
} catch (error: any) {
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -1,537 +0,0 @@
/**
* (Mutual Exclusion)
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
// =====================================================
// 상호 배제 규칙 CRUD
// =====================================================
/**
*
*/
export const getExclusions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let sql = `
SELECT * FROM cascading_mutual_exclusion
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 필터
if (companyCode !== "*") {
sql += ` AND company_code = $${paramIndex++}`;
params.push(companyCode);
}
// 활성 상태 필터
if (isActive) {
sql += ` AND is_active = $${paramIndex++}`;
params.push(isActive);
}
sql += ` ORDER BY exclusion_name`;
const result = await query(sql, params);
logger.info("상호 배제 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getExclusionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const params: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
sql += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await queryOne(sql, params);
if (!result) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
const generateExclusionCode = async (companyCode: string): Promise<string> => {
const prefix = "EX";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
[companyCode]
);
const count = parseInt(result?.cnt || "0", 10) + 1;
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
};
/**
*
*/
export const createExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
sourceTable,
valueColumn,
labelColumn,
exclusionType = "SAME_VALUE",
errorMessage = "동일한 값을 선택할 수 없습니다",
} = req.body;
// 필수 필드 검증
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
});
}
// 배제 코드 자동 생성
const exclusionCode = await generateExclusionCode(companyCode);
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
const existingCheck = await queryOne(
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
[exclusionCode, companyCode]
);
if (existingCheck) {
return res.status(409).json({
success: false,
message: "이미 존재하는 배제 코드입니다.",
});
}
const insertSql = `
INSERT INTO cascading_mutual_exclusion (
exclusion_code, exclusion_name, field_names,
source_table, value_column, label_column,
exclusion_type, error_message,
company_code, is_active, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await queryOne(insertSql, [
exclusionCode,
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn || null,
exclusionType,
errorMessage,
companyCode,
]);
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
res.status(201).json({
success: true,
message: "상호 배제 규칙이 생성되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
const {
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
} = req.body;
// 기존 규칙 확인
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const checkParams: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
checkSql += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await queryOne(checkSql, checkParams);
if (!existing) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
const updateSql = `
UPDATE cascading_mutual_exclusion SET
exclusion_name = COALESCE($1, exclusion_name),
field_names = COALESCE($2, field_names),
source_table = COALESCE($3, source_table),
value_column = COALESCE($4, value_column),
label_column = COALESCE($5, label_column),
exclusion_type = COALESCE($6, exclusion_type),
error_message = COALESCE($7, error_message),
is_active = COALESCE($8, is_active)
WHERE exclusion_id = $9
RETURNING *
`;
const result = await queryOne(updateSql, [
exclusionName,
fieldNames,
sourceTable,
valueColumn,
labelColumn,
exclusionType,
errorMessage,
isActive,
Number(exclusionId),
]);
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
res.json({
success: true,
message: "상호 배제 규칙이 수정되었습니다.",
data: result,
});
} catch (error: any) {
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
const deleteParams: any[] = [Number(exclusionId)];
if (companyCode !== "*") {
deleteSql += ` AND company_code = $2`;
deleteParams.push(companyCode);
}
deleteSql += ` RETURNING exclusion_id`;
const result = await queryOne(deleteSql, deleteParams);
if (!result) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
res.json({
success: true,
message: "상호 배제 규칙이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 규칙 삭제에 실패했습니다.",
error: error.message,
});
}
};
// =====================================================
// 상호 배제 검증 API (실제 사용)
// =====================================================
/**
*
*
*/
export const validateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 필드명 파싱
const fields = exclusion.field_names
.split(",")
.map((f: string) => f.trim());
// 필드 값 수집
const values: string[] = [];
for (const field of fields) {
if (fieldValues[field]) {
values.push(fieldValues[field]);
}
}
// 상호 배제 검증
let isValid = true;
let errorMessage = null;
let conflictingFields: string[] = [];
if (exclusion.exclusion_type === "SAME_VALUE") {
// 같은 값이 있는지 확인
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
isValid = false;
errorMessage = exclusion.error_message;
// 충돌하는 필드 찾기
const valueCounts: Record<string, string[]> = {};
for (const field of fields) {
const val = fieldValues[field];
if (val) {
if (!valueCounts[val]) {
valueCounts[val] = [];
}
valueCounts[val].push(field);
}
}
for (const [, fieldList] of Object.entries(valueCounts)) {
if (fieldList.length > 1) {
conflictingFields = fieldList;
break;
}
}
}
}
logger.info("상호 배제 검증", {
exclusionCode,
isValid,
fieldValues,
});
res.json({
success: true,
data: {
isValid,
errorMessage: isValid ? null : errorMessage,
conflictingFields,
},
});
} catch (error: any) {
logger.error("상호 배제 검증 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 검증에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*
*/
export const getExcludedOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
const companyCode = req.user?.companyCode || "*";
// 배제 규칙 조회
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
const exclusionParams: any[] = [exclusionCode];
if (companyCode !== "*") {
exclusionSql += ` AND company_code = $2`;
exclusionParams.push(companyCode);
}
const exclusion = await queryOne(exclusionSql, exclusionParams);
if (!exclusion) {
return res.status(404).json({
success: false,
message: "상호 배제 규칙을 찾을 수 없습니다.",
});
}
// 옵션 조회
const labelColumn = exclusion.label_column || exclusion.value_column;
let optionsSql = `
SELECT
${exclusion.value_column} as value,
${labelColumn} as label
FROM ${exclusion.source_table}
WHERE 1=1
`;
const optionsParams: any[] = [];
let optionsParamIndex = 1;
// 멀티테넌시 필터
if (companyCode !== "*") {
const columnCheck = await queryOne(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[exclusion.source_table]
);
if (columnCheck) {
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
optionsParams.push(companyCode);
}
}
// 이미 선택된 값 제외
if (selectedValues) {
const excludeValues = (selectedValues as string)
.split(",")
.map((v) => v.trim())
.filter((v) => v);
if (excludeValues.length > 0) {
const placeholders = excludeValues
.map((_, i) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
optionsParams.push(...excludeValues);
}
}
optionsSql += ` ORDER BY ${labelColumn}`;
const optionsResult = await query(optionsSql, optionsParams);
logger.info("상호 배제 옵션 조회", {
exclusionCode,
currentField,
excludedCount: (selectedValues as string)?.split(",").length || 0,
optionCount: optionsResult.length,
});
res.json({
success: true,
data: optionsResult,
});
} catch (error: any) {
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "상호 배제 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -1,798 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
/**
*
*/
export const getCascadingRelations = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
let query = `
SELECT
relation_id,
relation_code,
relation_name,
description,
parent_table,
parent_value_column,
parent_label_column,
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction,
empty_parent_message,
no_options_message,
loading_message,
clear_on_parent_change,
company_code,
is_active,
created_by,
created_date,
updated_by,
updated_date
FROM cascading_relation
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터링
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
if (companyCode !== "*") {
query += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 활성 상태 필터링
if (isActive !== undefined) {
query += ` AND is_active = $${paramIndex}`;
params.push(isActive);
paramIndex++;
}
query += ` ORDER BY relation_name ASC`;
const result = await pool.query(query, params);
logger.info("연쇄 관계 목록 조회", {
companyCode,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 목록 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getCascadingRelationById = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
let query = `
SELECT
relation_id,
relation_code,
relation_name,
description,
parent_table,
parent_value_column,
parent_label_column,
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction,
empty_parent_message,
no_options_message,
loading_message,
clear_on_parent_change,
company_code,
is_active,
created_by,
created_date,
updated_by,
updated_date
FROM cascading_relation
WHERE relation_id = $1
`;
const params: any[] = [id];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
const result = await pool.query(query, params);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const getCascadingRelationByCode = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const companyCode = req.user?.companyCode || "*";
let query = `
SELECT
relation_id,
relation_code,
relation_name,
description,
parent_table,
parent_value_column,
parent_label_column,
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction,
empty_parent_message,
no_options_message,
loading_message,
clear_on_parent_change,
company_code,
is_active
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const params: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
}
query += ` LIMIT 1`;
const result = await pool.query(query, params);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const createCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
relationCode,
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange,
} = req.body;
// 필수 필드 검증
if (
!relationCode ||
!relationName ||
!parentTable ||
!parentValueColumn ||
!childTable ||
!childFilterColumn ||
!childValueColumn ||
!childLabelColumn
) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
// 중복 코드 체크
const duplicateCheck = await pool.query(
`SELECT relation_id FROM cascading_relation
WHERE relation_code = $1 AND company_code = $2`,
[relationCode, companyCode]
);
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
return res.status(400).json({
success: false,
message: "이미 존재하는 관계 코드입니다.",
});
}
const query = `
INSERT INTO cascading_relation (
relation_code,
relation_name,
description,
parent_table,
parent_value_column,
parent_label_column,
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction,
empty_parent_message,
no_options_message,
loading_message,
clear_on_parent_change,
company_code,
is_active,
created_by,
created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP)
RETURNING *
`;
const result = await pool.query(query, [
relationCode,
relationName,
description || null,
parentTable,
parentValueColumn,
parentLabelColumn || null,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn || null,
childOrderDirection || "ASC",
emptyParentMessage || "상위 항목을 먼저 선택하세요",
noOptionsMessage || "선택 가능한 항목이 없습니다",
loadingMessage || "로딩 중...",
clearOnParentChange !== false ? "Y" : "N",
companyCode,
userId,
]);
logger.info("연쇄 관계 생성", {
relationId: result.rows[0].relation_id,
relationCode,
companyCode,
userId,
});
return res.status(201).json({
success: true,
data: result.rows[0],
message: "연쇄 관계가 생성되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 생성 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 생성에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const updateCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
const {
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange,
isActive,
} = req.body;
// 권한 체크
const existingCheck = await pool.query(
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
[id]
);
if (existingCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
const existingCompanyCode = existingCheck.rows[0].company_code;
if (
companyCode !== "*" &&
existingCompanyCode !== companyCode &&
existingCompanyCode !== "*"
) {
return res.status(403).json({
success: false,
message: "수정 권한이 없습니다.",
});
}
const query = `
UPDATE cascading_relation SET
relation_name = COALESCE($1, relation_name),
description = COALESCE($2, description),
parent_table = COALESCE($3, parent_table),
parent_value_column = COALESCE($4, parent_value_column),
parent_label_column = COALESCE($5, parent_label_column),
child_table = COALESCE($6, child_table),
child_filter_column = COALESCE($7, child_filter_column),
child_value_column = COALESCE($8, child_value_column),
child_label_column = COALESCE($9, child_label_column),
child_order_column = COALESCE($10, child_order_column),
child_order_direction = COALESCE($11, child_order_direction),
empty_parent_message = COALESCE($12, empty_parent_message),
no_options_message = COALESCE($13, no_options_message),
loading_message = COALESCE($14, loading_message),
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
is_active = COALESCE($16, is_active),
updated_by = $17,
updated_date = CURRENT_TIMESTAMP
WHERE relation_id = $18
RETURNING *
`;
const result = await pool.query(query, [
relationName,
description,
parentTable,
parentValueColumn,
parentLabelColumn,
childTable,
childFilterColumn,
childValueColumn,
childLabelColumn,
childOrderColumn,
childOrderDirection,
emptyParentMessage,
noOptionsMessage,
loadingMessage,
clearOnParentChange !== undefined
? clearOnParentChange
? "Y"
: "N"
: null,
isActive !== undefined ? (isActive ? "Y" : "N") : null,
userId,
id,
]);
logger.info("연쇄 관계 수정", {
relationId: id,
companyCode,
userId,
});
return res.json({
success: true,
data: result.rows[0],
message: "연쇄 관계가 수정되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 수정 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 수정에 실패했습니다.",
error: error.message,
});
}
};
/**
*
*/
export const deleteCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
// 권한 체크
const existingCheck = await pool.query(
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
[id]
);
if (existingCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
const existingCompanyCode = existingCheck.rows[0].company_code;
if (
companyCode !== "*" &&
existingCompanyCode !== companyCode &&
existingCompanyCode !== "*"
) {
return res.status(403).json({
success: false,
message: "삭제 권한이 없습니다.",
});
}
// 소프트 삭제 (is_active = 'N')
await pool.query(
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
[userId, id]
);
logger.info("연쇄 관계 삭제", {
relationId: id,
companyCode,
userId,
});
return res.json({
success: true,
message: "연쇄 관계가 삭제되었습니다.",
});
} catch (error: any) {
logger.error("연쇄 관계 삭제 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 관계 삭제에 실패했습니다.",
error: error.message,
});
}
};
/**
* 🆕 ( )
* parent_table에서 .
*/
export const getParentOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const companyCode = req.user?.companyCode || "*";
// 관계 정보 조회
let relationQuery = `
SELECT
parent_table,
parent_value_column,
parent_label_column
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const relationParams: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
relationQuery += ` AND company_code = $2`;
relationParams.push(companyCode);
}
relationQuery += ` LIMIT 1`;
const relationResult = await pool.query(relationQuery, relationParams);
if (relationResult.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
const relation = relationResult.rows[0];
// 라벨 컬럼이 없으면 값 컬럼 사용
const labelColumn =
relation.parent_label_column || relation.parent_value_column;
// 부모 옵션 조회
let optionsQuery = `
SELECT
${relation.parent_value_column} as value,
${labelColumn} as label
FROM ${relation.parent_table}
WHERE 1=1
`;
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
const tableInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.parent_table]
);
const optionsParams: any[] = [];
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
if (
tableInfoResult.rowCount &&
tableInfoResult.rowCount > 0 &&
companyCode !== "*"
) {
optionsQuery += ` AND company_code = $1`;
optionsParams.push(companyCode);
}
// status 컬럼이 있으면 활성 상태만 조회
const statusInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'status'`,
[relation.parent_table]
);
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
}
// 정렬
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
const optionsResult = await pool.query(optionsQuery, optionsParams);
logger.info("부모 옵션 조회", {
relationCode: code,
parentTable: relation.parent_table,
optionsCount: optionsResult.rowCount,
});
return res.json({
success: true,
data: optionsResult.rows,
});
} catch (error: any) {
logger.error("부모 옵션 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "부모 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};
/**
*
* API
*
* :
* - parentValue: 단일 (: "공정검사")
* - parentValues: 다중 (: "공정검사,출하검사" )
*/
export const getCascadingOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const { parentValue, parentValues } = req.query;
const companyCode = req.user?.companyCode || "*";
// 다중 부모값 파싱
let parentValueArray: string[] = [];
if (parentValues) {
// parentValues가 있으면 우선 사용 (다중 선택)
if (Array.isArray(parentValues)) {
parentValueArray = parentValues.map(v => String(v));
} else {
// 콤마로 구분된 문자열
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
}
} else if (parentValue) {
// 기존 단일 값 호환
parentValueArray = [String(parentValue)];
}
if (parentValueArray.length === 0) {
return res.json({
success: true,
data: [],
message: "부모 값이 없습니다.",
});
}
// 관계 정보 조회
let relationQuery = `
SELECT
child_table,
child_filter_column,
child_value_column,
child_label_column,
child_order_column,
child_order_direction
FROM cascading_relation
WHERE relation_code = $1
AND is_active = 'Y'
`;
const relationParams: any[] = [code];
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
if (companyCode !== "*") {
relationQuery += ` AND company_code = $2`;
relationParams.push(companyCode);
}
relationQuery += ` LIMIT 1`;
const relationResult = await pool.query(relationQuery, relationParams);
if (relationResult.rowCount === 0) {
return res.status(404).json({
success: false,
message: "연쇄 관계를 찾을 수 없습니다.",
});
}
const relation = relationResult.rows[0];
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
let optionsQuery = `
SELECT DISTINCT
${relation.child_value_column} as value,
${relation.child_label_column} as label,
${relation.child_filter_column} as parent_value
FROM ${relation.child_table}
WHERE ${relation.child_filter_column} IN (${placeholders})
`;
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
const tableInfoResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[relation.child_table]
);
const optionsParams: any[] = [...parentValueArray];
let paramIndex = parentValueArray.length + 1;
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
if (
tableInfoResult.rowCount &&
tableInfoResult.rowCount > 0 &&
companyCode !== "*"
) {
optionsQuery += ` AND company_code = $${paramIndex}`;
optionsParams.push(companyCode);
paramIndex++;
}
// 정렬
if (relation.child_order_column) {
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
} else {
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
}
const optionsResult = await pool.query(optionsQuery, optionsParams);
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
relationCode: code,
parentValues: parentValueArray,
optionsCount: optionsResult.rowCount,
});
return res.json({
success: true,
data: optionsResult.rows,
});
} catch (error: any) {
logger.error("연쇄 옵션 조회 실패", { error: error.message });
return res.status(500).json({
success: false,
message: "연쇄 옵션 조회에 실패했습니다.",
error: error.message,
});
}
};

View File

@ -282,175 +282,3 @@ export async function previewCodeMerge(
}
}
/**
* -
* oldValue를 newValue로
*/
export async function mergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue, newValue } = req.body;
const companyCode = req.user?.companyCode;
try {
// 입력값 검증
if (!oldValue || !newValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
// 같은 값으로 병합 시도 방지
if (oldValue === newValue) {
res.status(400).json({
success: false,
message: "기존 값과 새 값이 동일합니다.",
});
return;
}
logger.info("값 기반 코드 병합 시작", {
oldValue,
newValue,
companyCode,
userId: req.user?.userId,
});
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM merge_code_by_value($1, $2, $3)",
[oldValue, newValue, companyCode]
);
// 결과 처리
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = affectedData.reduce(
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
0
);
logger.info("값 기반 코드 병합 완료", {
oldValue,
newValue,
affectedTablesCount: affectedData.length,
totalRowsUpdated: totalRows,
});
res.json({
success: true,
message: `코드 병합 완료: ${oldValue}${newValue}`,
data: {
oldValue,
newValue,
affectedData: affectedData.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
rowsUpdated: parseInt(row.out_rows_updated),
})),
totalRowsUpdated: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 실패:", {
error: error.message,
stack: error.stack,
oldValue,
newValue,
});
res.status(500).json({
success: false,
message: "코드 병합 중 오류가 발생했습니다.",
error: {
code: "CODE_MERGE_BY_VALUE_ERROR",
details: error.message,
},
});
}
}
/**
*
* /
*/
export async function previewMergeCodeByValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const { oldValue } = req.body;
const companyCode = req.user?.companyCode;
try {
if (!oldValue) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (oldValue)",
});
return;
}
if (!companyCode) {
res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
return;
}
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
// PostgreSQL 함수 호출
const result = await pool.query(
"SELECT * FROM preview_merge_code_by_value($1, $2)",
[oldValue, companyCode]
);
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
const totalRows = preview.reduce(
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
0
);
logger.info("값 기반 코드 병합 미리보기 완료", {
tablesCount: preview.length,
totalRows,
});
res.json({
success: true,
message: "코드 병합 미리보기 완료",
data: {
oldValue,
preview: preview.map((row: any) => ({
tableName: row.out_table_name,
columnName: row.out_column_name,
affectedRows: parseInt(row.out_affected_rows),
})),
totalAffectedRows: totalRows,
},
});
} catch (error: any) {
logger.error("값 기반 코드 병합 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
error: {
code: "PREVIEW_BY_VALUE_ERROR",
details: error.message,
},
});
}
}

View File

@ -20,9 +20,8 @@ export class CommonCodeController {
*/
async getCategories(req: AuthenticatedRequest, res: Response) {
try {
const { search, isActive, page = "1", size = "20", menuObjid } = req.query;
const { search, isActive, page = "1", size = "20" } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const categories = await this.commonCodeService.getCategories(
{
@ -36,8 +35,7 @@ export class CommonCodeController {
page: parseInt(page as string),
size: parseInt(size as string),
},
userCompanyCode,
menuObjidNum
userCompanyCode
);
return res.json({
@ -63,9 +61,8 @@ export class CommonCodeController {
async getCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { search, isActive, page, size, menuObjid } = req.query;
const { search, isActive, page, size } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const result = await this.commonCodeService.getCodes(
categoryCode,
@ -80,8 +77,7 @@ export class CommonCodeController {
page: page ? parseInt(page as string) : undefined,
size: size ? parseInt(size as string) : undefined,
},
userCompanyCode,
menuObjidNum
userCompanyCode
);
// 프론트엔드가 기대하는 형식으로 데이터 변환
@ -94,9 +90,7 @@ export class CommonCodeController {
sortOrder: code.sort_order,
isActive: code.is_active,
useYn: code.is_active,
companyCode: code.company_code,
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
depth: code.depth, // 계층구조: 깊이
companyCode: code.company_code, // 추가
// 기존 필드명도 유지 (하위 호환성)
code_category: code.code_category,
@ -105,9 +99,7 @@ export class CommonCodeController {
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
company_code: code.company_code,
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
company_code: code.company_code, // 추가
created_date: code.created_date,
created_by: code.created_by,
updated_date: code.updated_date,
@ -139,7 +131,6 @@ export class CommonCodeController {
const categoryData: CreateCategoryData = req.body;
const userId = req.user?.userId || "SYSTEM";
const companyCode = req.user?.companyCode || "*";
const menuObjid = req.body.menuObjid;
// 입력값 검증
if (!categoryData.categoryCode || !categoryData.categoryName) {
@ -149,18 +140,10 @@ export class CommonCodeController {
});
}
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID는 필수입니다.",
});
}
const category = await this.commonCodeService.createCategory(
categoryData,
userId,
companyCode,
Number(menuObjid)
companyCode
);
return res.status(201).json({
@ -280,7 +263,6 @@ export class CommonCodeController {
const codeData: CreateCodeData = req.body;
const userId = req.user?.userId || "SYSTEM";
const companyCode = req.user?.companyCode || "*";
const menuObjid = req.body.menuObjid;
// 입력값 검증
if (!codeData.codeValue || !codeData.codeName) {
@ -290,17 +272,11 @@ export class CommonCodeController {
});
}
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
// 공통코드관리 메뉴 OBJID: 1757401858940
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
const code = await this.commonCodeService.createCode(
categoryCode,
codeData,
userId,
companyCode,
effectiveMenuObjid
companyCode
);
return res.status(201).json({
@ -590,129 +566,4 @@ export class CommonCodeController {
});
}
}
/**
*
* GET /api/common-codes/categories/:categoryCode/hierarchy
* Query: parentCodeValue (optional), depth (optional), menuObjid (optional)
*/
async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { parentCodeValue, depth, menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
// parentCodeValue가 빈 문자열이면 최상위 코드 조회
const parentValue = parentCodeValue === '' || parentCodeValue === undefined
? null
: parentCodeValue as string;
const codes = await this.commonCodeService.getHierarchicalCodes(
categoryCode,
parentValue,
depth ? parseInt(depth as string) : undefined,
userCompanyCode,
menuObjidNum
);
// 프론트엔드 형식으로 변환
const transformedData = codes.map((code: any) => ({
codeValue: code.code_value,
codeName: code.code_name,
codeNameEng: code.code_name_eng,
description: code.description,
sortOrder: code.sort_order,
isActive: code.is_active,
parentCodeValue: code.parent_code_value,
depth: code.depth,
// 기존 필드도 유지
code_category: code.code_category,
code_value: code.code_value,
code_name: code.code_name,
code_name_eng: code.code_name_eng,
sort_order: code.sort_order,
is_active: code.is_active,
parent_code_value: code.parent_code_value,
}));
return res.json({
success: true,
data: transformedData,
message: `계층구조 코드 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "계층구조 코드 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* GET /api/common-codes/categories/:categoryCode/tree
*/
async getCodeTree(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const result = await this.commonCodeService.getCodeTree(
categoryCode,
userCompanyCode,
menuObjidNum
);
return res.json({
success: true,
data: result,
message: `코드 트리 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "코드 트리 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children
*/
async hasChildren(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode, codeValue } = req.params;
const companyCode = req.user?.companyCode;
const hasChildren = await this.commonCodeService.hasChildren(
categoryCode,
codeValue,
companyCode
);
return res.json({
success: true,
data: { hasChildren },
message: "자식 코드 확인 완료",
});
} catch (error) {
logger.error(
`자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
error
);
return res.status(500).json({
success: false,
message: "자식 코드 확인 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}

View File

@ -1,433 +0,0 @@
import { Request, Response } from "express";
import logger from "../utils/logger";
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
export async function getExternalDbConnector(connectionId: number) {
const poolService = ExternalDbConnectionPoolService.getInstance();
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
return {
executeQuery: async (sql: string, params?: any[]) => {
const result = await poolService.executeQuery(connectionId, sql, params);
return { rows: result };
},
};
}
// 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig } = req.body;
if (!externalDbConnectionId || !hierarchyConfig) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig);
const result: any = {
warehouse: null,
levels: [],
materials: [],
};
// 창고 데이터 조회
if (config.warehouse) {
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
const warehouseResult = await connector.executeQuery(warehouseQuery);
result.warehouse = warehouseResult.rows;
}
// 각 레벨 데이터 조회
if (config.levels && Array.isArray(config.levels)) {
for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({
level: level.level,
name: level.name,
data: levelResult.rows,
});
}
}
// 자재 데이터 조회 (개수만)
if (config.material) {
const materialQuery = `
SELECT
${config.material.locationKeyColumn} as location_key,
COUNT(*) as count
FROM ${config.material.tableName}
GROUP BY ${config.material.locationKeyColumn}
`;
const materialResult = await connector.executeQuery(materialQuery);
result.materials = materialResult.rows;
}
logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({
level: l.level,
count: l.data.length,
})),
});
return res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("동적 계층 구조 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 특정 레벨의 하위 데이터 조회
export const getChildrenData = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
req.body;
if (
!externalDbConnectionId ||
!hierarchyConfig ||
!parentLevel ||
!parentKey
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기
const nextLevel = config.levels?.find(
(l: any) => l.level === parentLevel + 1
);
if (!nextLevel) {
return res.json({
success: true,
data: [],
message: "하위 레벨이 없습니다.",
});
}
// 하위 데이터 조회
const query = `
SELECT * FROM ${nextLevel.tableName}
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("하위 데이터 조회", {
externalDbConnectionId,
parentLevel,
parentKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("하위 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "하위 데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getWarehouses = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, tableName } = req.query;
if (!externalDbConnectionId) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID가 필요합니다.",
});
}
if (!tableName) {
return res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 테이블명을 사용하여 모든 컬럼 조회
const query = `SELECT * FROM ${tableName} LIMIT 100`;
const result = await connector.executeQuery(query);
logger.info("창고 목록 조회", {
externalDbConnectionId,
tableName,
count: result.rows.length,
data: result.rows, // 실제 데이터 확인
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("창고 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "창고 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getAreas = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
if (!externalDbConnectionId || !warehouseKey || !tableName) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = `
SELECT * FROM ${tableName}
WHERE WAREKEY = '${warehouseKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("구역 목록 조회", {
externalDbConnectionId,
tableName,
warehouseKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("구역 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "구역 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getLocations = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, areaKey, tableName } = req.query;
if (!externalDbConnectionId || !areaKey || !tableName) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = `
SELECT * FROM ${tableName}
WHERE AREAKEY = '${areaKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("위치 목록 조회", {
externalDbConnectionId,
tableName,
areaKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("위치 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "위치 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 자재 목록 조회 (동적 컬럼 매핑 지원)
export const getMaterials = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const {
externalDbConnectionId,
locaKey,
tableName,
keyColumn,
locationKeyColumn,
layerColumn,
} = req.query;
if (
!externalDbConnectionId ||
!locaKey ||
!tableName ||
!locationKeyColumn
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 동적 쿼리 생성
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
const query = `
SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}'
${orderByClause}
LIMIT 1000
`;
logger.info(`자재 조회 쿼리: ${query}`);
const result = await connector.executeQuery(query);
logger.info("자재 목록 조회", {
externalDbConnectionId,
tableName,
locaKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("자재 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "자재 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, locationKeys, tableName } = req.body;
if (!externalDbConnectionId || !locationKeys || !tableName) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
const query = `
SELECT
LOCAKEY as location_key,
COUNT(*) as count
FROM ${tableName}
WHERE LOCAKEY IN (${keysString})
GROUP BY LOCAKEY
`;
const result = await connector.executeQuery(query);
logger.info("자재 개수 조회", {
externalDbConnectionId,
tableName,
locationCount: locationKeys.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("자재 개수 조회 실패", error);
return res.status(500).json({
success: false,
message: "자재 개수 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};

View File

@ -1,471 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import logger from "../utils/logger";
// 레이아웃 목록 조회
export const getLayouts = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
const { externalDbConnectionId, warehouseKey } = req.query;
let query = `
SELECT
l.*,
u1.user_name as created_by_name,
u2.user_name as updated_by_name,
COUNT(o.id) as object_count
FROM digital_twin_layout l
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
`;
const params: any[] = [];
let paramIndex = 1;
// 최고 관리자는 모든 레이아웃 조회 가능
if (companyCode && companyCode !== '*') {
query += ` WHERE l.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
} else {
query += ` WHERE 1=1`;
}
if (externalDbConnectionId) {
query += ` AND l.external_db_connection_id = $${paramIndex}`;
params.push(externalDbConnectionId);
paramIndex++;
}
if (warehouseKey) {
query += ` AND l.warehouse_key = $${paramIndex}`;
params.push(warehouseKey);
paramIndex++;
}
query += `
GROUP BY l.id, u1.user_name, u2.user_name
ORDER BY l.updated_at DESC
`;
const result = await pool.query(query, params);
logger.info("레이아웃 목록 조회", {
companyCode,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("레이아웃 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "레이아웃 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 레이아웃 상세 조회 (객체 포함)
export const getLayoutById = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
const { id } = req.params;
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
let layoutQuery: string;
let layoutParams: any[];
if (companyCode && companyCode !== '*') {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
layoutParams = [id, companyCode];
} else {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1
`;
layoutParams = [id];
}
const layoutResult = await pool.query(layoutQuery, layoutParams);
if (layoutResult.rowCount === 0) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
// 배치된 객체들 조회
const objectsQuery = `
SELECT *
FROM digital_twin_objects
WHERE layout_id = $1
ORDER BY display_order, created_at
`;
const objectsResult = await pool.query(objectsQuery, [id]);
logger.info("레이아웃 상세 조회", {
companyCode,
layoutId: id,
objectCount: objectsResult.rowCount,
});
return res.json({
success: true,
data: {
layout: layoutResult.rows[0],
objects: objectsResult.rows,
},
});
} catch (error: any) {
logger.error("레이아웃 상세 조회 실패", error);
return res.status(500).json({
success: false,
message: "레이아웃 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 레이아웃 생성
export const createLayout = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response> => {
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
const {
externalDbConnectionId,
warehouseKey,
layoutName,
description,
hierarchyConfig,
objects,
} = req.body;
await client.query("BEGIN");
// 레이아웃 생성
const layoutQuery = `
INSERT INTO digital_twin_layout (
company_code, external_db_connection_id, warehouse_key,
layout_name, description, hierarchy_config, created_by, updated_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING *
`;
const layoutResult = await client.query(layoutQuery, [
companyCode,
externalDbConnectionId,
warehouseKey,
layoutName,
description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
userId,
]);
const layoutId = layoutResult.rows[0].id;
// 객체들 저장
if (objects && objects.length > 0) {
const objectQuery = `
INSERT INTO digital_twin_objects (
layout_id, object_type, object_name,
position_x, position_y, position_z,
size_x, size_y, size_z,
rotation, color,
area_key, loca_key, loc_type,
material_count, material_preview_height,
parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
`;
for (const obj of objects) {
await client.query(objectQuery, [
layoutId,
obj.type,
obj.name,
obj.position.x,
obj.position.y,
obj.position.z,
obj.size.x,
obj.size.y,
obj.size.z,
obj.rotation || 0,
obj.color,
obj.areaKey || null,
obj.locaKey || null,
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
obj.parentId || null,
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
}
}
await client.query("COMMIT");
logger.info("레이아웃 생성", {
companyCode,
layoutId,
objectCount: objects?.length || 0,
});
return res.status(201).json({
success: true,
data: layoutResult.rows[0],
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("레이아웃 생성 실패", error);
return res.status(500).json({
success: false,
message: "레이아웃 생성 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
};
// 레이아웃 수정
export const updateLayout = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response> => {
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
const { id } = req.params;
const {
layoutName,
description,
hierarchyConfig,
externalDbConnectionId,
warehouseKey,
objects,
} = req.body;
await client.query("BEGIN");
// 레이아웃 기본 정보 수정
const updateLayoutQuery = `
UPDATE digital_twin_layout
SET layout_name = $1,
description = $2,
hierarchy_config = $3,
external_db_connection_id = $4,
warehouse_key = $5,
updated_by = $6,
updated_at = NOW()
WHERE id = $7 AND company_code = $8
RETURNING *
`;
const layoutResult = await client.query(updateLayoutQuery, [
layoutName,
description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
externalDbConnectionId || null,
warehouseKey || null,
userId,
id,
companyCode,
]);
if (layoutResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
// 기존 객체 삭제
await client.query(
"DELETE FROM digital_twin_objects WHERE layout_id = $1",
[id]
);
// 새 객체 저장 (부모-자식 관계 처리)
if (objects && objects.length > 0) {
const objectQuery = `
INSERT INTO digital_twin_objects (
layout_id, object_type, object_name,
position_x, position_y, position_z,
size_x, size_y, size_z,
rotation, color,
area_key, loca_key, loc_type,
material_count, material_preview_height,
parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING id
`;
// 임시 ID (음수) → 실제 DB ID 매핑
const idMapping: { [tempId: number]: number } = {};
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
for (const obj of objects.filter((o) => !o.parentId)) {
const result = await client.query(objectQuery, [
id,
obj.type,
obj.name,
obj.position.x,
obj.position.y,
obj.position.z,
obj.size.x,
obj.size.y,
obj.size.z,
obj.rotation || 0,
obj.color,
obj.areaKey || null,
obj.locaKey || null,
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
null, // parent_id
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
// 임시 ID와 실제 DB ID 매핑
if (obj.id) {
idMapping[obj.id] = result.rows[0].id;
}
}
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
for (const obj of objects.filter((o) => o.parentId)) {
const realParentId = idMapping[obj.parentId!] || null;
await client.query(objectQuery, [
id,
obj.type,
obj.name,
obj.position.x,
obj.position.y,
obj.position.z,
obj.size.x,
obj.size.y,
obj.size.z,
obj.rotation || 0,
obj.color,
obj.areaKey || null,
obj.locaKey || null,
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
realParentId, // 실제 DB ID 사용
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
}
}
await client.query("COMMIT");
logger.info("레이아웃 수정", {
companyCode,
layoutId: id,
objectCount: objects?.length || 0,
});
return res.json({
success: true,
data: layoutResult.rows[0],
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("레이아웃 수정 실패", error);
return res.status(500).json({
success: false,
message: "레이아웃 수정 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
};
// 레이아웃 삭제
export const deleteLayout = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
const { id } = req.params;
const query = `
DELETE FROM digital_twin_layout
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
logger.info("레이아웃 삭제", {
companyCode,
layoutId: id,
});
return res.json({
success: true,
message: "레이아웃이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("레이아웃 삭제 실패", error);
return res.status(500).json({
success: false,
message: "레이아웃 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
};

View File

@ -1,164 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import {
DigitalTwinTemplateService,
DigitalTwinLayoutTemplate,
} from "../services/DigitalTwinTemplateService";
export const listMappingTemplates = async (
req: AuthenticatedRequest,
res: Response,
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
}
const externalDbConnectionId = req.query.externalDbConnectionId
? Number(req.query.externalDbConnectionId)
: undefined;
const layoutType =
typeof req.query.layoutType === "string"
? req.query.layoutType
: undefined;
const result = await DigitalTwinTemplateService.listTemplates(
companyCode,
{
externalDbConnectionId,
layoutType,
},
);
if (!result.success) {
return res.status(500).json({
success: false,
message: result.message,
error: result.error,
});
}
return res.json({
success: true,
data: result.data as DigitalTwinLayoutTemplate[],
});
} catch (error: any) {
return res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
export const getMappingTemplateById = async (
req: AuthenticatedRequest,
res: Response,
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
const { id } = req.params;
if (!companyCode) {
return res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
}
const result = await DigitalTwinTemplateService.getTemplateById(
companyCode,
id,
);
if (!result.success) {
return res.status(404).json({
success: false,
message: result.message || "매핑 템플릿을 찾을 수 없습니다.",
error: result.error,
});
}
return res.json({
success: true,
data: result.data,
});
} catch (error: any) {
return res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
export const createMappingTemplate = async (
req: AuthenticatedRequest,
res: Response,
): Promise<Response> => {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({
success: false,
message: "인증 정보가 없습니다.",
});
}
const {
name,
description,
externalDbConnectionId,
layoutType,
config,
} = req.body;
if (!name || !externalDbConnectionId || !config) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const result = await DigitalTwinTemplateService.createTemplate(
companyCode,
userId,
{
name,
description,
externalDbConnectionId,
layoutType,
config,
},
);
if (!result.success || !result.data) {
return res.status(500).json({
success: false,
message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.",
error: result.error,
});
}
return res.status(201).json({
success: true,
data: result.data,
});
} catch (error: any) {
return res.status(500).json({
success: false,
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
};

View File

@ -1,459 +0,0 @@
// 공차중계 운전자 컨트롤러
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
export class DriverController {
/**
* GET /api/driver/profile
*
*/
static async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 사용자 정보 조회
const userResult = await query<any>(
`SELECT
user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name
FROM user_info
WHERE user_id = $1`,
[userId]
);
if (userResult.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
const user = userResult[0];
// 공차중계 사용자가 아닌 경우
if (user.signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// 차량 정보 조회
const vehicleResult = await query<any>(
`SELECT
vehicle_number, vehicle_type, driver_name, driver_phone, status
FROM vehicles
WHERE user_id = $1`,
[userId]
);
const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null;
res.status(200).json({
success: true,
data: {
userId: user.user_id,
userName: user.user_name,
phoneNumber: user.cell_phone,
licenseNumber: user.license_number,
vehicleNumber: user.vehicle_number,
vehicleType: vehicle?.vehicle_type || null,
vehicleStatus: vehicle?.status || null,
branchName: user.branch_name || null,
},
});
} catch (error) {
logger.error("운전자 프로필 조회 오류:", error);
res.status(500).json({
success: false,
message: "프로필 조회 중 오류가 발생했습니다.",
});
}
}
/**
* PUT /api/driver/profile
* (, , , , )
*/
static async updateProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body;
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
if (userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
const oldVehicleNumber = userCheck[0].vehicle_number;
// 차량번호 변경 시 중복 확인
if (vehicleNumber && vehicleNumber !== oldVehicleNumber) {
const duplicateCheck = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`,
[vehicleNumber, userId]
);
if (duplicateCheck.length > 0) {
res.status(400).json({
success: false,
message: "이미 등록된 차량번호입니다.",
});
return;
}
}
// user_info 업데이트
await query(
`UPDATE user_info SET
user_name = COALESCE($1, user_name),
cell_phone = COALESCE($2, cell_phone),
license_number = COALESCE($3, license_number),
vehicle_number = COALESCE($4, vehicle_number),
branch_name = COALESCE($5, branch_name)
WHERE user_id = $6`,
[userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId]
);
// vehicles 테이블 업데이트
await query(
`UPDATE vehicles SET
vehicle_number = COALESCE($1, vehicle_number),
vehicle_type = COALESCE($2, vehicle_type),
driver_name = COALESCE($3, driver_name),
driver_phone = COALESCE($4, driver_phone),
branch_name = COALESCE($5, branch_name),
updated_at = NOW()
WHERE user_id = $6`,
[vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId]
);
logger.info(`운전자 프로필 수정 완료: ${userId}`);
res.status(200).json({
success: true,
message: "프로필이 수정되었습니다.",
});
} catch (error) {
logger.error("운전자 프로필 수정 오류:", error);
res.status(500).json({
success: false,
message: "프로필 수정 중 오류가 발생했습니다.",
});
}
}
/**
* PUT /api/driver/status
* (/ )
*/
static async updateStatus(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { status } = req.body;
// 허용된 상태값만 (대기: off, 정비: maintenance)
const allowedStatuses = ["off", "maintenance"];
if (!status || !allowedStatuses.includes(status)) {
res.status(400).json({
success: false,
message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// vehicles 테이블 상태 업데이트
const updateResult = await query(
`UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`,
[status, userId]
);
logger.info(`차량 상태 변경: ${userId} -> ${status}`);
res.status(200).json({
success: true,
message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`,
});
} catch (error) {
logger.error("차량 상태 변경 오류:", error);
res.status(500).json({
success: false,
message: "상태 변경 중 오류가 발생했습니다.",
});
}
}
/**
* DELETE /api/driver/vehicle
* (user_id = NULL , )
*/
static async deleteVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존)
await query(
`UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`,
[userId]
);
// user_info에서 vehicle_number를 NULL로 변경
await query(
`UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`,
[userId]
);
logger.info(`차량 삭제 완료 (기록 보존): ${userId}`);
res.status(200).json({
success: true,
message: "차량이 삭제되었습니다.",
});
} catch (error) {
logger.error("차량 삭제 오류:", error);
res.status(500).json({
success: false,
message: "차량 삭제 중 오류가 발생했습니다.",
});
}
}
/**
* POST /api/driver/vehicle
*
*/
static async registerVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const companyCode = req.user?.companyCode;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
const { vehicleNumber, vehicleType, branchName } = req.body;
if (!vehicleNumber) {
res.status(400).json({
success: false,
message: "차량번호는 필수입니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 접근할 수 있습니다.",
});
return;
}
// 이미 차량이 있는지 확인
if (userCheck[0].vehicle_number) {
res.status(400).json({
success: false,
message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.",
});
return;
}
// 차량번호 중복 확인
const duplicateCheck = await query<any>(
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`,
[vehicleNumber]
);
if (duplicateCheck.length > 0) {
res.status(400).json({
success: false,
message: "이미 등록된 차량번호입니다.",
});
return;
}
const userName = userCheck[0].user_name;
const userPhone = userCheck[0].cell_phone;
// 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용)
const userCompanyCode = companyCode || userCheck[0].company_code;
// vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off')
await query(
`INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`,
[vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode]
);
// user_info에 vehicle_number 업데이트
await query(
`UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`,
[vehicleNumber, userId]
);
logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`);
res.status(200).json({
success: true,
message: "차량이 등록되었습니다.",
});
} catch (error) {
logger.error("차량 등록 오류:", error);
res.status(500).json({
success: false,
message: "차량 등록 중 오류가 발생했습니다.",
});
}
}
/**
* DELETE /api/driver/account
* ( )
*/
static async deleteAccount(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: "인증이 필요합니다.",
});
return;
}
// 공차중계 사용자 확인
const userCheck = await query<any>(
`SELECT signup_type FROM user_info WHERE user_id = $1`,
[userId]
);
if (userCheck.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
if (userCheck[0].signup_type !== "DRIVER") {
res.status(403).json({
success: false,
message: "공차중계 사용자만 탈퇴할 수 있습니다.",
});
return;
}
// vehicles 테이블에서 삭제
await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]);
// user_info 테이블에서 삭제
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
logger.info(`회원 탈퇴 완료: ${userId}`);
res.status(200).json({
success: true,
message: "회원 탈퇴가 완료되었습니다.",
});
} catch (error) {
logger.error("회원 탈퇴 오류:", error);
res.status(500).json({
success: false,
message: "회원 탈퇴 처리 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
};
const result = await dynamicFormService.updateFormDataPartial(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
parseInt(id),
tableName,
originalData,
newDataWithMeta
@ -231,7 +231,7 @@ export const deleteFormData = async (
try {
const { id } = req.params;
const { companyCode, userId } = req.user as any;
const { tableName, screenId } = req.body;
const { tableName } = req.body;
if (!tableName) {
return res.status(400).json({
@ -240,16 +240,7 @@ export const deleteFormData = async (
});
}
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
await dynamicFormService.deleteFormData(
id,
tableName,
companyCode,
userId,
parsedScreenId // screenId 추가 (제어관리 실행용)
);
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
res.json({
success: true,
@ -428,207 +419,3 @@ export const getTableColumns = async (
});
}
};
// 특정 필드만 업데이트 (다른 테이블 지원)
export const updateFieldValue = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } =
req.body;
console.log("🔄 [updateFieldValue] 요청:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
userId,
companyCode,
});
// 필수 필드 검증
if (
!tableName ||
!keyField ||
keyValue === undefined ||
!updateField ||
updateValue === undefined
) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
});
}
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!validNamePattern.test(tableName) ||
!validNamePattern.test(keyField) ||
!validNamePattern.test(updateField)
) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
});
}
// 업데이트 쿼리 실행
const result = await dynamicFormService.updateFieldValue(
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
userId
);
console.log("✅ [updateFieldValue] 성공:", result);
res.json({
success: true,
data: result,
message: "필드 값이 업데이트되었습니다.",
});
} catch (error: any) {
console.error("❌ [updateFieldValue] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "필드 업데이트에 실패했습니다.",
});
}
};
/**
* ( )
* POST /api/dynamic-form/location-history
*/
export const saveLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId: loginUserId } = req.user as any;
const {
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus,
departure,
arrival,
departureName,
destinationName,
recordedAt,
vehicleId,
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
} = req.body;
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
// 없으면 로그인한 사용자의 userId 사용
const userId = requestUserId || loginUserId;
console.log("📍 [saveLocationHistory] 요청:", {
userId,
requestUserId,
loginUserId,
companyCode,
latitude,
longitude,
tripId,
});
// 필수 필드 검증
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
});
}
const result = await dynamicFormService.saveLocationHistory({
userId,
companyCode,
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus: tripStatus || "active",
departure,
arrival,
departureName,
destinationName,
recordedAt: recordedAt || new Date().toISOString(),
vehicleId,
});
console.log("✅ [saveLocationHistory] 성공:", result);
res.json({
success: true,
data: result,
message: "위치 이력이 저장되었습니다.",
});
} catch (error: any) {
console.error("❌ [saveLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 저장에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/dynamic-form/location-history/:tripId
*/
export const getLocationHistory = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.params;
const { userId, startDate, endDate, limit } = req.query;
console.log("📍 [getLocationHistory] 요청:", {
tripId,
userId,
startDate,
endDate,
limit,
});
const result = await dynamicFormService.getLocationHistory({
companyCode,
tripId,
userId: userId as string,
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 1000,
});
res.json({
success: true,
data: result,
count: result.length,
});
} catch (error: any) {
console.error("❌ [getLocationHistory] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 이력 조회에 실패했습니다.",
});
}
};

View File

@ -28,8 +28,6 @@ export class EntityJoinController {
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@ -66,23 +64,11 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
if (userValue) {
searchConditions[filterColumn] = userValue;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn,
finalCompanyCode,
userValue,
tableName,
});
}
@ -125,32 +111,6 @@ export class EntityJoinController {
}
}
// 🆕 데이터 필터 처리
let parsedDataFilter: any = undefined;
if (dataFilter) {
try {
parsedDataFilter =
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
} catch (error) {
logger.warn("데이터 필터 파싱 오류:", error);
parsedDataFilter = undefined;
}
}
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let parsedExcludeFilter: any = undefined;
if (excludeFilter) {
try {
parsedExcludeFilter =
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
} catch (error) {
logger.warn("제외 필터 파싱 오류:", error);
parsedExcludeFilter = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@ -166,8 +126,6 @@ export class EntityJoinController {
enableEntityJoin === "true" || enableEntityJoin === true,
additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
}
);
@ -436,16 +394,18 @@ export class EntityJoinController {
config.referenceTable
);
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
// 현재 display_column으로 사용 중인 컬럼 제외
const currentDisplayColumn =
config.displayColumn || config.displayColumns[0];
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
const availableColumns = columns.filter(
(col) => col.columnName !== currentDisplayColumn
);
return {
joinConfig: config,
tableName: config.referenceTable,
currentDisplayColumn: currentDisplayColumn,
availableColumns: columns.map((col) => ({
availableColumns: availableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType,

View File

@ -1,242 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* API
* GET /api/entity-search/:tableName
*/
export async function searchEntity(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const {
searchText = "",
searchFields = "",
filterCondition = "{}",
page = "1",
limit = "20",
} = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
return res.status(400).json({
success: false,
message:
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
});
}
// 멀티테넌시
const companyCode = req.user!.companyCode;
// 검색 필드 파싱
const requestedFields = searchFields
? (searchFields as string).split(",").map((f) => f.trim())
: [];
// 🆕 테이블의 실제 컬럼 목록 조회
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 fields = requestedFields.filter((field) => {
if (existingColumns.has(field)) {
return true;
} else {
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
return false;
}
});
const existingColumnsArray = Array.from(existingColumns);
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
// WHERE 조건 생성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터링
if (companyCode !== "*") {
// 🆕 company_code 컬럼이 있는 경우에만 필터링
if (existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
}
// 검색 조건
if (searchText) {
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
let searchableFields = fields;
if (searchableFields.length === 0) {
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
const defaultSearchColumns = [
'name', 'code', 'description', 'title', 'label',
'item_name', 'item_code', 'item_number',
'equipment_name', 'equipment_code',
'inspection_item', 'consumable_name', // 소모품명 추가
'supplier_name', 'customer_name', 'product_name',
];
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
}
if (searchableFields.length > 0) {
const searchConditions = searchableFields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가
searchableFields.forEach(() => {
params.push(`%${searchText}%`);
});
}
}
// 추가 필터 조건 (존재하는 컬럼만)
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
// 특수 키 형식: column__operator (예: division__in, name__like)
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
// 특수 키 형식 파싱: column__operator
let columnName = key;
let operator = "=";
if (key.includes("__")) {
const parts = key.split("__");
columnName = parts[0];
operator = parts[1] || "=";
}
if (!existingColumns.has(columnName)) {
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
continue;
}
// 연산자별 WHERE 조건 생성
switch (operator) {
case "=":
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${columnName}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
whereConditions.push(`"${columnName}" > $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<":
whereConditions.push(`"${columnName}" < $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">=":
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "<=":
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in":
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inValues.length > 0) {
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" IN (${placeholders})`);
params.push(...inValues);
paramIndex += inValues.length;
}
break;
case "notIn":
// NOT IN 연산자
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInValues.length > 0) {
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
params.push(...notInValues);
paramIndex += notInValues.length;
}
break;
case "like":
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
default:
// 알 수 없는 연산자는 등호로 처리
whereConditions.push(`"${columnName}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
}
// 페이징
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행 (pool은 위에서 이미 선언됨)
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}
ORDER BY id DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const countResult = await pool.query(
countQuery,
params.slice(0, params.length - 2)
);
const dataResult = await pool.query(dataQuery, params);
logger.info("엔티티 검색 성공", {
tableName,
searchText,
companyCode,
rowCount: dataResult.rowCount,
});
res.json({
success: true,
data: dataResult.rows,
pagination: {
total: parseInt(countResult.rows[0].count),
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("엔티티 검색 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -1,208 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import excelMappingService from "../services/excelMappingService";
import { logger } from "../utils/logger";
/**
* 릿
* POST /api/excel-mapping/find
*/
export async function findMappingByColumns(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
res.status(400).json({
success: false,
message: "tableName과 excelColumns(배열)가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 조회 요청", {
tableName,
excelColumns,
companyCode,
userId: req.user?.userId,
});
const template = await excelMappingService.findMappingByColumns(
tableName,
excelColumns,
companyCode
);
if (template) {
res.json({
success: true,
data: template,
message: "기존 매핑 템플릿을 찾았습니다.",
});
} else {
res.json({
success: true,
data: null,
message: "일치하는 매핑 템플릿이 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿 (UPSERT)
* POST /api/excel-mapping/save
*/
export async function saveMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns, columnMappings } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!tableName || !excelColumns || !columnMappings) {
res.status(400).json({
success: false,
message: "tableName, excelColumns, columnMappings가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 저장 요청", {
tableName,
excelColumns,
columnMappings,
companyCode,
userId,
});
const template = await excelMappingService.saveMappingTemplate(
tableName,
excelColumns,
columnMappings,
companyCode,
userId
);
res.json({
success: true,
data: template,
message: "매핑 템플릿이 저장되었습니다.",
});
} catch (error: any) {
logger.error("매핑 템플릿 저장 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* GET /api/excel-mapping/list/:tableName
*/
export async function getMappingTemplates(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!tableName) {
res.status(400).json({
success: false,
message: "tableName이 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 목록 조회 요청", {
tableName,
companyCode,
});
const templates = await excelMappingService.getMappingTemplates(
tableName,
companyCode
);
res.json({
success: true,
data: templates,
});
} catch (error: any) {
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* DELETE /api/excel-mapping/:id
*/
export async function deleteMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!id) {
res.status(400).json({
success: false,
message: "id가 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 삭제 요청", {
id,
companyCode,
});
const deleted = await excelMappingService.deleteMappingTemplate(
parseInt(id),
companyCode
);
if (deleted) {
res.json({
success: true,
message: "매핑 템플릿이 삭제되었습니다.",
});
} else {
res.status(404).json({
success: false,
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -341,64 +341,6 @@ export const uploadFiles = async (
});
}
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
// 🔍 디버깅: 레코드 모드 조건 확인
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
isRecordMode,
linkedTable,
recordId,
columnName,
finalTargetObjid,
"req.body.isRecordMode": req.body.isRecordMode,
"req.body.linkedTable": req.body.linkedTable,
"req.body.recordId": req.body.recordId,
"req.body.columnName": req.body.columnName,
});
if (isRecordMode && linkedTable && recordId && columnName) {
try {
// 해당 레코드의 모든 첨부파일 조회
const allFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[finalTargetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = allFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${linkedTable}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, companyCode]
);
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
tableName: linkedTable,
recordId: recordId,
columnName: columnName,
fileCount: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
res.json({
success: true,
message: `${files.length}개 파일 업로드 완료`,
@ -463,56 +405,6 @@ export const deleteFile = async (
["DELETED", parseInt(objid)]
);
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const targetObjid = fileRecord.target_objid;
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
// targetObjid 파싱: tableName:recordId:columnName 형식
const parts = targetObjid.split(':');
if (parts.length >= 3) {
const [tableName, recordId, columnName] = parts;
try {
// 해당 레코드의 남은 첨부파일 조회
const remainingFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[targetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = remainingFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${tableName}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
);
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
tableName,
recordId,
columnName,
remainingFiles: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
}
res.json({
success: true,
message: "파일이 삭제되었습니다.",

View File

@ -32,17 +32,8 @@ export class FlowController {
*/
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
// REST API 관련 필드
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
const { name, description, tableName, dbSourceType, dbConnectionId } =
req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
@ -52,9 +43,6 @@ export class FlowController {
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode,
});
@ -66,12 +54,8 @@ export class FlowController {
return;
}
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
// 테이블 이름이 제공된 경우에만 존재 확인
if (tableName) {
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
@ -84,17 +68,7 @@ export class FlowController {
}
const flowDef = await this.flowDefinitionService.create(
{
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
},
{ name, description, tableName, dbSourceType, dbConnectionId },
userId,
userCompanyCode
);
@ -837,53 +811,4 @@ export class FlowController {
});
}
};
/**
* ( )
*/
updateStepData = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId, recordId } = req.params;
const updateData = req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
if (!flowId || !stepId || !recordId) {
res.status(400).json({
success: false,
message: "flowId, stepId, and recordId are required",
});
return;
}
if (!updateData || Object.keys(updateData).length === 0) {
res.status(400).json({
success: false,
message: "Update data is required",
});
return;
}
const result = await this.flowExecutionService.updateStepData(
parseInt(flowId),
parseInt(stepId),
recordId,
updateData,
userId,
userCompanyCode
);
res.json({
success: true,
message: "Data updated successfully",
data: result,
});
} catch (error: any) {
console.error("Error updating step data:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to update step data",
});
}
};
}

View File

@ -10,10 +10,7 @@ import {
SaveLangTextsRequest,
GetUserTextParams,
BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse,
LangCategory,
} from "../types/multilang";
/**
@ -190,7 +187,7 @@ export const getLangKeys = async (
res: Response
): Promise<void> => {
try {
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
const { companyCode, menuCode, keyType, searchText } = req.query;
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
@ -202,7 +199,6 @@ export const getLangKeys = async (
menuCode: menuCode as string,
keyType: keyType as string,
searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
});
const response: ApiResponse<any[]> = {
@ -634,391 +630,6 @@ export const deleteLanguage = async (
}
};
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* GET /api/multilang/categories
* API ( )
*/
export const getCategories = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("카테고리 목록 조회 요청", { user: req.user });
const multiLangService = new MultiLangService();
const categories = await multiLangService.getCategories();
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 목록 조회 성공",
data: categories,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId
* API
*/
export const getCategoryById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const category = await multiLangService.getCategoryById(parseInt(categoryId));
if (!category) {
res.status(404).json({
success: false,
message: "카테고리를 찾을 수 없습니다.",
error: {
code: "CATEGORY_NOT_FOUND",
details: `Category ID ${categoryId} not found`,
},
});
return;
}
const response: ApiResponse<LangCategory> = {
success: true,
message: "카테고리 상세 조회 성공",
data: category,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_DETAIL_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId/path
* API ( )
*/
export const getCategoryPath = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 경로 조회 성공",
data: path,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 경로 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_PATH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
// =====================================================
// 자동 생성 및 오버라이드 관련 API
// =====================================================
/**
* POST /api/multilang/keys/generate
* API
*/
export const generateKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const generateData: GenerateKeyRequest = req.body;
logger.info("키 자동 생성 요청", { generateData, user: req.user });
// 필수 입력값 검증
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
res.status(400).json({
success: false,
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode, categoryId, and keyMeaning are required",
},
});
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Only super admin can create common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 키만 생성 가능
if (generateData.companyCode !== "*" &&
req.user?.companyCode !== "*" &&
generateData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.generateKey({
...generateData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("키 자동 생성 실패:", error);
res.status(500).json({
success: false,
message: "키 자동 생성 중 오류가 발생했습니다.",
error: {
code: "KEY_GENERATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/preview
* API
*/
export const previewKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId, keyMeaning, companyCode } = req.body;
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
if (!categoryId || !keyMeaning || !companyCode) {
res.status(400).json({
success: false,
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "categoryId, keyMeaning, and companyCode are required",
},
});
return;
}
const multiLangService = new MultiLangService();
const preview = await multiLangService.previewGeneratedKey(
parseInt(categoryId),
keyMeaning,
companyCode
);
const response: ApiResponse<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> = {
success: true,
message: "키 미리보기 성공",
data: preview,
};
res.status(200).json(response);
} catch (error) {
logger.error("키 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "키 미리보기 중 오류가 발생했습니다.",
error: {
code: "KEY_PREVIEW_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/override
* API
*/
export const createOverrideKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const overrideData: CreateOverrideKeyRequest = req.body;
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
// 필수 입력값 검증
if (!overrideData.companyCode || !overrideData.baseKeyId) {
res.status(400).json({
success: false,
message: "회사 코드와 원본 키 ID는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode and baseKeyId are required",
},
});
return;
}
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
if (overrideData.companyCode === "*") {
res.status(400).json({
success: false,
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
error: {
code: "INVALID_OVERRIDE",
details: "Cannot create override for common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
if (req.user?.companyCode !== "*" &&
overrideData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createOverrideKey({
...overrideData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "오버라이드 키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("오버라이드 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEY_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/keys/overrides/:companyCode
* API
*/
export const getOverrideKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot view override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keys = await multiLangService.getOverrideKeys(companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "오버라이드 키 목록 조회 성공",
data: keys,
};
res.status(200).json(response);
} catch (error) {
logger.error("오버라이드 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEYS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/batch
* API
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
});
}
};
/**
* POST /api/multilang/screen-labels
* API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
const { queryOne } = await import("../database/db");
const screenInfo = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

View File

@ -27,24 +27,12 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
const companyCode = req.user!.companyCode;
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
try {
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
companyCode,
menuObjid,
rulesCount: rules.length
});
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
logger.error("메뉴별 사용 가능한 규칙 조회 실패", {
error: error.message,
errorCode: error.code,
errorStack: error.stack,
companyCode,
menuObjid,
});
return res.status(500).json({ success: false, error: error.message });
@ -112,17 +100,6 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
const userId = req.user!.userId;
const ruleConfig = req.body;
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
companyCode,
userId,
ruleId: ruleConfig.ruleId,
ruleName: ruleConfig.ruleName,
scopeType: ruleConfig.scopeType,
menuObjid: ruleConfig.menuObjid,
tableName: ruleConfig.tableName,
partsCount: ruleConfig.parts?.length,
});
try {
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
@ -132,33 +109,13 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
}
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
if (ruleConfig.scopeType === "table") {
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
return res.status(400).json({
success: false,
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
});
}
}
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
ruleId: newRule.ruleId,
menuObjid: newRule.menuObjid,
});
return res.status(201).json({ success: true, data: newRule });
} catch (error: any) {
if (error.code === "23505") {
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
}
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
error: error.message,
stack: error.stack,
code: error.code,
});
logger.error("규칙 생성 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
@ -217,14 +174,11 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
logger.info("코드 할당 요청", { ruleId, companyCode });
try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({ success: true, data: { generatedCode: allocatedCode } });
} catch (error: any) {
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
logger.error("코드 할당 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1,925 +0,0 @@
/**
*
*/
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
const pool = getPool();
// ============================================
// 1. 화면 임베딩 API
// ============================================
/**
*
* GET /api/screen-embedding?parentScreenId=1
*/
export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
try {
const { parentScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!parentScreenId) {
return res.status(400).json({
success: false,
message: "부모 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.parent_screen_id = $1
AND se.company_code = $2
ORDER BY se.position, se.created_at
`;
const result = await pool.query(query, [parentScreenId, companyCode]);
logger.info("화면 임베딩 목록 조회", {
companyCode,
parentScreenId,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("화면 임베딩 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* GET /api/screen-embedding/:id
*/
export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.id = $1
AND se.company_code = $2
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 상세 조회", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 상세 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-embedding
*/
export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try {
const {
parentScreenId,
childScreenId,
position,
mode,
config = {},
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!parentScreenId || !childScreenId || !position || !mode) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
parentScreenId,
childScreenId,
position,
mode,
JSON.stringify(config),
companyCode,
userId,
]);
logger.info("화면 임베딩 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 임베딩 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* PUT /api/screen-embedding/:id
*/
export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const { position, mode, config } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (position) {
updates.push(`position = $${paramIndex++}`);
values.push(position);
}
if (mode) {
updates.push(`mode = $${paramIndex++}`);
values.push(mode);
}
if (config) {
updates.push(`config = $${paramIndex++}`);
values.push(JSON.stringify(config));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_embedding
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 수정 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-embedding/:id
*/
export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_embedding
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 삭제", { companyCode, id });
return res.json({
success: true,
message: "화면 임베딩이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("화면 임베딩 삭제 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 2. 데이터 전달 API
// ============================================
/**
*
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
*/
export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try {
const { sourceScreenId, targetScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!sourceScreenId || !targetScreenId) {
return res.status(400).json({
success: false,
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
sdt.*,
ss.screen_name as source_screen_name,
ts.screen_name as target_screen_name
FROM screen_data_transfer sdt
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
WHERE sdt.source_screen_id = $1
AND sdt.target_screen_id = $2
AND sdt.company_code = $3
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 조회", {
companyCode,
sourceScreenId,
targetScreenId,
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-data-transfer
*/
export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try {
const {
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
dataReceivers,
buttonConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
JSON.stringify(dataReceivers),
JSON.stringify(buttonConfig || {}),
companyCode,
userId,
]);
logger.info("데이터 전달 설정 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* PUT /api/screen-data-transfer/:id
*/
export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const { dataReceivers, buttonConfig } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dataReceivers) {
updates.push(`data_receivers = $${paramIndex++}`);
values.push(JSON.stringify(dataReceivers));
}
if (buttonConfig) {
updates.push(`button_config = $${paramIndex++}`);
values.push(JSON.stringify(buttonConfig));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_data_transfer
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-data-transfer/:id
*/
export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_data_transfer
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "데이터 전달 설정이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("데이터 전달 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 3. 분할 패널 API
// ============================================
/**
*
* GET /api/screen-split-panel/:screenId
*/
export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
try {
const { screenId } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
ssp.*,
le.parent_screen_id as le_parent_screen_id,
le.child_screen_id as le_child_screen_id,
le.position as le_position,
le.mode as le_mode,
le.config as le_config,
re.parent_screen_id as re_parent_screen_id,
re.child_screen_id as re_child_screen_id,
re.position as re_position,
re.mode as re_mode,
re.config as re_config,
sdt.source_screen_id,
sdt.target_screen_id,
sdt.source_component_id,
sdt.source_component_type,
sdt.data_receivers,
sdt.button_config
FROM screen_split_panel ssp
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
WHERE ssp.screen_id = $1
AND ssp.company_code = $2
`;
const result = await pool.query(query, [screenId, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const row = result.rows[0];
// 데이터 구조화
const data = {
id: row.id,
screenId: row.screen_id,
leftEmbeddingId: row.left_embedding_id,
rightEmbeddingId: row.right_embedding_id,
dataTransferId: row.data_transfer_id,
layoutConfig: row.layout_config,
companyCode: row.company_code,
createdAt: row.created_at,
updatedAt: row.updated_at,
leftEmbedding: row.le_child_screen_id
? {
id: row.left_embedding_id,
parentScreenId: row.le_parent_screen_id,
childScreenId: row.le_child_screen_id,
position: row.le_position,
mode: row.le_mode,
config: row.le_config,
}
: null,
rightEmbedding: row.re_child_screen_id
? {
id: row.right_embedding_id,
parentScreenId: row.re_parent_screen_id,
childScreenId: row.re_child_screen_id,
position: row.re_position,
mode: row.re_mode,
config: row.re_config,
}
: null,
dataTransfer: row.source_screen_id
? {
id: row.data_transfer_id,
sourceScreenId: row.source_screen_id,
targetScreenId: row.target_screen_id,
sourceComponentId: row.source_component_id,
sourceComponentType: row.source_component_type,
dataReceivers: row.data_receivers,
buttonConfig: row.button_config,
}
: null,
};
logger.info("분할 패널 설정 조회", { companyCode, screenId });
return res.json({
success: true,
data,
});
} catch (error: any) {
logger.error("분할 패널 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-split-panel
*/
export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
const client = await pool.connect();
try {
const {
screenId,
leftEmbedding,
rightEmbedding,
dataTransfer,
layoutConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
await client.query("BEGIN");
// 1. 좌측 임베딩 생성
const leftEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const leftResult = await client.query(leftEmbeddingQuery, [
screenId,
leftEmbedding.childScreenId,
leftEmbedding.position,
leftEmbedding.mode,
JSON.stringify(leftEmbedding.config || {}),
companyCode,
userId,
]);
const leftEmbeddingId = leftResult.rows[0].id;
// 2. 우측 임베딩 생성
const rightEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const rightResult = await client.query(rightEmbeddingQuery, [
screenId,
rightEmbedding.childScreenId,
rightEmbedding.position,
rightEmbedding.mode,
JSON.stringify(rightEmbedding.config || {}),
companyCode,
userId,
]);
const rightEmbeddingId = rightResult.rows[0].id;
// 3. 데이터 전달 설정 생성
const dataTransferQuery = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id
`;
const dataTransferResult = await client.query(dataTransferQuery, [
dataTransfer.sourceScreenId,
dataTransfer.targetScreenId,
dataTransfer.sourceComponentId,
dataTransfer.sourceComponentType,
JSON.stringify(dataTransfer.dataReceivers),
JSON.stringify(dataTransfer.buttonConfig || {}),
companyCode,
userId,
]);
const dataTransferId = dataTransferResult.rows[0].id;
// 4. 분할 패널 생성
const splitPanelQuery = `
INSERT INTO screen_split_panel (
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
layout_config, company_code, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *
`;
const splitPanelResult = await client.query(splitPanelQuery, [
screenId,
leftEmbeddingId,
rightEmbeddingId,
dataTransferId,
JSON.stringify(layoutConfig || {}),
companyCode,
]);
await client.query("COMMIT");
logger.info("분할 패널 설정 생성", {
companyCode,
userId,
screenId,
id: splitPanelResult.rows[0].id,
});
return res.status(201).json({
success: true,
data: splitPanelResult.rows[0],
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 생성 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
*
* PUT /api/screen-split-panel/:id
*/
export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const { layoutConfig } = req.body;
const companyCode = req.user!.companyCode;
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
const query = `
UPDATE screen_split_panel
SET layout_config = $1, updated_at = NOW()
WHERE id = $2 AND company_code = $3
RETURNING *
`;
const result = await pool.query(query, [
JSON.stringify(layoutConfig),
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
logger.info("분할 패널 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("분할 패널 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-split-panel/:id
*/
export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
const client = await pool.connect();
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
await client.query("BEGIN");
// 1. 분할 패널 조회
const selectQuery = `
SELECT left_embedding_id, right_embedding_id, data_transfer_id
FROM screen_split_panel
WHERE id = $1 AND company_code = $2
`;
const selectResult = await client.query(selectQuery, [id, companyCode]);
if (selectResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const { left_embedding_id, right_embedding_id, data_transfer_id } =
selectResult.rows[0];
// 2. 분할 패널 삭제
await client.query(
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
if (left_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[left_embedding_id, companyCode]
);
}
if (right_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[right_embedding_id, companyCode]
);
}
if (data_transfer_id) {
await client.query(
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
[data_transfer_id, companyCode]
);
}
await client.query("COMMIT");
logger.info("분할 패널 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "분할 패널 설정이 삭제되었습니다.",
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,26 +5,13 @@ import { AuthenticatedRequest } from "../types/auth";
// 화면 목록 조회
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
// 아니면 현재 사용자의 companyCode 사용
const targetCompanyCode = (companyCode as string) || userCompanyCode;
// 최고 관리자가 아닌 경우 자신의 회사 코드만 사용 가능
if (userCompanyCode !== "*" && targetCompanyCode !== userCompanyCode) {
return res.status(403).json({
success: false,
message: "다른 회사의 화면을 조회할 권한이 없습니다.",
});
}
const { companyCode } = req.user as any;
const { page = 1, size = 20, searchTerm } = req.query;
const result = await screenManagementService.getScreensByCompany(
targetCompanyCode,
companyCode,
parseInt(page as string),
parseInt(size as string),
searchTerm as string // 검색어 전달
parseInt(size as string)
);
res.json({
@ -73,29 +60,6 @@ export const getScreen = async (
}
};
// 화면에 할당된 메뉴 조회
export const getScreenMenu = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { companyCode } = req.user as any;
const menuInfo = await screenManagementService.getMenuByScreen(
parseInt(id),
companyCode
);
res.json({ success: true, data: menuInfo });
} catch (error) {
console.error("화면 메뉴 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "화면 메뉴 조회에 실패했습니다." });
}
};
// 화면 생성
export const createScreen = async (
req: AuthenticatedRequest,
@ -148,42 +112,11 @@ export const updateScreenInfo = async (
try {
const { id } = req.params;
const { companyCode } = req.user as any;
const {
screenName,
tableName,
description,
isActive,
// REST API 관련 필드 추가
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
console.log("화면 정보 수정 요청:", {
screenId: id,
dataSourceType,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
});
const { screenName, description, isActive } = req.body;
await screenManagementService.updateScreenInfo(
parseInt(id),
{
screenName,
tableName,
description,
isActive,
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
},
{ screenName, description, isActive },
companyCode
);
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
@ -325,53 +258,6 @@ export const getDeletedScreens = async (
}
};
// 활성 화면 일괄 삭제 (휴지통으로 이동)
export const bulkDeleteScreens = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { companyCode, userId } = req.user as any;
const { screenIds, deleteReason, force } = req.body;
if (!Array.isArray(screenIds) || screenIds.length === 0) {
return res.status(400).json({
success: false,
message: "삭제할 화면 ID 목록이 필요합니다.",
});
}
const result = await screenManagementService.bulkDeleteScreens(
screenIds,
companyCode,
userId,
deleteReason,
force || false
);
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
if (result.skippedCount > 0) {
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
}
return res.json({
success: true,
message,
result: {
deletedCount: result.deletedCount,
skippedCount: result.skippedCount,
errors: result.errors,
},
});
} catch (error) {
console.error("활성 화면 일괄 삭제 실패:", error);
return res.status(500).json({
success: false,
message: "일괄 삭제에 실패했습니다.",
});
}
};
// 휴지통 화면 일괄 영구 삭제
export const bulkPermanentDeleteScreens = async (
req: AuthenticatedRequest,
@ -416,118 +302,7 @@ export const bulkPermanentDeleteScreens = async (
}
};
// 연결된 모달 화면 감지 (화면 복사 전 확인)
export const detectLinkedScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const linkedScreens = await screenManagementService.detectLinkedModalScreens(
parseInt(id)
);
res.json({
success: true,
data: linkedScreens,
message: linkedScreens.length > 0
? `${linkedScreens.length}개의 연결된 모달 화면을 감지했습니다.`
: "연결된 모달 화면이 없습니다.",
});
} catch (error: any) {
console.error("연결된 화면 감지 실패:", error);
res.status(500).json({
success: false,
message: error.message || "연결된 화면 감지에 실패했습니다.",
});
}
};
// 화면명 중복 체크
export const checkDuplicateScreenName = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode, screenName } = req.body;
if (!companyCode || !screenName) {
res.status(400).json({
success: false,
message: "companyCode와 screenName은 필수입니다.",
});
return;
}
const isDuplicate =
await screenManagementService.checkDuplicateScreenName(
companyCode,
screenName
);
res.json({
success: true,
data: { isDuplicate },
message: isDuplicate
? "이미 존재하는 화면명입니다."
: "사용 가능한 화면명입니다.",
});
} catch (error: any) {
console.error("화면명 중복 체크 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면명 중복 체크에 실패했습니다.",
});
}
};
// 화면 일괄 복사 (메인 + 모달 화면들)
export const copyScreenWithModals = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { mainScreen, modalScreens, targetCompanyCode } = req.body;
const { companyCode, userId } = req.user as any;
if (!mainScreen || !mainScreen.screenName || !mainScreen.screenCode) {
res.status(400).json({
success: false,
message: "메인 화면 정보(screenName, screenCode)가 필요합니다.",
});
return;
}
const result = await screenManagementService.copyScreenWithModals({
sourceScreenId: parseInt(id),
companyCode,
userId,
targetCompanyCode, // 최고 관리자가 다른 회사로 복사할 때 사용
mainScreen: {
screenName: mainScreen.screenName,
screenCode: mainScreen.screenCode,
description: mainScreen.description,
},
modalScreens: modalScreens || [],
});
res.json({
success: true,
data: result,
message: `화면 복사가 완료되었습니다. (메인 1개 + 모달 ${result.modalScreens.length}개)`,
});
} catch (error: any) {
console.error("화면 일괄 복사 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 일괄 복사에 실패했습니다.",
});
}
};
// 화면 복사 (단일 - 하위 호환용)
// 화면 복사
export const copyScreen = async (
req: AuthenticatedRequest,
res: Response
@ -697,50 +472,6 @@ export const generateScreenCode = async (
}
};
// 여러 개의 화면 코드 일괄 생성
export const generateMultipleScreenCodes = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { companyCode, count } = req.body;
if (!companyCode || typeof companyCode !== "string") {
res.status(400).json({
success: false,
message: "회사 코드(companyCode)는 필수입니다.",
});
return;
}
if (!count || typeof count !== "number" || count < 1 || count > 100) {
res.status(400).json({
success: false,
message: "count는 1~100 사이의 숫자여야 합니다.",
});
return;
}
const screenCodes =
await screenManagementService.generateMultipleScreenCodes(
companyCode,
count
);
res.json({
success: true,
data: { screenCodes },
message: `${count}개의 화면 코드가 생성되었습니다.`,
});
} catch (error: any) {
console.error("화면 코드 일괄 생성 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 코드 일괄 생성에 실패했습니다.",
});
}
};
// 화면-메뉴 할당
export const assignScreenToMenu = async (
req: AuthenticatedRequest,

View File

@ -30,56 +30,20 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
}
};
/**
* (Select )
*/
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* ( )
*
* Query Parameters:
* - menuObjid: 메뉴 OBJID (, )
* - includeInactive: 비활성
*/
export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
const includeInactive = req.query.includeInactive === "true";
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
logger.info("카테고리 값 조회 요청", {
tableName,
columnName,
menuObjid,
companyCode,
});
const values = await tableCategoryValueService.getCategoryValues(
tableName,
columnName,
companyCode,
includeInactive,
menuObjid // ← menuObjid 전달
includeInactive
);
return res.json({
@ -97,37 +61,18 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
};
/**
* ( )
*
* Body:
* - menuObjid: 메뉴 OBJID ()
* -
*
*/
export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { menuObjid, ...value } = req.body;
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "menuObjid는 필수입니다",
});
}
logger.info("카테고리 값 추가 요청", {
tableName: value.tableName,
columnName: value.columnName,
menuObjid,
companyCode,
});
const value = req.body;
const newValue = await tableCategoryValueService.addCategoryValue(
value,
companyCode,
userId,
Number(menuObjid) // ← menuObjid 전달
userId
);
return res.status(201).json({
@ -210,16 +155,6 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon
});
} catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
// 사용 중인 경우 상세 에러 메시지 반환 (400)
if (error.message.includes("삭제할 수 없습니다")) {
return res.status(400).json({
success: false,
message: error.message,
});
}
// 기타 에러 (500)
return res.status(500).json({
success: false,
message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",
@ -301,329 +236,3 @@ export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Resp
}
};
// ================================================
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
// ================================================
/**
*
*
* GET /api/categories/column-mapping/:tableName/:menuObjid
*
* .
*
* @returns { logical_column: physical_column }
*/
export const getColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, menuObjid } = req.params;
if (!tableName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName과 menuObjid는 필수입니다",
});
}
logger.info("컬럼 매핑 조회", {
tableName,
menuObjid,
companyCode,
});
const mapping = await tableCategoryValueService.getColumnMapping(
tableName,
Number(menuObjid),
companyCode
);
return res.json({
success: true,
data: mapping,
});
} catch (error: any) {
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "컬럼 매핑 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* /
*
* POST /api/categories/column-mapping
*
* Body:
* - tableName: 테이블명
* - logicalColumnName: 논리적 (: status_stock)
* - physicalColumnName: 물리적 (: status)
* - menuObjid: 메뉴 OBJID
* - description: 설명 ()
*/
export const createColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
} = req.body;
// 입력 검증
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다",
});
}
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
const mapping = await tableCategoryValueService.createColumnMapping(
tableName,
logicalColumnName,
physicalColumnName,
Number(menuObjid),
companyCode,
userId,
description
);
return res.status(201).json({
success: true,
data: mapping,
message: "컬럼 매핑이 생성되었습니다",
});
} catch (error: any) {
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 생성 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
*
*
* GET /api/categories/logical-columns/:tableName/:menuObjid
*
* .
* ( )
*/
export const getLogicalColumns = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, menuObjid } = req.params;
if (!tableName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName과 menuObjid는 필수입니다",
});
}
logger.info("논리적 컬럼 목록 조회", {
tableName,
menuObjid,
companyCode,
});
const columns = await tableCategoryValueService.getLogicalColumns(
tableName,
Number(menuObjid),
companyCode
);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "논리적 컬럼 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
*
*
* DELETE /api/categories/column-mapping/:mappingId
*/
export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { mappingId } = req.params;
if (!mappingId) {
return res.status(400).json({
success: false,
message: "mappingId는 필수입니다",
});
}
logger.info("컬럼 매핑 삭제", {
mappingId,
companyCode,
});
await tableCategoryValueService.deleteColumnMapping(
Number(mappingId),
companyCode
);
return res.json({
success: true,
message: "컬럼 매핑이 삭제되었습니다",
});
} catch (error: any) {
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* +
*
* DELETE /api/categories/column-mapping/:tableName/:columnName
*
*
*/
export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
if (!tableName || !columnName) {
return res.status(400).json({
success: false,
message: "tableName과 columnName은 필수입니다",
});
}
logger.info("테이블+컬럼 기준 매핑 삭제", {
tableName,
columnName,
companyCode,
});
const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn(
tableName,
columnName,
companyCode
);
return res.json({
success: true,
message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`,
deletedCount,
});
} catch (error: any) {
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
*
*
* POST /api/table-categories/labels-by-codes
*
* Body:
* - valueCodes: 카테고리 (: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
*
* Response:
* - { [code]: label }
*/
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { valueCodes } = req.body;
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
return res.json({
success: true,
data: {},
});
}
logger.info("카테고리 코드로 라벨 조회", {
valueCodes,
companyCode,
});
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
valueCodes,
companyCode
);
return res.json({
success: true,
data: labels,
});
} catch (error: any) {
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 2
*
* GET /api/categories/second-level-menus
*
*
* 2
*/
export const getSecondLevelMenus = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
logger.info("2레벨 메뉴 목록 조회", { companyCode });
const menus = await tableCategoryValueService.getSecondLevelMenus(companyCode);
return res.json({
success: true,
data: menus,
});
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "2레벨 메뉴 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};

View File

@ -67,7 +67,7 @@ export class TableHistoryController {
const whereClause = whereConditions.join(" AND ");
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
// 이력 조회 쿼리
const historyQuery = `
SELECT
log_id,
@ -84,7 +84,7 @@ export class TableHistoryController {
full_row_after
FROM ${logTableName}
WHERE ${whereClause}
ORDER BY log_id DESC
ORDER BY changed_at DESC
LIMIT ${limitParam} OFFSET ${offsetParam}
`;
@ -196,7 +196,7 @@ export class TableHistoryController {
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
// 이력 조회 쿼리
const historyQuery = `
SELECT
log_id,
@ -213,7 +213,7 @@ export class TableHistoryController {
full_row_after
FROM ${logTableName}
${whereClause}
ORDER BY log_id DESC
ORDER BY changed_at DESC
LIMIT ${limitParam} OFFSET ${offsetParam}
`;

View File

@ -742,7 +742,6 @@ export async function getTableData(
sortBy,
sortOrder = "asc",
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
} = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
@ -750,7 +749,6 @@ export async function getTableData(
logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`);
logger.info(`자동 필터:`, autoFilter); // 🆕
logger.info(`데이터 필터:`, dataFilter); // 🆕
if (!tableName) {
const response: ApiResponse<null> = {
@ -767,33 +765,20 @@ export async function getTableData(
const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
// 🆕 현재 사용자 필터 적용
let enhancedSearch = { ...search };
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
if (shouldApplyAutoFilter && req.user) {
const filterColumn = autoFilter?.filterColumn || "company_code";
const userField = autoFilter?.userField || "companyCode";
if (autoFilter?.enabled && req.user) {
const filterColumn = autoFilter.filterColumn || "company_code";
const userField = autoFilter.userField || "companyCode";
const userValue = (req.user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
if (userValue) {
enhancedSearch[filterColumn] = userValue;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue: finalCompanyCode,
userValue,
tableName,
});
} else {
@ -811,7 +796,6 @@ export async function getTableData(
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy,
sortOrder,
dataFilter, // 🆕 데이터 필터 전달
});
logger.info(
@ -883,27 +867,6 @@ export async function addTableData(
const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
}
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
@ -1636,647 +1599,3 @@ export async function toggleLogTable(
res.status(500).json(response);
}
}
/**
* ( )
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description category_column_mapping의
*
* :
* - 2 "고객사관리" discount_type, rounding_type
* - 3 "고객등록", "고객조회" ()
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
// 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 (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",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ccm.logical_column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
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 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, [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
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
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.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, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
});
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패");
logger.error("에러 메시지:", error.message);
logger.error("에러 스택:", error.stack);
logger.error("에러 전체:", error);
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회에 실패했습니다.",
error: error.message,
stack: error.stack, // 디버깅용
});
}
}
/**
* API
*
* () .
*
* :
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
* mainData: Record<string, any>,
* subTables: Array<{
* tableName: string,
* linkColumn: { mainField: string, subColumn: string },
* items: Record<string, any>[],
* options?: {
* saveMainAsFirst?: boolean,
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
* mainMarkerColumn?: string,
* mainMarkerValue?: any,
* subMarkerValue?: any,
* deleteExistingBefore?: boolean,
* }
* }>,
* isUpdate?: boolean
* }
*/
export async function multiTableSave(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = require("../database/db").getPool();
const client = await pool.connect();
try {
const { mainTable, mainData, subTables, isUpdate } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info("=== 다중 테이블 저장 시작 ===", {
mainTable,
mainDataKeys: Object.keys(mainData || {}),
subTablesCount: subTables?.length || 0,
isUpdate,
companyCode,
});
// 유효성 검사
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
res.status(400).json({
success: false,
message: "메인 테이블 설정이 올바르지 않습니다.",
});
return;
}
if (!mainData || Object.keys(mainData).length === 0) {
res.status(400).json({
success: false,
message: "저장할 메인 데이터가 없습니다.",
});
return;
}
await client.query("BEGIN");
// 1. 메인 테이블 저장
const mainTableName = mainTable.tableName;
const pkColumn = mainTable.primaryKeyColumn;
const pkValue = mainData[pkColumn];
// company_code 자동 추가 (최고 관리자가 아닌 경우)
if (companyCode !== "*" && !mainData.company_code) {
mainData.company_code = companyCode;
}
let mainResult: any;
if (isUpdate && pkValue) {
// UPDATE
const updateColumns = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => mainData[col]);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateQuery = `
UPDATE "${mainTableName}"
SET ${updateColumns}${updatedAtClause}
WHERE "${pkColumn}" = $${updateValues.length + 1}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
RETURNING *
`;
const updateParams = companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
mainResult = await client.query(updateQuery, updateParams);
} else {
// INSERT
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
const values = Object.values(mainData);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateSetClause = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => `"${col}" = EXCLUDED."${col}"`)
.join(", ");
const insertQuery = `
INSERT INTO "${mainTableName}" (${columns})
VALUES (${placeholders})
ON CONFLICT ("${pkColumn}") DO UPDATE SET
${updateSetClause}${updatedAtClause}
RETURNING *
`;
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
mainResult = await client.query(insertQuery, values);
}
if (mainResult.rowCount === 0) {
throw new Error("메인 테이블 저장 실패");
}
const savedMainData = mainResult.rows[0];
const savedPkValue = savedMainData[pkColumn];
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
// 2. 서브 테이블 저장
const subTableResults: any[] = [];
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
itemsCount: items?.length || 0,
linkColumn,
options,
hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
await client.query(deleteQuery, deleteParams);
}
// 메인 데이터도 서브 테이블에 저장 (옵션)
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
logger.info(`saveMainAsFirst 옵션 확인:`, {
saveMainAsFirst: options?.saveMainAsFirst,
mainFieldMappings: options?.mainFieldMappings,
mainFieldMappingsLength: options?.mainFieldMappings?.length,
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};
// 메인 필드 매핑 적용
for (const mapping of options.mainFieldMappings) {
if (mapping.formField && mapping.targetColumn) {
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
}
}
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
}
// company_code 추가
if (companyCode !== "*") {
mainSubItem.company_code = companyCode;
}
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
const checkQuery = `
SELECT * FROM "${tableName}"
WHERE "${linkColumn.subColumn}" = $1
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
LIMIT 1
`;
const checkParams: any[] = [savedPkValue];
if (options.mainMarkerColumn) {
checkParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
SET ${updateColumns}
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
RETURNING *
`;
const updateParams = [...updateValues, savedPkValue];
if (options.mainMarkerColumn) {
updateParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
updateParams.push(companyCode);
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
} else {
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
INSERT INTO "${tableName}" (${mainSubColumns})
VALUES (${mainSubPlaceholders})
RETURNING *
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
}
}
// 서브 아이템들 저장
for (const item of items) {
// 연결 컬럼 값 설정
if (linkColumn?.subColumn) {
item[linkColumn.subColumn] = savedPkValue;
}
// company_code 추가
if (companyCode !== "*" && !item.company_code) {
item.company_code = companyCode;
}
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
const subValues = Object.values(item);
const subInsertQuery = `
INSERT INTO "${tableName}" (${subColumns})
VALUES (${subPlaceholders})
RETURNING *
`;
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
const subResult = await client.query(subInsertQuery, subValues);
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
}
logger.info(`서브 테이블 ${tableName} 저장 완료`);
}
await client.query("COMMIT");
logger.info("=== 다중 테이블 저장 완료 ===", {
mainTable: mainTableName,
mainPk: savedPkValue,
subTableResultsCount: subTableResults.length,
});
res.json({
success: true,
message: "다중 테이블 저장이 완료되었습니다.",
data: {
main: savedMainData,
subTables: subTableResults,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("다중 테이블 저장 실패:", {
message: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "다중 테이블 저장에 실패했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
*
* column_labels의 entity/category
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
if (!leftTable || !rightTable) {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
});
return;
}
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
data: {
leftTable,
rightTable,
relations,
},
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}

View File

@ -1,365 +0,0 @@
/**
*
* API
*/
import { Request, Response } from "express";
import { TaxInvoiceService } from "../services/taxInvoiceService";
import { logger } from "../utils/logger";
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
companyCode: string;
};
}
export class TaxInvoiceController {
/**
*
* GET /api/tax-invoice
*/
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const {
page = "1",
pageSize = "20",
invoice_type,
invoice_status,
start_date,
end_date,
search,
buyer_name,
cost_type,
} = req.query;
const result = await TaxInvoiceService.getList(companyCode, {
page: parseInt(page as string, 10),
pageSize: parseInt(pageSize as string, 10),
invoice_type: invoice_type as "sales" | "purchase" | undefined,
invoice_status: invoice_status as string | undefined,
start_date: start_date as string | undefined,
end_date: end_date as string | undefined,
search: search as string | undefined,
buyer_name: buyer_name as string | undefined,
cost_type: cost_type as any,
});
res.json({
success: true,
data: result.data,
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: Math.ceil(result.total / result.pageSize),
},
});
} catch (error: any) {
logger.error("세금계산서 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/:id
*/
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.getById(id, companyCode);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("세금계산서 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice
*/
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const data = req.body;
// 필수 필드 검증
if (!data.invoice_type) {
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
return;
}
if (!data.invoice_date) {
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
return;
}
if (data.supply_amount === undefined || data.supply_amount === null) {
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
return;
}
const result = await TaxInvoiceService.create(data, companyCode, userId);
res.status(201).json({
success: true,
data: result,
message: "세금계산서가 생성되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 생성 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
});
}
}
/**
*
* PUT /api/tax-invoice/:id
*/
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const data = req.body;
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 수정되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 수정 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
});
}
}
/**
*
* DELETE /api/tax-invoice/:id
*/
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.delete(id, companyCode, userId);
if (!result) {
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
return;
}
res.json({
success: true,
message: "세금계산서가 삭제되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 삭제 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/issue
*/
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const result = await TaxInvoiceService.issue(id, companyCode, userId);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 발행되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 발행 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
});
}
}
/**
*
* POST /api/tax-invoice/:id/cancel
*/
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { id } = req.params;
const { reason } = req.body;
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
if (!result) {
res.status(404).json({
success: false,
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
});
return;
}
res.json({
success: true,
data: result,
message: "세금계산서가 취소되었습니다.",
});
} catch (error: any) {
logger.error("세금계산서 취소 실패:", error);
res.status(500).json({
success: false,
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/monthly
*/
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const now = new Date();
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("월별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/tax-invoice/stats/cost-type
*/
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const targetYear = year ? parseInt(year as string, 10) : undefined;
const targetMonth = month ? parseInt(month as string, 10) : undefined;
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("비용 유형별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -1,206 +0,0 @@
/**
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { vehicleReportService } from "../services/vehicleReportService";
/**
*
* GET /api/vehicle/reports/daily
*/
export const getDailyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, userId, vehicleId } = req.query;
console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getDailyReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getDailyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "일별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/weekly
*/
export const getWeeklyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { year, month, userId, vehicleId } = req.query;
console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month });
const result = await vehicleReportService.getWeeklyReport(companyCode, {
year: year ? parseInt(year as string) : new Date().getFullYear(),
month: month ? parseInt(month as string) : new Date().getMonth() + 1,
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getWeeklyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "주별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/monthly
*/
export const getMonthlyReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { year, userId, vehicleId } = req.query;
console.log("📊 [getMonthlyReport] 요청:", { companyCode, year });
const result = await vehicleReportService.getMonthlyReport(companyCode, {
year: year ? parseInt(year as string) : new Date().getFullYear(),
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getMonthlyReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "월별 통계 조회에 실패했습니다.",
});
}
};
/**
* ()
* GET /api/vehicle/reports/summary
*/
export const getSummaryReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { period } = req.query; // today, week, month, year
console.log("📊 [getSummaryReport] 요청:", { companyCode, period });
const result = await vehicleReportService.getSummaryReport(
companyCode,
(period as string) || "today"
);
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getSummaryReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "요약 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/by-driver
*/
export const getDriverReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, limit } = req.query;
console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getDriverReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 10,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getDriverReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운전자별 통계 조회에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/reports/by-route
*/
export const getRouteReport = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { startDate, endDate, limit } = req.query;
console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate });
const result = await vehicleReportService.getRouteReport(companyCode, {
startDate: startDate as string,
endDate: endDate as string,
limit: limit ? parseInt(limit as string) : 10,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getRouteReport] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "구간별 통계 조회에 실패했습니다.",
});
}
};

View File

@ -1,301 +0,0 @@
/**
*
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { vehicleTripService } from "../services/vehicleTripService";
/**
*
* POST /api/vehicle/trip/start
*/
export const startTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body;
console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival });
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.startTrip({
userId,
companyCode,
vehicleId,
departure,
arrival,
departureName,
destinationName,
latitude,
longitude,
});
console.log("✅ [startTrip] 성공:", result);
res.json({
success: true,
data: result,
message: "운행이 시작되었습니다.",
});
} catch (error: any) {
console.error("❌ [startTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 시작에 실패했습니다.",
});
}
};
/**
*
* POST /api/vehicle/trip/end
*/
export const endTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tripId, latitude, longitude } = req.body;
console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId });
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.endTrip({
tripId,
userId,
companyCode,
latitude,
longitude,
});
console.log("✅ [endTrip] 성공:", result);
res.json({
success: true,
data: result,
message: "운행이 종료되었습니다.",
});
} catch (error: any) {
console.error("❌ [endTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 종료에 실패했습니다.",
});
}
};
/**
* ( )
* POST /api/vehicle/trip/location
*/
export const addTripLocation = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tripId, latitude, longitude, accuracy, speed } = req.body;
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
if (latitude === undefined || longitude === undefined) {
return res.status(400).json({
success: false,
message: "위치 정보(latitude, longitude)가 필요합니다.",
});
}
const result = await vehicleTripService.addLocation({
tripId,
userId,
companyCode,
latitude,
longitude,
accuracy,
speed,
});
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [addTripLocation] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "위치 기록에 실패했습니다.",
});
}
};
/**
*
* GET /api/vehicle/trips
*/
export const getTripList = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query;
console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate });
const result = await vehicleTripService.getTripList(companyCode, {
userId: userId as string,
vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
departure: departure as string,
arrival: arrival as string,
limit: limit ? parseInt(limit as string) : 50,
offset: offset ? parseInt(offset as string) : 0,
});
res.json({
success: true,
data: result.data,
total: result.total,
});
} catch (error: any) {
console.error("❌ [getTripList] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 이력 조회에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/vehicle/trips/:tripId
*/
export const getTripDetail = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.params;
console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId });
const result = await vehicleTripService.getTripDetail(tripId, companyCode);
if (!result) {
return res.status(404).json({
success: false,
message: "운행 정보를 찾을 수 없습니다.",
});
}
res.json({
success: true,
data: result,
});
} catch (error: any) {
console.error("❌ [getTripDetail] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 상세 조회에 실패했습니다.",
});
}
};
/**
* ( )
* GET /api/vehicle/trip/active
*/
export const getActiveTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const result = await vehicleTripService.getActiveTrip(userId, companyCode);
res.json({
success: true,
data: result,
hasActiveTrip: !!result,
});
} catch (error: any) {
console.error("❌ [getActiveTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "활성 운행 조회에 실패했습니다.",
});
}
};
/**
*
* POST /api/vehicle/trip/cancel
*/
export const cancelTrip = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode } = req.user as any;
const { tripId } = req.body;
if (!tripId) {
return res.status(400).json({
success: false,
message: "tripId가 필요합니다.",
});
}
const result = await vehicleTripService.cancelTrip(tripId, companyCode);
if (!result) {
return res.status(404).json({
success: false,
message: "취소할 운행을 찾을 수 없습니다.",
});
}
res.json({
success: true,
message: "운행이 취소되었습니다.",
});
} catch (error: any) {
console.error("❌ [cancelTrip] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "운행 취소에 실패했습니다.",
});
}
};

View File

@ -1,11 +1,7 @@
import {
DatabaseConnector,
ConnectionConfig,
QueryResult,
} from "../interfaces/DatabaseConnector";
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mysql from "mysql2/promise";
import * as mysql from 'mysql2/promise';
export class MariaDBConnector implements DatabaseConnector {
private connection: mysql.Connection | null = null;
@ -24,7 +20,7 @@ export class MariaDBConnector implements DatabaseConnector {
password: this.config.password,
database: this.config.database,
connectTimeout: this.config.connectionTimeoutMillis,
ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl,
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
});
}
}
@ -40,9 +36,7 @@ export class MariaDBConnector implements DatabaseConnector {
const startTime = Date.now();
try {
await this.connect();
const [rows] = await this.connection!.query(
"SELECT VERSION() as version"
);
const [rows] = await this.connection!.query("SELECT VERSION() as version");
const version = (rows as any[])[0]?.version || "Unknown";
const responseTime = Date.now() - startTime;
await this.disconnect();
@ -95,13 +89,15 @@ export class MariaDBConnector implements DatabaseConnector {
ORDER BY TABLE_NAME;
`);
// 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회)
const tables: TableInfo[] = (rows as any[]).map((row) => ({
table_name: row.table_name,
description: row.description || null,
columns: [],
}));
const tables: TableInfo[] = [];
for (const row of rows as any[]) {
const columns = await this.getColumns(row.table_name);
tables.push({
table_name: row.table_name,
description: row.description || null,
columns: columns,
});
}
await this.disconnect();
return tables;
} catch (error: any) {
@ -115,43 +111,21 @@ export class MariaDBConnector implements DatabaseConnector {
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
await this.connect();
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
const [rows] = await this.connection!.query(
`
const [rows] = await this.connection!.query(`
SELECT
c.COLUMN_NAME AS column_name,
c.DATA_TYPE AS data_type,
c.IS_NULLABLE AS is_nullable,
c.COLUMN_DEFAULT AS column_default,
c.COLUMN_COMMENT AS description,
CASE
WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES'
ELSE 'NO'
END AS is_primary_key
FROM information_schema.COLUMNS c
LEFT JOIN information_schema.KEY_COLUMN_USAGE k
ON c.TABLE_SCHEMA = k.TABLE_SCHEMA
AND c.TABLE_NAME = k.TABLE_NAME
AND c.COLUMN_NAME = k.COLUMN_NAME
LEFT JOIN information_schema.TABLE_CONSTRAINTS tc
ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA
AND k.TABLE_NAME = tc.TABLE_NAME
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
WHERE c.TABLE_SCHEMA = DATABASE()
AND c.TABLE_NAME = ?
ORDER BY c.ORDINAL_POSITION;
`,
[tableName]
);
COLUMN_NAME as column_name,
DATA_TYPE as data_type,
IS_NULLABLE as is_nullable,
COLUMN_DEFAULT as column_default
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
`, [tableName]);
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
console.log(
`[MariaDBConnector] 결과 개수:`,
Array.isArray(rows) ? rows.length : "not array"
);
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
await this.disconnect();
return rows as any[];
} catch (error: any) {

View File

@ -210,33 +210,15 @@ export class PostgreSQLConnector implements DatabaseConnector {
const result = await tempClient.query(
`
SELECT
isc.column_name,
isc.data_type,
isc.is_nullable,
isc.column_default,
col_description(c.oid, a.attnum) as column_comment,
CASE
WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES'
ELSE 'NO'
END AS is_primary_key
column_name,
data_type,
is_nullable,
column_default,
col_description(c.oid, a.attnum) as column_comment
FROM information_schema.columns isc
LEFT JOIN pg_class c
ON c.relname = isc.table_name
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema)
LEFT JOIN pg_attribute a
ON a.attrelid = c.oid
AND a.attname = isc.column_name
LEFT JOIN information_schema.key_column_usage k
ON k.table_name = isc.table_name
AND k.table_schema = isc.table_schema
AND k.column_name = isc.column_name
LEFT JOIN information_schema.table_constraints tc
ON tc.constraint_name = k.constraint_name
AND tc.table_schema = k.table_schema
AND tc.table_name = k.table_name
AND tc.constraint_type = 'PRIMARY KEY'
WHERE isc.table_schema = 'public'
AND isc.table_name = $1
LEFT JOIN pg_class c ON c.relname = isc.table_name
LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name
WHERE isc.table_schema = 'public' AND isc.table_name = $1
ORDER BY isc.ordinal_position;
`,
[tableName]

View File

@ -1,5 +1,4 @@
import axios, { AxiosInstance, AxiosResponse } from "axios";
import https from "https";
import {
DatabaseConnector,
ConnectionConfig,
@ -25,26 +24,16 @@ export class RestApiConnector implements DatabaseConnector {
constructor(config: RestApiConfig) {
this.config = config;
// Axios 인스턴스 생성
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
const defaultHeaders: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.apiKey) {
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
}
this.httpClient = axios.create({
baseURL: config.baseUrl,
timeout: config.timeout || 30000,
headers: defaultHeaders,
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
// 공개 인터넷용 API에는 적용하면 안 된다.
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
Accept: "application/json",
},
});
// 요청/응답 인터셉터 설정
@ -86,16 +75,26 @@ export class RestApiConnector implements DatabaseConnector {
}
async connect(): Promise<void> {
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
//
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
console.log(
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
);
return;
try {
// 연결 테스트 - 기본 엔드포인트 호출
await this.httpClient.get("/health", { timeout: 5000 });
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
} catch (error) {
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
if (axios.isAxiosError(error) && error.response?.status === 404) {
console.log(
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
);
return;
}
console.error(
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
error
);
throw new Error(
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
}
}
async disconnect(): Promise<void> {

View File

@ -54,17 +54,16 @@ export const authenticateToken = (
next();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
logger.error(
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
);
// 토큰 만료 에러인지 확인
const isTokenExpired = errorMessage.includes("만료");
res.status(401).json({
success: false,
error: {
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
details: errorMessage || "토큰 검증에 실패했습니다.",
code: "INVALID_TOKEN",
details:
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
},
});
}

View File

@ -28,16 +28,6 @@ export const errorHandler = (
// PostgreSQL 에러 처리 (pg 라이브러리)
if ((err as any).code) {
const pgError = err as any;
// 원본 에러 메시지 로깅 (디버깅용)
console.error("🔴 PostgreSQL Error:", {
code: pgError.code,
message: pgError.message,
detail: pgError.detail,
hint: pgError.hint,
table: pgError.table,
column: pgError.column,
constraint: pgError.constraint,
});
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") {
// unique_violation
@ -52,7 +42,7 @@ export const errorHandler = (
// 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
} else {
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
}
}

View File

@ -8,7 +8,6 @@ import {
deleteMenu, // 메뉴 삭제
deleteMenusBatch, // 메뉴 일괄 삭제
toggleMenuStatus, // 메뉴 상태 토글
copyMenu, // 메뉴 복사
getUserList,
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
@ -18,11 +17,8 @@ import {
getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!)
getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회
createCompany, // 회사 등록
updateCompany, // 회사 수정
deleteCompany, // 회사 삭제
@ -42,7 +38,6 @@ router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
@ -52,10 +47,8 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
@ -67,7 +60,6 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회
// 회사 관리 API
router.get("/companies", getCompanyList);
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회
router.post("/companies", createCompany); // 회사 등록
router.put("/companies/:companyCode", updateCompany); // 회사 수정
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제

View File

@ -41,16 +41,4 @@ router.post("/logout", AuthController.logout);
*/
router.post("/refresh", AuthController.refreshToken);
/**
* POST /api/auth/signup
* API
*/
router.post("/signup", AuthController.signup);
/**
* POST /api/auth/switch-company
* WACE 전용: 다른
*/
router.post("/switch-company", AuthController.switchCompany);
export default router;

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