Compare commits
31 Commits
main
...
logistream
| Author | SHA1 | Date |
|---|---|---|
|
|
b190e2ba08 | |
|
|
2ae43c879f | |
|
|
166004e8fd | |
|
|
08d4d7dbfc | |
|
|
b787b027a6 | |
|
|
c38153eff1 | |
|
|
0c897ad8fd | |
|
|
8e455def0c | |
|
|
10c16c818a | |
|
|
6cbe200f00 | |
|
|
660ddb0f95 | |
|
|
0b61ef4d12 | |
|
|
8f926f6887 | |
|
|
cb8184735c | |
|
|
ec26aa1bac | |
|
|
22b6404a5b | |
|
|
a2e58c3848 | |
|
|
ff21a84932 | |
|
|
91a4401120 | |
|
|
1514af2383 | |
|
|
c669374156 | |
|
|
cf2b5d4e80 | |
|
|
3153cf0383 | |
|
|
198f9a6f2b | |
|
|
b47f34c616 | |
|
|
8508e64ab3 | |
|
|
12b5c4243a | |
|
|
802cda7348 | |
|
|
70e97aa4a2 | |
|
|
f340b1ac05 | |
|
|
50dbf1f738 |
|
|
@ -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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
|
||||||
|
|
@ -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` - 스티키 헤더 테이블
|
|
||||||
|
|
@ -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 |
51
Dockerfile
51
Dockerfile
|
|
@ -39,8 +39,10 @@ RUN npm ci && \
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||||
|
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||||
RUN npm run build:no-lint
|
RUN npm run build:no-lint
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|
@ -66,9 +68,19 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./
|
||||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
|
||||||
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
|
||||||
|
|
||||||
# 업로드 디렉토리 생성 (백엔드용)
|
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
|
||||||
RUN mkdir -p /app/backend/uploads && \
|
# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
|
||||||
chown -R nodejs:nodejs /app/backend/uploads
|
# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
|
||||||
|
RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \
|
||||||
|
/app/uploads /app/data && \
|
||||||
|
chown -R nodejs:nodejs /app/backend /app/uploads /app/data && \
|
||||||
|
chmod -R 777 /app/uploads /app/data && \
|
||||||
|
chmod -R 755 /app/backend
|
||||||
|
|
||||||
|
# 프론트엔드 standalone 모드를 위한 디렉토리 생성
|
||||||
|
RUN mkdir -p /app/frontend/data && \
|
||||||
|
chown -R nodejs:nodejs /app/frontend && \
|
||||||
|
chmod -R 755 /app/frontend
|
||||||
|
|
||||||
# 시작 스크립트 생성
|
# 시작 스크립트 생성
|
||||||
RUN echo '#!/bin/sh' > /app/start.sh && \
|
RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||||
|
|
@ -77,29 +89,44 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
|
||||||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||||
echo 'cd /app/backend' >> /app/start.sh && \
|
echo 'cd /app/backend' >> /app/start.sh && \
|
||||||
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
|
||||||
echo 'node dist/app.js &' >> /app/start.sh && \
|
echo 'PORT=8080 node dist/app.js &' >> /app/start.sh && \
|
||||||
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
echo 'BACKEND_PID=$!' >> /app/start.sh && \
|
||||||
echo '' >> /app/start.sh && \
|
echo '' >> /app/start.sh && \
|
||||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||||
echo 'npm start &' >> /app/start.sh && \
|
echo 'PORT=3000 exec npm start' >> /app/start.sh && \
|
||||||
echo 'FRONTEND_PID=$!' >> /app/start.sh && \
|
|
||||||
echo '' >> /app/start.sh && \
|
|
||||||
echo '# 프로세스 모니터링' >> /app/start.sh && \
|
|
||||||
echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
|
|
||||||
chmod +x /app/start.sh && \
|
chmod +x /app/start.sh && \
|
||||||
chown nodejs:nodejs /app/start.sh
|
chown nodejs:nodejs /app/start.sh
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 환경변수 설정 (임시 조치)
|
||||||
|
# helm-charts의 values_logistream.yaml 관리자가 설정 완료 시 삭제 예정
|
||||||
|
# ============================================================
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
|
||||||
|
JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \
|
||||||
|
ENCRYPTION_KEY="ilshin-plm-mail-encryption-key-32characters-2024-secure" \
|
||||||
|
JWT_EXPIRES_IN="24h" \
|
||||||
|
CORS_CREDENTIALS="true" \
|
||||||
|
CORS_ORIGIN="https://logistream.kpslp.kr" \
|
||||||
|
KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
|
||||||
|
ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
|
||||||
|
NEXT_TELEMETRY_DISABLED="1" \
|
||||||
|
NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||||
|
|
||||||
# 비특권 사용자로 전환
|
# 비특권 사용자로 전환
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 3000 8080
|
EXPOSE 3000 8080
|
||||||
|
|
||||||
# 헬스체크
|
# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health && \
|
||||||
|
wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
# 컨테이너 시작
|
# 컨테이너 시작
|
||||||
CMD ["/app/start.sh"]
|
CMD ["/app/start.sh"]
|
||||||
|
|
|
||||||
70
PLAN.MD
70
PLAN.MD
|
|
@ -1,72 +1,4 @@
|
||||||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||||
|
|
||||||
## 개요
|
|
||||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
|
||||||
|
|
||||||
## 핵심 기능
|
|
||||||
|
|
||||||
### 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 등 다양한 외부 시스템과의 연동을 지원합니다.
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
|
|
|
||||||
680
PLAN_RENEWAL.md
680
PLAN_RENEWAL.md
|
|
@ -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건)를
|
|
||||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
|
||||||
|
|
@ -12,15 +12,12 @@
|
||||||
"@types/mssql": "^9.1.8",
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bwip-js": "^4.8.0",
|
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"docx": "^9.5.1",
|
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"html-to-docx": "^1.8.0",
|
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
|
|
@ -37,12 +34,11 @@
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/bwip-js": "^3.2.3",
|
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
@ -1044,7 +1040,6 @@
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
|
|
@ -2261,93 +2256,6 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -3217,16 +3124,6 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/compression": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3713,7 +3609,6 @@
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
|
|
@ -3931,7 +3826,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4432,12 +4326,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.26.2",
|
"version": "4.26.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||||
|
|
@ -4458,7 +4346,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
|
|
@ -4558,15 +4445,6 @@
|
||||||
"node": ">=10.16.0"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
|
@ -4643,15 +4521,6 @@
|
||||||
"node": ">=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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001745",
|
"version": "1.0.30001745",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
||||||
|
|
@ -5333,56 +5202,6 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/domelementtype": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
|
@ -5535,27 +5349,6 @@
|
||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
|
@ -5568,16 +5361,6 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
|
|
@ -5861,14 +5643,6 @@
|
||||||
"node": ">= 0.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": {
|
"node_modules/event-target-shim": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
|
@ -6505,16 +6279,6 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/globals": {
|
||||||
"version": "13.24.0",
|
"version": "13.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||||
|
|
@ -6649,16 +6413,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|
@ -6689,22 +6443,6 @@
|
||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|
@ -6712,27 +6450,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
|
@ -6749,106 +6466,6 @@
|
||||||
"node": ">=14"
|
"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": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
|
@ -6973,30 +6590,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/imap": {
|
||||||
"version": "0.8.19",
|
"version": "0.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||||
|
|
@ -7033,12 +6626,6 @@
|
||||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -7086,11 +6673,6 @@
|
||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
|
@ -7272,15 +6854,6 @@
|
||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/is-path-inside": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||||
|
|
@ -7432,7 +7005,6 @@
|
||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
|
|
@ -8124,18 +7696,6 @@
|
||||||
"npm": ">=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": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
|
@ -8252,15 +7812,6 @@
|
||||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -8625,21 +8177,6 @@
|
||||||
"node": ">=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": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||||
|
|
@ -8763,24 +8300,6 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/native-duplexpair": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||||
|
|
@ -8810,12 +8329,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-cron": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
|
|
@ -9157,12 +8670,6 @@
|
||||||
"node": ">=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": {
|
"node_modules/parchment": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
@ -9673,15 +9179,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -10098,23 +9595,6 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/safe-stable-stringify": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
|
@ -10130,17 +9610,12 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -10269,12 +9744,6 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -10551,11 +10020,6 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
|
@ -10949,7 +10413,6 @@
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
|
@ -11055,7 +10518,6 @@
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -11180,16 +10642,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist-node/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
|
@ -11223,22 +10685,6 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|
@ -11416,80 +10862,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,12 @@
|
||||||
"@types/mssql": "^9.1.8",
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bwip-js": "^4.8.0",
|
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"docx": "^9.5.1",
|
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"html-to-docx": "^1.8.0",
|
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
|
|
@ -51,12 +48,11 @@
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/bwip-js": "^3.2.3",
|
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import path from "path";
|
||||||
import config from "./config/environment";
|
import config from "./config/environment";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { errorHandler } from "./middleware/errorHandler";
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
|
||||||
|
|
||||||
// 라우터 임포트
|
// 라우터 임포트
|
||||||
import authRoutes from "./routes/authRoutes";
|
import authRoutes from "./routes/authRoutes";
|
||||||
|
|
@ -58,7 +57,6 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
||||||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
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 materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||||
|
|
@ -72,17 +70,8 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
|
||||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
|
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
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 { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -177,10 +166,6 @@ const limiter = rateLimit({
|
||||||
});
|
});
|
||||||
app.use("/api/", limiter);
|
app.use("/api/", limiter);
|
||||||
|
|
||||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
|
||||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
|
||||||
app.use("/api/", refreshTokenIfNeeded);
|
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
@ -198,7 +183,6 @@ app.use("/api/multilang", multilangRoutes);
|
||||||
app.use("/api/table-management", tableManagementRoutes);
|
app.use("/api/table-management", tableManagementRoutes);
|
||||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||||
app.use("/api/screen-management", screenManagementRoutes);
|
app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
|
@ -223,7 +207,6 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||||
app.use("/api/screen-files", screenFileRoutes);
|
app.use("/api/screen-files", screenFileRoutes);
|
||||||
app.use("/api/batch-configs", batchRoutes);
|
app.use("/api/batch-configs", batchRoutes);
|
||||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
|
||||||
app.use("/api/batch-management", batchManagementRoutes);
|
app.use("/api/batch-management", batchManagementRoutes);
|
||||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||||
|
|
@ -253,16 +236,8 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
||||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||||
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", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
|
||||||
|
|
@ -632,9 +632,6 @@ export class DashboardController {
|
||||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 정보 (응답에 포함용)
|
|
||||||
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
|
||||||
|
|
||||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||||
if (externalConnectionId) {
|
if (externalConnectionId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -655,11 +652,6 @@ export class DashboardController {
|
||||||
if (connectionResult.success && connectionResult.data) {
|
if (connectionResult.success && connectionResult.data) {
|
||||||
const connection = connectionResult.data;
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
// 연결 정보 저장 (응답에 포함)
|
|
||||||
connectionInfo = {
|
|
||||||
saveToHistory: connection.save_to_history === "Y",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 인증 헤더 생성 (DB 토큰 등)
|
// 인증 헤더 생성 (DB 토큰 등)
|
||||||
const authHeaders =
|
const authHeaders =
|
||||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||||
|
|
@ -702,15 +694,6 @@ export class DashboardController {
|
||||||
requestConfig.data = body;
|
requestConfig.data = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 디버깅 로그: 실제 요청 정보 출력
|
|
||||||
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
|
||||||
url: requestConfig.url,
|
|
||||||
method: requestConfig.method,
|
|
||||||
headers: requestConfig.headers,
|
|
||||||
body: requestConfig.data,
|
|
||||||
externalConnectionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||||
const bypassDomains = ["thiratis.com"];
|
const bypassDomains = ["thiratis.com"];
|
||||||
|
|
@ -725,12 +708,6 @@ export class DashboardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
|
||||||
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
|
||||||
if (isKmaApi) {
|
|
||||||
requestConfig.responseType = "arraybuffer";
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios(requestConfig);
|
const response = await axios(requestConfig);
|
||||||
|
|
||||||
if (response.status >= 400) {
|
if (response.status >= 400) {
|
||||||
|
|
@ -742,35 +719,14 @@ export class DashboardController {
|
||||||
let data = response.data;
|
let data = response.data;
|
||||||
const contentType = response.headers["content-type"];
|
const contentType = response.headers["content-type"];
|
||||||
|
|
||||||
// 기상청 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" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 텍스트 응답인 경우 포맷팅
|
// 텍스트 응답인 경우 포맷팅
|
||||||
else if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
data = { text: data, contentType };
|
data = { text: data, contentType };
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status || 500;
|
const status = error.response?.status || 500;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { ApiResponse } from "../types/common";
|
import { ApiResponse } from "../types/common";
|
||||||
import { Client } from "pg";
|
import { Client } from "pg";
|
||||||
import { query, queryOne, getPool } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
import config from "../config/environment";
|
import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
|
|
@ -553,24 +553,10 @@ export const setUserLocale = async (
|
||||||
|
|
||||||
const { locale } = req.body;
|
const { locale } = req.body;
|
||||||
|
|
||||||
if (!locale) {
|
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "로케일이 필요합니다.",
|
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
|
||||||
const validLang = await queryOne<{ lang_code: string }>(
|
|
||||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
|
||||||
[locale]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validLang) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: `유효하지 않은 로케일입니다: ${locale}`,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1179,33 +1165,6 @@ export async function saveMenu(
|
||||||
|
|
||||||
logger.info("메뉴 저장 성공", { savedMenu });
|
logger.info("메뉴 저장 성공", { savedMenu });
|
||||||
|
|
||||||
// 다국어 메뉴 카테고리 자동 생성
|
|
||||||
try {
|
|
||||||
const { MultiLangService } = await import("../services/multilangService");
|
|
||||||
const multilangService = new MultiLangService();
|
|
||||||
|
|
||||||
// 회사명 조회
|
|
||||||
const companyInfo = await queryOne<{ company_name: string }>(
|
|
||||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
|
||||||
[companyCode]
|
|
||||||
);
|
|
||||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
|
||||||
|
|
||||||
// 메뉴 경로 조회 및 카테고리 생성
|
|
||||||
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
|
||||||
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
|
||||||
|
|
||||||
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
|
||||||
menuObjId: savedMenu.objid.toString(),
|
|
||||||
menuPath,
|
|
||||||
});
|
|
||||||
} catch (categoryError) {
|
|
||||||
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
|
||||||
menuObjId: savedMenu.objid.toString(),
|
|
||||||
error: categoryError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||||
|
|
@ -1297,17 +1256,8 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestCompanyCode =
|
const requestCompanyCode =
|
||||||
menuData.companyCode || menuData.company_code;
|
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
||||||
|
|
||||||
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
|
||||||
if (
|
|
||||||
requestCompanyCode === "none" ||
|
|
||||||
requestCompanyCode === "" ||
|
|
||||||
!requestCompanyCode
|
|
||||||
) {
|
|
||||||
requestCompanyCode = currentMenu.company_code;
|
|
||||||
}
|
|
||||||
|
|
||||||
// company_code 변경 시도하는 경우 권한 체크
|
// company_code 변경 시도하는 경우 권한 체크
|
||||||
if (requestCompanyCode !== currentMenu.company_code) {
|
if (requestCompanyCode !== currentMenu.company_code) {
|
||||||
|
|
@ -1417,75 +1367,6 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수
|
|
||||||
*/
|
|
||||||
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
|
||||||
const allIds: number[] = [];
|
|
||||||
|
|
||||||
// 직접 자식 메뉴들 조회
|
|
||||||
const children = await query<any>(
|
|
||||||
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
|
|
||||||
[parentObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
allIds.push(child.objid);
|
|
||||||
// 자식의 자식들도 재귀적으로 수집
|
|
||||||
const grandChildren = await collectAllChildMenuIds(child.objid);
|
|
||||||
allIds.push(...grandChildren);
|
|
||||||
}
|
|
||||||
|
|
||||||
return allIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
|
||||||
*/
|
|
||||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
|
||||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 7. screen_groups에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 삭제
|
* 메뉴 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1512,7 +1393,7 @@ export async function deleteMenu(
|
||||||
|
|
||||||
// 삭제하려는 메뉴 조회
|
// 삭제하려는 메뉴 조회
|
||||||
const currentMenu = await queryOne<any>(
|
const currentMenu = await queryOne<any>(
|
||||||
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
|
||||||
[Number(menuId)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1547,50 +1428,26 @@ export async function deleteMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuObjid = Number(menuId);
|
// Raw Query를 사용한 메뉴 삭제
|
||||||
|
const [deletedMenu] = await query<any>(
|
||||||
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
|
[Number(menuId)]
|
||||||
|
);
|
||||||
|
|
||||||
// 하위 메뉴들 재귀적으로 수집
|
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||||
const childMenuIds = await collectAllChildMenuIds(menuObjid);
|
|
||||||
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
|
|
||||||
|
|
||||||
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}개`, {
|
|
||||||
menuName: currentMenu.menu_name_kor,
|
|
||||||
totalCount: allMenuIdsToDelete.length,
|
|
||||||
childMenuIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
|
||||||
for (const objid of allMenuIdsToDelete) {
|
|
||||||
await cleanupMenuRelatedData(objid);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
|
||||||
menuObjid,
|
|
||||||
totalCleaned: allMenuIdsToDelete.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
|
|
||||||
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
|
|
||||||
const reversedIds = [...allMenuIdsToDelete].reverse();
|
|
||||||
|
|
||||||
for (const objid of reversedIds) {
|
|
||||||
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", {
|
|
||||||
deletedMenuObjid: menuObjid,
|
|
||||||
deletedMenuName: currentMenu.menu_name_kor,
|
|
||||||
totalDeleted: allMenuIdsToDelete.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
|
message: "메뉴가 성공적으로 삭제되었습니다.",
|
||||||
data: {
|
data: {
|
||||||
objid: menuObjid.toString(),
|
objid: deletedMenu.objid.toString(),
|
||||||
menuNameKor: currentMenu.menu_name_kor,
|
menuNameKor: deletedMenu.menu_name_kor,
|
||||||
deletedCount: allMenuIdsToDelete.length,
|
menuNameEng: deletedMenu.menu_name_eng,
|
||||||
deletedChildCount: childMenuIds.length,
|
menuUrl: deletedMenu.menu_url,
|
||||||
|
menuDesc: deletedMenu.menu_desc,
|
||||||
|
status: deletedMenu.status,
|
||||||
|
writer: deletedMenu.writer,
|
||||||
|
regdate: new Date(deletedMenu.regdate).toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1675,49 +1532,18 @@ export async function deleteMenusBatch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
|
|
||||||
const allMenuIdsToDelete = new Set<number>();
|
|
||||||
|
|
||||||
for (const menuId of menuIds) {
|
|
||||||
const objid = Number(menuId);
|
|
||||||
allMenuIdsToDelete.add(objid);
|
|
||||||
|
|
||||||
// 하위 메뉴들 재귀적으로 수집
|
|
||||||
const childMenuIds = await collectAllChildMenuIds(objid);
|
|
||||||
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const allIdsArray = Array.from(allMenuIdsToDelete);
|
|
||||||
|
|
||||||
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, {
|
|
||||||
selectedMenuIds: menuIds,
|
|
||||||
totalWithChildren: allIdsArray.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
|
||||||
for (const objid of allIdsArray) {
|
|
||||||
await cleanupMenuRelatedData(objid);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
|
||||||
totalCleaned: allIdsArray.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 일괄 삭제
|
// Raw Query를 사용한 메뉴 일괄 삭제
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
const deletedMenus: any[] = [];
|
const deletedMenus: any[] = [];
|
||||||
const failedMenuIds: string[] = [];
|
const failedMenuIds: string[] = [];
|
||||||
|
|
||||||
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
|
|
||||||
const reversedIds = [...allIdsArray].reverse();
|
|
||||||
|
|
||||||
// 각 메뉴 ID에 대해 삭제 시도
|
// 각 메뉴 ID에 대해 삭제 시도
|
||||||
for (const menuObjid of reversedIds) {
|
for (const menuId of menuIds) {
|
||||||
try {
|
try {
|
||||||
const result = await query<any>(
|
const result = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[menuObjid]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|
@ -1728,20 +1554,20 @@ export async function deleteMenusBatch(
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(String(menuObjid));
|
failedMenuIds.push(menuId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
|
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(String(menuObjid));
|
failedMenuIds.push(menuId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("메뉴 일괄 삭제 완료", {
|
logger.info("메뉴 일괄 삭제 완료", {
|
||||||
requested: menuIds.length,
|
total: menuIds.length,
|
||||||
totalWithChildren: allIdsArray.length,
|
|
||||||
deletedCount,
|
deletedCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
|
deletedMenus,
|
||||||
failedMenuIds,
|
failedMenuIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2773,24 +2599,6 @@ export const createCompany = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다국어 카테고리 자동 생성
|
|
||||||
try {
|
|
||||||
const { MultiLangService } = await import("../services/multilangService");
|
|
||||||
const multilangService = new MultiLangService();
|
|
||||||
await multilangService.ensureCompanyCategory(
|
|
||||||
createdCompany.company_code,
|
|
||||||
createdCompany.company_name
|
|
||||||
);
|
|
||||||
logger.info("회사 다국어 카테고리 생성 완료", {
|
|
||||||
companyCode: createdCompany.company_code,
|
|
||||||
});
|
|
||||||
} catch (categoryError) {
|
|
||||||
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
|
|
||||||
companyCode: createdCompany.company_code,
|
|
||||||
error: categoryError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("회사 등록 성공", {
|
logger.info("회사 등록 성공", {
|
||||||
companyCode: createdCompany.company_code,
|
companyCode: createdCompany.company_code,
|
||||||
companyName: createdCompany.company_name,
|
companyName: createdCompany.company_name,
|
||||||
|
|
@ -3200,23 +3008,6 @@ export const updateProfile = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locale !== undefined) {
|
if (locale !== undefined) {
|
||||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
|
||||||
const validLang = await queryOne<{ lang_code: string }>(
|
|
||||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
|
||||||
[locale]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validLang) {
|
|
||||||
res.status(400).json({
|
|
||||||
result: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_LOCALE",
|
|
||||||
details: `유효하지 않은 로케일입니다: ${locale}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFields.push(`locale = $${paramIndex}`);
|
updateFields.push(`locale = $${paramIndex}`);
|
||||||
updateValues.push(locale);
|
updateValues.push(locale);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -3404,7 +3195,6 @@ export const resetUserPassword = async (
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||||
* column_labels 테이블에서 라벨 정보도 함께 가져옴
|
|
||||||
*/
|
*/
|
||||||
export async function getTableSchema(
|
export async function getTableSchema(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -3424,25 +3214,20 @@ export async function getTableSchema(
|
||||||
|
|
||||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||||
|
|
||||||
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
// information_schema에서 컬럼 정보 가져오기
|
||||||
const schemaQuery = `
|
const schemaQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
ic.column_name,
|
column_name,
|
||||||
ic.data_type,
|
data_type,
|
||||||
ic.is_nullable,
|
is_nullable,
|
||||||
ic.column_default,
|
column_default,
|
||||||
ic.character_maximum_length,
|
character_maximum_length,
|
||||||
ic.numeric_precision,
|
numeric_precision,
|
||||||
ic.numeric_scale,
|
numeric_scale
|
||||||
cl.column_label,
|
FROM information_schema.columns
|
||||||
cl.display_order
|
WHERE table_schema = 'public'
|
||||||
FROM information_schema.columns ic
|
AND table_name = $1
|
||||||
LEFT JOIN column_labels cl
|
ORDER BY ordinal_position
|
||||||
ON cl.table_name = ic.table_name
|
|
||||||
AND cl.column_name = ic.column_name
|
|
||||||
WHERE ic.table_schema = 'public'
|
|
||||||
AND ic.table_name = $1
|
|
||||||
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const columns = await query<any>(schemaQuery, [tableName]);
|
const columns = await query<any>(schemaQuery, [tableName]);
|
||||||
|
|
@ -3455,10 +3240,9 @@ export async function getTableSchema(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
// 컬럼 정보를 간단한 형태로 변환
|
||||||
const columnList = columns.map((col: any) => ({
|
const columnList = columns.map((col: any) => ({
|
||||||
name: col.column_name,
|
name: col.column_name,
|
||||||
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
|
||||||
type: col.data_type,
|
type: col.data_type,
|
||||||
nullable: col.is_nullable === "YES",
|
nullable: col.is_nullable === "YES",
|
||||||
default: col.column_default,
|
default: col.column_default,
|
||||||
|
|
@ -3553,23 +3337,13 @@ export async function copyMenu(
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
|
||||||
const additionalCopyOptions = req.body.additionalCopyOptions
|
|
||||||
? {
|
|
||||||
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
|
||||||
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
|
||||||
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// 메뉴 복사 실행
|
// 메뉴 복사 실행
|
||||||
const menuCopyService = new MenuCopyService();
|
const menuCopyService = new MenuCopyService();
|
||||||
const result = await menuCopyService.copyMenu(
|
const result = await menuCopyService.copyMenu(
|
||||||
parseInt(menuObjid, 10),
|
parseInt(menuObjid, 10),
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
userId,
|
userId,
|
||||||
screenNameConfig,
|
screenNameConfig
|
||||||
additionalCopyOptions
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("✅ 메뉴 복사 API 성공");
|
logger.info("✅ 메뉴 복사 API 성공");
|
||||||
|
|
@ -3591,395 +3365,3 @@ export async function copyMenu(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ============================================================
|
|
||||||
* 사원 + 부서 통합 관리 API
|
|
||||||
* ============================================================
|
|
||||||
*
|
|
||||||
* 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다.
|
|
||||||
*
|
|
||||||
* ## 핵심 기능
|
|
||||||
* 1. user_info 테이블에 사원 개인정보 저장
|
|
||||||
* 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장
|
|
||||||
* 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환
|
|
||||||
* 4. 트랜잭션으로 데이터 정합성 보장
|
|
||||||
*
|
|
||||||
* ## 요청 데이터 구조
|
|
||||||
* ```json
|
|
||||||
* {
|
|
||||||
* "userInfo": {
|
|
||||||
* "user_id": "string (필수)",
|
|
||||||
* "user_name": "string (필수)",
|
|
||||||
* "email": "string",
|
|
||||||
* "cell_phone": "string",
|
|
||||||
* "sabun": "string",
|
|
||||||
* ...
|
|
||||||
* },
|
|
||||||
* "mainDept": {
|
|
||||||
* "dept_code": "string (필수)",
|
|
||||||
* "dept_name": "string",
|
|
||||||
* "position_name": "string"
|
|
||||||
* },
|
|
||||||
* "subDepts": [
|
|
||||||
* {
|
|
||||||
* "dept_code": "string (필수)",
|
|
||||||
* "dept_name": "string",
|
|
||||||
* "position_name": "string"
|
|
||||||
* }
|
|
||||||
* ]
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 사원 + 부서 저장 요청 타입
|
|
||||||
interface UserWithDeptRequest {
|
|
||||||
userInfo: {
|
|
||||||
user_id: string;
|
|
||||||
user_name: string;
|
|
||||||
user_name_eng?: string;
|
|
||||||
user_password?: string;
|
|
||||||
email?: string;
|
|
||||||
tel?: string;
|
|
||||||
cell_phone?: string;
|
|
||||||
sabun?: string;
|
|
||||||
user_type?: string;
|
|
||||||
user_type_name?: string;
|
|
||||||
status?: string;
|
|
||||||
locale?: string;
|
|
||||||
// 메인 부서 정보 (user_info에도 저장)
|
|
||||||
dept_code?: string;
|
|
||||||
dept_name?: string;
|
|
||||||
position_code?: string;
|
|
||||||
position_name?: string;
|
|
||||||
};
|
|
||||||
mainDept?: {
|
|
||||||
dept_code: string;
|
|
||||||
dept_name?: string;
|
|
||||||
position_name?: string;
|
|
||||||
};
|
|
||||||
subDepts?: Array<{
|
|
||||||
dept_code: string;
|
|
||||||
dept_name?: string;
|
|
||||||
position_name?: string;
|
|
||||||
}>;
|
|
||||||
isUpdate?: boolean; // 수정 모드 여부
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/admin/users/with-dept
|
|
||||||
* 사원 + 부서 통합 저장 API
|
|
||||||
*/
|
|
||||||
export const saveUserWithDept = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
const client = await getPool().connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
const currentUserId = req.user?.userId;
|
|
||||||
|
|
||||||
logger.info("사원+부서 통합 저장 요청", {
|
|
||||||
userId: userInfo?.user_id,
|
|
||||||
mainDept: mainDept?.dept_code,
|
|
||||||
subDeptsCount: subDepts.length,
|
|
||||||
isUpdate,
|
|
||||||
companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필수값 검증
|
|
||||||
if (!userInfo?.user_id || !userInfo?.user_name) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "사용자 ID와 이름은 필수입니다.",
|
|
||||||
error: { code: "REQUIRED_FIELD_MISSING" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랜잭션 시작
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
// 1. 기존 사용자 확인
|
|
||||||
const existingUser = await client.query(
|
|
||||||
"SELECT user_id FROM user_info WHERE user_id = $1",
|
|
||||||
[userInfo.user_id]
|
|
||||||
);
|
|
||||||
const isExistingUser = existingUser.rows.length > 0;
|
|
||||||
|
|
||||||
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
|
|
||||||
let encryptedPassword = null;
|
|
||||||
if (userInfo.user_password) {
|
|
||||||
encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. user_info 저장 (UPSERT)
|
|
||||||
// mainDept가 있으면 user_info에도 메인 부서 정보 저장
|
|
||||||
const deptCode = mainDept?.dept_code || userInfo.dept_code || null;
|
|
||||||
const deptName = mainDept?.dept_name || userInfo.dept_name || null;
|
|
||||||
const positionName = mainDept?.position_name || userInfo.position_name || null;
|
|
||||||
|
|
||||||
if (isExistingUser) {
|
|
||||||
// 기존 사용자 수정
|
|
||||||
const updateFields: string[] = [];
|
|
||||||
const updateValues: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// 동적으로 업데이트할 필드 구성
|
|
||||||
const fieldsToUpdate: Record<string, any> = {
|
|
||||||
user_name: userInfo.user_name,
|
|
||||||
user_name_eng: userInfo.user_name_eng,
|
|
||||||
email: userInfo.email,
|
|
||||||
tel: userInfo.tel,
|
|
||||||
cell_phone: userInfo.cell_phone,
|
|
||||||
sabun: userInfo.sabun,
|
|
||||||
user_type: userInfo.user_type,
|
|
||||||
user_type_name: userInfo.user_type_name,
|
|
||||||
status: userInfo.status || "active",
|
|
||||||
locale: userInfo.locale,
|
|
||||||
dept_code: deptCode,
|
|
||||||
dept_name: deptName,
|
|
||||||
position_code: userInfo.position_code,
|
|
||||||
position_name: positionName,
|
|
||||||
company_code: companyCode !== "*" ? companyCode : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비밀번호가 제공된 경우에만 업데이트
|
|
||||||
if (encryptedPassword) {
|
|
||||||
fieldsToUpdate.user_password = encryptedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fieldsToUpdate)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
updateFields.push(`${key} = $${paramIndex}`);
|
|
||||||
updateValues.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateFields.length > 0) {
|
|
||||||
updateValues.push(userInfo.user_id);
|
|
||||||
await client.query(
|
|
||||||
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
|
|
||||||
updateValues
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 새 사용자 등록
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO user_info (
|
|
||||||
user_id, user_name, user_name_eng, user_password,
|
|
||||||
email, tel, cell_phone, sabun,
|
|
||||||
user_type, user_type_name, status, locale,
|
|
||||||
dept_code, dept_name, position_code, position_name,
|
|
||||||
company_code, regdate
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
|
|
||||||
[
|
|
||||||
userInfo.user_id,
|
|
||||||
userInfo.user_name,
|
|
||||||
userInfo.user_name_eng || null,
|
|
||||||
encryptedPassword || null,
|
|
||||||
userInfo.email || null,
|
|
||||||
userInfo.tel || null,
|
|
||||||
userInfo.cell_phone || null,
|
|
||||||
userInfo.sabun || null,
|
|
||||||
userInfo.user_type || null,
|
|
||||||
userInfo.user_type_name || null,
|
|
||||||
userInfo.status || "active",
|
|
||||||
userInfo.locale || null,
|
|
||||||
deptCode,
|
|
||||||
deptName,
|
|
||||||
userInfo.position_code || null,
|
|
||||||
positionName,
|
|
||||||
companyCode !== "*" ? companyCode : null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. user_dept 처리
|
|
||||||
if (mainDept?.dept_code || subDepts.length > 0) {
|
|
||||||
// 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용)
|
|
||||||
const existingDepts = await client.query(
|
|
||||||
"SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1",
|
|
||||||
[userInfo.user_id]
|
|
||||||
);
|
|
||||||
const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true);
|
|
||||||
|
|
||||||
// 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환
|
|
||||||
if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) {
|
|
||||||
logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", {
|
|
||||||
userId: userInfo.user_id,
|
|
||||||
oldMain: existingMainDept.dept_code,
|
|
||||||
newMain: mainDept.dept_code,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
"UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2",
|
|
||||||
[userInfo.user_id, existingMainDept.dept_code]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-3. 기존 겸직 부서 삭제 (메인 제외)
|
|
||||||
// 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제
|
|
||||||
await client.query(
|
|
||||||
"DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false",
|
|
||||||
[userInfo.user_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4-4. 메인 부서 저장 (UPSERT)
|
|
||||||
if (mainDept?.dept_code) {
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW())
|
|
||||||
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
|
||||||
is_primary = true,
|
|
||||||
dept_name = $3,
|
|
||||||
user_name = $4,
|
|
||||||
position_name = $5,
|
|
||||||
company_code = $6,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[
|
|
||||||
userInfo.user_id,
|
|
||||||
mainDept.dept_code,
|
|
||||||
mainDept.dept_name || null,
|
|
||||||
userInfo.user_name,
|
|
||||||
mainDept.position_name || null,
|
|
||||||
companyCode !== "*" ? companyCode : null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-5. 겸직 부서 저장
|
|
||||||
for (const subDept of subDepts) {
|
|
||||||
if (!subDept.dept_code) continue;
|
|
||||||
|
|
||||||
// 메인 부서와 같은 부서는 겸직으로 추가하지 않음
|
|
||||||
if (mainDept?.dept_code === subDept.dept_code) continue;
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW())
|
|
||||||
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
|
||||||
is_primary = false,
|
|
||||||
dept_name = $3,
|
|
||||||
user_name = $4,
|
|
||||||
position_name = $5,
|
|
||||||
company_code = $6,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[
|
|
||||||
userInfo.user_id,
|
|
||||||
subDept.dept_code,
|
|
||||||
subDept.dept_name || null,
|
|
||||||
userInfo.user_name,
|
|
||||||
subDept.position_name || null,
|
|
||||||
companyCode !== "*" ? companyCode : null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랜잭션 커밋
|
|
||||||
await client.query("COMMIT");
|
|
||||||
|
|
||||||
logger.info("사원+부서 통합 저장 완료", {
|
|
||||||
userId: userInfo.user_id,
|
|
||||||
isUpdate: isExistingUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
|
||||||
data: {
|
|
||||||
userId: userInfo.user_id,
|
|
||||||
isUpdate: isExistingUser,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// 트랜잭션 롤백
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
|
|
||||||
logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body });
|
|
||||||
|
|
||||||
// 중복 키 에러 처리
|
|
||||||
if (error.code === "23505") {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "이미 존재하는 사용자 ID입니다.",
|
|
||||||
error: { code: "DUPLICATE_USER_ID" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "사원 저장 중 오류가 발생했습니다.",
|
|
||||||
error: { code: "SAVE_ERROR", details: error.message },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/users/:userId/with-dept
|
|
||||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
|
||||||
*/
|
|
||||||
export const getUserWithDept = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.params;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
|
|
||||||
logger.info("사원+부서 조회 요청", { userId, companyCode });
|
|
||||||
|
|
||||||
// 1. user_info 조회
|
|
||||||
let userQuery = "SELECT * FROM user_info WHERE user_id = $1";
|
|
||||||
const userParams: any[] = [userId];
|
|
||||||
|
|
||||||
// 최고 관리자가 아니면 회사 필터링
|
|
||||||
if (companyCode !== "*") {
|
|
||||||
userQuery += " AND company_code = $2";
|
|
||||||
userParams.push(companyCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userResult = await query<any>(userQuery, userParams);
|
|
||||||
|
|
||||||
if (userResult.length === 0) {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: "사용자를 찾을 수 없습니다.",
|
|
||||||
error: { code: "USER_NOT_FOUND" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userInfo = userResult[0];
|
|
||||||
|
|
||||||
// 2. user_dept 조회 (메인 + 겸직)
|
|
||||||
let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC";
|
|
||||||
const deptResult = await query<any>(deptQuery, [userId]);
|
|
||||||
|
|
||||||
const mainDept = deptResult.find((d: any) => d.is_primary === true);
|
|
||||||
const subDepts = deptResult.filter((d: any) => d.is_primary === false);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
userInfo,
|
|
||||||
mainDept: mainDept || null,
|
|
||||||
subDepts,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId });
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "사원 조회 중 오류가 발생했습니다.",
|
|
||||||
error: { code: "QUERY_ERROR", details: error.message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* POST /api/auth/logout
|
||||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||||
|
|
@ -330,14 +226,13 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||||
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
|
||||||
const userInfoResponse: any = {
|
const userInfoResponse: any = {
|
||||||
userId: dbUserInfo.userId,
|
userId: dbUserInfo.userId,
|
||||||
userName: dbUserInfo.userName || "",
|
userName: dbUserInfo.userName || "",
|
||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
userType: dbUserInfo.userType || "USER",
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
photo: dbUserInfo.photo,
|
photo: dbUserInfo.photo,
|
||||||
|
|
@ -492,47 +387,50 @@ export class AuthController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/signup
|
* POST /api/auth/signup
|
||||||
* 공차중계 회원가입 API
|
* 회원가입 API (공차중계용)
|
||||||
*/
|
*/
|
||||||
static async signup(req: Request, res: Response): Promise<void> {
|
static async signup(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||||
|
|
||||||
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
logger.info(`=== 회원가입 API 호출 ===`);
|
||||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}, vehicleType: ${vehicleType}`);
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 입력값이 누락되었습니다.",
|
message: "모든 필수 항목을 입력해주세요.",
|
||||||
error: {
|
error: {
|
||||||
code: "INVALID_INPUT",
|
code: "INVALID_INPUT",
|
||||||
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
details: "필수 입력값이 누락되었습니다.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회원가입 처리
|
// 회원가입 처리
|
||||||
const signupResult = await AuthService.signupDriver({
|
const signupResult = await AuthService.signupUser({
|
||||||
userId,
|
userId,
|
||||||
password,
|
password,
|
||||||
userName,
|
userName,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
licenseNumber,
|
licenseNumber,
|
||||||
vehicleNumber,
|
vehicleNumber,
|
||||||
vehicleType,
|
vehicleType, // 차량 타입 추가
|
||||||
});
|
});
|
||||||
|
|
||||||
if (signupResult.success) {
|
if (signupResult.success) {
|
||||||
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
logger.info(`회원가입 성공: ${userId}`);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "회원가입이 완료되었습니다.",
|
message: "회원가입이 완료되었습니다.",
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||||
|
|
@ -542,14 +440,14 @@ export class AuthController {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error("공차중계 회원가입 API 오류:", error);
|
logger.error("회원가입 오류:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||||
error: {
|
error: {
|
||||||
code: "SIGNUP_ERROR",
|
code: "SIGNUP_ERROR",
|
||||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import {
|
import {
|
||||||
BatchManagementService,
|
BatchManagementService,
|
||||||
|
|
@ -13,7 +13,6 @@ import { BatchService } from "../services/batchService";
|
||||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||||
import { query } from "../database/db";
|
|
||||||
|
|
||||||
export class BatchManagementController {
|
export class BatchManagementController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -423,8 +422,6 @@ export class BatchManagementController {
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
requestBody,
|
requestBody,
|
||||||
authServiceName, // DB에서 토큰 가져올 서비스명
|
|
||||||
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// apiUrl, endpoint는 항상 필수
|
// apiUrl, endpoint는 항상 필수
|
||||||
|
|
@ -435,47 +432,15 @@ export class BatchManagementController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||||
let finalApiKey = apiKey || "";
|
if ((!method || method === "GET") && !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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
console.log("🔍 REST API 미리보기 요청:", {
|
||||||
|
|
||||||
console.log("REST API 미리보기 요청:", {
|
|
||||||
apiUrl,
|
apiUrl,
|
||||||
endpoint,
|
endpoint,
|
||||||
method,
|
method,
|
||||||
|
|
@ -484,8 +449,6 @@ export class BatchManagementController {
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
requestBody: requestBody ? "Included" : "None",
|
requestBody: requestBody ? "Included" : "None",
|
||||||
authServiceName: authServiceName || "직접 입력",
|
|
||||||
dataArrayPath: dataArrayPath || "전체 응답",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// RestApiConnector 사용하여 데이터 조회
|
// RestApiConnector 사용하여 데이터 조회
|
||||||
|
|
@ -493,7 +456,7 @@ export class BatchManagementController {
|
||||||
|
|
||||||
const connector = new RestApiConnector({
|
const connector = new RestApiConnector({
|
||||||
baseUrl: apiUrl,
|
baseUrl: apiUrl,
|
||||||
apiKey: finalApiKey,
|
apiKey: apiKey || "",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -548,50 +511,8 @@ export class BatchManagementController {
|
||||||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 데이터 배열 추출 헬퍼 함수
|
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||||
const getValueByPath = (obj: any, path: string): any => {
|
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
// 첫 번째 객체에서 필드명 추출
|
// 첫 번째 객체에서 필드명 추출
|
||||||
|
|
@ -603,9 +524,9 @@ export class BatchManagementController {
|
||||||
data: {
|
data: {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
samples: data,
|
samples: data,
|
||||||
totalCount: extractedData.length,
|
totalCount: result.rowCount || data.length,
|
||||||
},
|
},
|
||||||
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -633,17 +554,8 @@ export class BatchManagementController {
|
||||||
*/
|
*/
|
||||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const {
|
const { batchName, batchType, cronSchedule, description, apiMappings } =
|
||||||
batchName,
|
req.body;
|
||||||
batchType,
|
|
||||||
cronSchedule,
|
|
||||||
description,
|
|
||||||
apiMappings,
|
|
||||||
authServiceName,
|
|
||||||
dataArrayPath,
|
|
||||||
saveMode,
|
|
||||||
conflictKey,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!batchName ||
|
!batchName ||
|
||||||
|
|
@ -664,10 +576,6 @@ export class BatchManagementController {
|
||||||
cronSchedule,
|
cronSchedule,
|
||||||
description,
|
description,
|
||||||
apiMappings,
|
apiMappings,
|
||||||
authServiceName,
|
|
||||||
dataArrayPath,
|
|
||||||
saveMode,
|
|
||||||
conflictKey,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||||
|
|
@ -681,10 +589,6 @@ export class BatchManagementController {
|
||||||
cronSchedule: cronSchedule,
|
cronSchedule: cronSchedule,
|
||||||
isActive: "Y",
|
isActive: "Y",
|
||||||
companyCode,
|
companyCode,
|
||||||
authServiceName: authServiceName || undefined,
|
|
||||||
dataArrayPath: dataArrayPath || undefined,
|
|
||||||
saveMode: saveMode || "INSERT",
|
|
||||||
conflictKey: conflictKey || undefined,
|
|
||||||
mappings: apiMappings,
|
mappings: apiMappings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -721,51 +625,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: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,7 @@ export class CommonCodeController {
|
||||||
sortOrder: code.sort_order,
|
sortOrder: code.sort_order,
|
||||||
isActive: code.is_active,
|
isActive: code.is_active,
|
||||||
useYn: code.is_active,
|
useYn: code.is_active,
|
||||||
companyCode: code.company_code,
|
companyCode: code.company_code, // 추가
|
||||||
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
|
|
||||||
depth: code.depth, // 계층구조: 깊이
|
|
||||||
|
|
||||||
// 기존 필드명도 유지 (하위 호환성)
|
// 기존 필드명도 유지 (하위 호환성)
|
||||||
code_category: code.code_category,
|
code_category: code.code_category,
|
||||||
|
|
@ -105,9 +103,7 @@ export class CommonCodeController {
|
||||||
code_name_eng: code.code_name_eng,
|
code_name_eng: code.code_name_eng,
|
||||||
sort_order: code.sort_order,
|
sort_order: code.sort_order,
|
||||||
is_active: code.is_active,
|
is_active: code.is_active,
|
||||||
company_code: code.company_code,
|
company_code: code.company_code, // 추가
|
||||||
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
|
|
||||||
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
|
|
||||||
created_date: code.created_date,
|
created_date: code.created_date,
|
||||||
created_by: code.created_by,
|
created_by: code.created_by,
|
||||||
updated_date: code.updated_date,
|
updated_date: code.updated_date,
|
||||||
|
|
@ -290,17 +286,19 @@ export class CommonCodeController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
|
if (!menuObjid) {
|
||||||
// 공통코드관리 메뉴 OBJID: 1757401858940
|
return res.status(400).json({
|
||||||
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
|
success: false,
|
||||||
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
|
message: "메뉴 OBJID는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const code = await this.commonCodeService.createCode(
|
const code = await this.commonCodeService.createCode(
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeData,
|
codeData,
|
||||||
userId,
|
userId,
|
||||||
companyCode,
|
companyCode,
|
||||||
effectiveMenuObjid
|
Number(menuObjid)
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
|
|
@ -590,129 +588,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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,43 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
import { pool, queryOne } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
|
// 외부 DB 커넥터를 가져오는 헬퍼 함수
|
||||||
export async function getExternalDbConnector(connectionId: number) {
|
export async function getExternalDbConnector(connectionId: number) {
|
||||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connection = await queryOne<any>(
|
||||||
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
if (!connection) {
|
||||||
return {
|
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
||||||
executeQuery: async (sql: string, params?: any[]) => {
|
}
|
||||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
|
||||||
return { rows: result };
|
// 패스워드 복호화
|
||||||
},
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// DB 연결 설정
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
database: connection.database_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DB 커넥터 생성
|
||||||
|
return await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type || "mariadb",
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 계층 구조 데이터 조회 (범용)
|
// 동적 계층 구조 데이터 조회 (범용)
|
||||||
export const getHierarchyData = async (
|
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||||
|
|
||||||
|
|
@ -30,9 +48,7 @@ export const getHierarchyData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
const config = JSON.parse(hierarchyConfig);
|
const config = JSON.parse(hierarchyConfig);
|
||||||
|
|
||||||
const result: any = {
|
const result: any = {
|
||||||
|
|
@ -78,10 +94,7 @@ export const getHierarchyData = async (
|
||||||
logger.info("동적 계층 구조 데이터 조회", {
|
logger.info("동적 계층 구조 데이터 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
warehouseCount: result.warehouse?.length || 0,
|
warehouseCount: result.warehouse?.length || 0,
|
||||||
levelCounts: result.levels.map((l: any) => ({
|
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
|
||||||
level: l.level,
|
|
||||||
count: l.data.length,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -99,35 +112,22 @@ export const getHierarchyData = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 특정 레벨의 하위 데이터 조회
|
// 특정 레벨의 하위 데이터 조회
|
||||||
export const getChildrenData = async (
|
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
|
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
|
||||||
req.body;
|
|
||||||
|
|
||||||
if (
|
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
|
||||||
!externalDbConnectionId ||
|
|
||||||
!hierarchyConfig ||
|
|
||||||
!parentLevel ||
|
|
||||||
!parentKey
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
const config = JSON.parse(hierarchyConfig);
|
const config = JSON.parse(hierarchyConfig);
|
||||||
|
|
||||||
// 다음 레벨 찾기
|
// 다음 레벨 찾기
|
||||||
const nextLevel = config.levels?.find(
|
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
|
||||||
(l: any) => l.level === parentLevel + 1
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!nextLevel) {
|
if (!nextLevel) {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -168,10 +168,7 @@ export const getChildrenData = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getWarehouses = async (
|
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, tableName } = req.query;
|
const { externalDbConnectionId, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -189,9 +186,7 @@ export const getWarehouses = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||||
|
|
@ -220,10 +215,7 @@ export const getWarehouses = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getAreas = async (
|
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -234,9 +226,7 @@ export const getAreas = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -268,10 +258,7 @@ export const getAreas = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getLocations = async (
|
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -282,9 +269,7 @@ export const getLocations = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -316,10 +301,7 @@ export const getLocations = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||||
export const getMaterials = async (
|
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
|
|
@ -327,27 +309,20 @@ export const getMaterials = async (
|
||||||
tableName,
|
tableName,
|
||||||
keyColumn,
|
keyColumn,
|
||||||
locationKeyColumn,
|
locationKeyColumn,
|
||||||
layerColumn,
|
layerColumn
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
if (
|
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
||||||
!externalDbConnectionId ||
|
|
||||||
!locaKey ||
|
|
||||||
!tableName ||
|
|
||||||
!locationKeyColumn
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 동적 쿼리 생성
|
// 동적 쿼리 생성
|
||||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
|
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||||
|
|
@ -381,10 +356,7 @@ export const getMaterials = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||||
export const getMaterialCounts = async (
|
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||||
req: Request,
|
|
||||||
res: Response
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||||
|
|
||||||
|
|
@ -395,9 +367,7 @@ export const getMaterialCounts = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
Number(externalDbConnectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "회원 탈퇴 처리 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName, screenId } = req.body;
|
const { tableName } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,16 +240,7 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(
|
|
||||||
id,
|
|
||||||
tableName,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -436,8 +427,7 @@ export const updateFieldValue = async (
|
||||||
): Promise<Response | void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName, keyField, keyValue, updateField, updateValue } =
|
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
||||||
req.body;
|
|
||||||
|
|
||||||
console.log("🔄 [updateFieldValue] 요청:", {
|
console.log("🔄 [updateFieldValue] 요청:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -450,27 +440,16 @@ export const updateFieldValue = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (
|
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
||||||
!tableName ||
|
|
||||||
!keyField ||
|
|
||||||
keyValue === undefined ||
|
|
||||||
!updateField ||
|
|
||||||
updateValue === undefined
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||||
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
if (
|
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
||||||
!validNamePattern.test(tableName) ||
|
|
||||||
!validNamePattern.test(keyField) ||
|
|
||||||
!validNamePattern.test(updateField)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||||
|
|
@ -503,132 +482,3 @@ export const updateFieldValue = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치 이력 저장 (연속 위치 추적용)
|
|
||||||
* 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 || "위치 이력 조회에 실패했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -66,23 +65,11 @@ export class EntityJoinController {
|
||||||
const userField = parsedAutoFilter.userField || "companyCode";
|
const userField = parsedAutoFilter.userField || "companyCode";
|
||||||
const userValue = ((req as any).user as any)[userField];
|
const userValue = ((req as any).user as any)[userField];
|
||||||
|
|
||||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
if (userValue) {
|
||||||
let finalCompanyCode = userValue;
|
searchConditions[filterColumn] = userValue;
|
||||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
|
||||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
|
||||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
|
||||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
|
||||||
originalCompanyCode: userValue,
|
|
||||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
|
||||||
tableName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalCompanyCode) {
|
|
||||||
searchConditions[filterColumn] = finalCompanyCode;
|
|
||||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
finalCompanyCode,
|
userValue,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -138,19 +125,6 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
|
||||||
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(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -167,7 +141,6 @@ export class EntityJoinController {
|
||||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -436,16 +409,18 @@ export class EntityJoinController {
|
||||||
config.referenceTable
|
config.referenceTable
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
// 현재 display_column으로 사용 중인 컬럼 제외
|
||||||
const currentDisplayColumn =
|
const currentDisplayColumn =
|
||||||
config.displayColumn || config.displayColumns[0];
|
config.displayColumn || config.displayColumns[0];
|
||||||
|
const availableColumns = columns.filter(
|
||||||
|
(col) => col.columnName !== currentDisplayColumn
|
||||||
|
);
|
||||||
|
|
||||||
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
|
||||||
return {
|
return {
|
||||||
joinConfig: config,
|
joinConfig: config,
|
||||||
tableName: config.referenceTable,
|
tableName: config.referenceTable,
|
||||||
currentDisplayColumn: currentDisplayColumn,
|
currentDisplayColumn: currentDisplayColumn,
|
||||||
availableColumns: columns.map((col) => ({
|
availableColumns: availableColumns.map((col) => ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.displayName || col.columnName,
|
columnLabel: col.displayName || col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
|
|
|
||||||
|
|
@ -32,32 +32,10 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
// 검색 필드 파싱
|
// 검색 필드 파싱
|
||||||
const requestedFields = searchFields
|
const fields = searchFields
|
||||||
? (searchFields as string).split(",").map((f) => f.trim())
|
? (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 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
@ -65,34 +43,14 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
// 멀티테넌시 필터링
|
// 멀티테넌시 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
|
||||||
if (existingColumns.has("company_code")) {
|
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
params.push(companyCode);
|
params.push(companyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 검색 조건
|
// 검색 조건
|
||||||
if (searchText) {
|
if (searchText && fields.length > 0) {
|
||||||
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
const searchConditions = fields.map((field) => {
|
||||||
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}`;
|
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
return condition;
|
return condition;
|
||||||
|
|
@ -100,96 +58,17 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||||
|
|
||||||
// 검색어 파라미터 추가
|
// 검색어 파라미터 추가
|
||||||
searchableFields.forEach(() => {
|
fields.forEach(() => {
|
||||||
params.push(`%${searchText}%`);
|
params.push(`%${searchText}%`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 필터 조건 (존재하는 컬럼만)
|
// 추가 필터 조건
|
||||||
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
|
||||||
// 특수 키 형식: column__operator (예: division__in, name__like)
|
|
||||||
const additionalFilter = JSON.parse(filterCondition as string);
|
const additionalFilter = JSON.parse(filterCondition as string);
|
||||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||||
// 특수 키 형식 파싱: column__operator
|
whereConditions.push(`${key} = $${paramIndex}`);
|
||||||
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);
|
params.push(value);
|
||||||
paramIndex++;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이징
|
// 페이징
|
||||||
|
|
@ -199,7 +78,8 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
// 쿼리 실행
|
||||||
|
const pool = getPool();
|
||||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM ${tableName} ${whereClause}
|
SELECT * FROM ${tableName} ${whereClause}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${files.length}개 파일 업로드 완료`,
|
message: `${files.length}개 파일 업로드 완료`,
|
||||||
|
|
@ -463,56 +405,6 @@ export const deleteFile = async (
|
||||||
["DELETED", parseInt(objid)]
|
["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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "파일이 삭제되었습니다.",
|
message: "파일이 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,8 @@ export class FlowController {
|
||||||
*/
|
*/
|
||||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
||||||
name,
|
req.body;
|
||||||
description,
|
|
||||||
tableName,
|
|
||||||
dbSourceType,
|
|
||||||
dbConnectionId,
|
|
||||||
// REST API 관련 필드
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
} = req.body;
|
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
const userCompanyCode = (req as any).user?.companyCode;
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
|
@ -52,9 +43,6 @@ export class FlowController {
|
||||||
tableName,
|
tableName,
|
||||||
dbSourceType,
|
dbSourceType,
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
userCompanyCode,
|
userCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,12 +54,8 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
// 테이블 이름이 제공된 경우에만 존재 확인
|
||||||
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
if (tableName) {
|
||||||
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_")) {
|
|
||||||
const tableExists =
|
const tableExists =
|
||||||
await this.flowDefinitionService.checkTableExists(tableName);
|
await this.flowDefinitionService.checkTableExists(tableName);
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
|
|
@ -84,17 +68,7 @@ export class FlowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowDef = await this.flowDefinitionService.create(
|
const flowDef = await this.flowDefinitionService.create(
|
||||||
{
|
{ name, description, tableName, dbSourceType, dbConnectionId },
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tableName,
|
|
||||||
dbSourceType,
|
|
||||||
dbConnectionId,
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
|
||||||
},
|
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode
|
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ import {
|
||||||
SaveLangTextsRequest,
|
SaveLangTextsRequest,
|
||||||
GetUserTextParams,
|
GetUserTextParams,
|
||||||
BatchTranslationRequest,
|
BatchTranslationRequest,
|
||||||
GenerateKeyRequest,
|
|
||||||
CreateOverrideKeyRequest,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
LangCategory,
|
|
||||||
} from "../types/multilang";
|
} from "../types/multilang";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -190,7 +187,7 @@ export const getLangKeys = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
const { companyCode, menuCode, keyType, searchText } = req.query;
|
||||||
logger.info("다국어 키 목록 조회 요청", {
|
logger.info("다국어 키 목록 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
|
|
@ -202,7 +199,6 @@ export const getLangKeys = async (
|
||||||
menuCode: menuCode as string,
|
menuCode: menuCode as string,
|
||||||
keyType: keyType as string,
|
keyType: keyType as string,
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
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
|
* POST /api/multilang/batch
|
||||||
* 다국어 텍스트 배치 조회 API
|
* 다국어 텍스트 배치 조회 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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -217,14 +217,11 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
logger.info("코드 할당 요청", { ruleId, companyCode });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
|
||||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||||
} catch (error: any) {
|
} 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 });
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 번호 생성 함수
|
||||||
|
* 형식: ORD + YYMMDD + 4자리 시퀀스
|
||||||
|
* 예: ORD250114001
|
||||||
|
*/
|
||||||
|
async function generateOrderNumber(companyCode: string): Promise<string> {
|
||||||
|
const pool = getPool();
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear().toString().slice(2); // 25
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
|
||||||
|
const day = String(today.getDate()).padStart(2, "0"); // 14
|
||||||
|
const dateStr = `${year}${month}${day}`; // 250114
|
||||||
|
|
||||||
|
// 당일 수주 카운트 조회
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM order_mng_master
|
||||||
|
WHERE objid LIKE $1
|
||||||
|
AND writer LIKE $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const pattern = `ORD${dateStr}%`;
|
||||||
|
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
|
||||||
|
const count = parseInt(result.rows[0]?.count || "0");
|
||||||
|
const seq = count + 1;
|
||||||
|
|
||||||
|
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 등록 API
|
||||||
|
* POST /api/orders
|
||||||
|
*/
|
||||||
|
export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
inputMode, // 입력 방식
|
||||||
|
customerCode, // 거래처 코드
|
||||||
|
deliveryDate, // 납품일
|
||||||
|
items, // 품목 목록
|
||||||
|
memo, // 메모
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 멀티테넌시
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!customerCode) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "거래처 코드는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "품목은 최소 1개 이상 필요합니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수주 번호 생성
|
||||||
|
const orderNo = await generateOrderNumber(companyCode);
|
||||||
|
|
||||||
|
// 전체 금액 계산
|
||||||
|
const totalAmount = items.reduce(
|
||||||
|
(sum: number, item: any) => sum + (item.amount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// 수주 마스터 생성
|
||||||
|
const masterQuery = `
|
||||||
|
INSERT INTO order_mng_master (
|
||||||
|
objid,
|
||||||
|
partner_objid,
|
||||||
|
final_delivery_date,
|
||||||
|
reason,
|
||||||
|
status,
|
||||||
|
reg_date,
|
||||||
|
writer
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const masterResult = await pool.query(masterQuery, [
|
||||||
|
orderNo,
|
||||||
|
customerCode,
|
||||||
|
deliveryDate || null,
|
||||||
|
memo || null,
|
||||||
|
"진행중",
|
||||||
|
`${userId}|${companyCode}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const masterObjid = masterResult.rows[0].objid;
|
||||||
|
|
||||||
|
// 수주 상세 (품목) 생성
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const subObjid = `${orderNo}_${i + 1}`;
|
||||||
|
|
||||||
|
const subQuery = `
|
||||||
|
INSERT INTO order_mng_sub (
|
||||||
|
objid,
|
||||||
|
order_mng_master_objid,
|
||||||
|
part_objid,
|
||||||
|
partner_objid,
|
||||||
|
partner_price,
|
||||||
|
partner_qty,
|
||||||
|
delivery_date,
|
||||||
|
status,
|
||||||
|
regdate,
|
||||||
|
writer
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(subQuery, [
|
||||||
|
subObjid,
|
||||||
|
masterObjid,
|
||||||
|
item.item_code || item.id, // 품목 코드
|
||||||
|
customerCode,
|
||||||
|
item.unit_price || 0,
|
||||||
|
item.quantity || 0,
|
||||||
|
item.delivery_date || deliveryDate || null,
|
||||||
|
"진행중",
|
||||||
|
`${userId}|${companyCode}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("수주 등록 성공", {
|
||||||
|
companyCode,
|
||||||
|
orderNo,
|
||||||
|
masterObjid,
|
||||||
|
itemCount: items.length,
|
||||||
|
totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderNo,
|
||||||
|
masterObjid,
|
||||||
|
itemCount: items.length,
|
||||||
|
totalAmount,
|
||||||
|
},
|
||||||
|
message: "수주가 등록되었습니다",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("수주 등록 오류", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "수주 등록 중 오류가 발생했습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
||||||
|
* GET /api/orders
|
||||||
|
*/
|
||||||
|
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { page = "1", limit = "20", searchText = "" } = req.query;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||||
|
|
||||||
|
// WHERE 조건
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||||
|
params.push(`%${companyCode}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
if (searchText) {
|
||||||
|
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||||
|
params.push(`%${searchText}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 카운트 쿼리 (고유한 수주 개수)
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(DISTINCT m.objid) as count
|
||||||
|
FROM order_mng_master m
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
const countResult = await pool.query(countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||||
|
|
||||||
|
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT
|
||||||
|
m.objid as order_no,
|
||||||
|
m.partner_objid,
|
||||||
|
m.final_delivery_date,
|
||||||
|
m.reason,
|
||||||
|
m.status,
|
||||||
|
m.reg_date,
|
||||||
|
m.writer,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
CASE WHEN s.objid IS NOT NULL THEN
|
||||||
|
json_build_object(
|
||||||
|
'sub_objid', s.objid,
|
||||||
|
'part_objid', s.part_objid,
|
||||||
|
'partner_price', s.partner_price,
|
||||||
|
'partner_qty', s.partner_qty,
|
||||||
|
'delivery_date', s.delivery_date,
|
||||||
|
'status', s.status,
|
||||||
|
'regdate', s.regdate
|
||||||
|
)
|
||||||
|
END
|
||||||
|
ORDER BY s.regdate
|
||||||
|
) FILTER (WHERE s.objid IS NOT NULL),
|
||||||
|
'[]'::json
|
||||||
|
) as items
|
||||||
|
FROM order_mng_master m
|
||||||
|
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
||||||
|
ORDER BY m.reg_date DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(parseInt(limit as string));
|
||||||
|
params.push(offset);
|
||||||
|
|
||||||
|
const dataResult = await pool.query(dataQuery, params);
|
||||||
|
|
||||||
|
logger.info("수주 목록 조회 성공", {
|
||||||
|
companyCode,
|
||||||
|
total,
|
||||||
|
page: parseInt(page as string),
|
||||||
|
itemCount: dataResult.rows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: dataResult.rows,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: parseInt(page as string),
|
||||||
|
limit: parseInt(limit as string),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("수주 목록 조회 오류", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -148,42 +148,11 @@ export const updateScreenInfo = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode } = req.user as any;
|
||||||
const {
|
const { screenName, tableName, description, isActive } = req.body;
|
||||||
screenName,
|
|
||||||
tableName,
|
|
||||||
description,
|
|
||||||
isActive,
|
|
||||||
// REST API 관련 필드 추가
|
|
||||||
dataSourceType,
|
|
||||||
dbSourceType,
|
|
||||||
dbConnectionId,
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
console.log("화면 정보 수정 요청:", {
|
|
||||||
screenId: id,
|
|
||||||
dataSourceType,
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
await screenManagementService.updateScreenInfo(
|
await screenManagementService.updateScreenInfo(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
{
|
{ screenName, tableName, description, isActive },
|
||||||
screenName,
|
|
||||||
tableName,
|
|
||||||
description,
|
|
||||||
isActive,
|
|
||||||
dataSourceType,
|
|
||||||
dbSourceType,
|
|
||||||
dbConnectionId,
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
},
|
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||||
|
|
@ -325,53 +294,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 (
|
export const bulkPermanentDeleteScreens = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -30,29 +30,6 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
*
|
*
|
||||||
|
|
@ -550,53 +527,6 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 코드로 라벨 조회
|
|
||||||
*
|
|
||||||
* 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레벨 메뉴 목록 조회
|
* 2레벨 메뉴 목록 조회
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
// 이력 조회 쿼리
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY log_id DESC
|
ORDER BY changed_at DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
// 이력 조회 쿼리
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY log_id DESC
|
ORDER BY changed_at DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -767,33 +767,20 @@ export async function getTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
|
// 🆕 현재 사용자 필터 적용
|
||||||
let enhancedSearch = { ...search };
|
let enhancedSearch = { ...search };
|
||||||
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
|
if (autoFilter?.enabled && req.user) {
|
||||||
if (shouldApplyAutoFilter && req.user) {
|
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||||
const filterColumn = autoFilter?.filterColumn || "company_code";
|
const userField = autoFilter.userField || "companyCode";
|
||||||
const userField = autoFilter?.userField || "companyCode";
|
|
||||||
const userValue = (req.user as any)[userField];
|
const userValue = (req.user as any)[userField];
|
||||||
|
|
||||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
if (userValue) {
|
||||||
let finalCompanyCode = userValue;
|
enhancedSearch[filterColumn] = userValue;
|
||||||
if (autoFilter?.companyCodeOverride && userValue === "*") {
|
|
||||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
|
||||||
finalCompanyCode = autoFilter.companyCodeOverride;
|
|
||||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
|
||||||
originalCompanyCode: userValue,
|
|
||||||
overrideCompanyCode: autoFilter.companyCodeOverride,
|
|
||||||
tableName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalCompanyCode) {
|
|
||||||
enhancedSearch[filterColumn] = finalCompanyCode;
|
|
||||||
|
|
||||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
userField,
|
userField,
|
||||||
userValue: finalCompanyCode,
|
userValue,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -883,27 +870,6 @@ export async function addTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
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);
|
await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
|
|
@ -1834,449 +1800,3 @@ export async function getCategoryColumnsByMenu(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 범용 다중 테이블 저장 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 || "통계 조회 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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 || "구간별 통계 조회에 실패했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -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 || "운행 취소에 실패했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -54,17 +54,16 @@ export const authenticateToken = (
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
logger.error(
|
||||||
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||||
|
);
|
||||||
// 토큰 만료 에러인지 확인
|
|
||||||
const isTokenExpired = errorMessage.includes("만료");
|
|
||||||
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
code: "INVALID_TOKEN",
|
||||||
details: errorMessage || "토큰 검증에 실패했습니다.",
|
details:
|
||||||
|
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,6 @@ export const errorHandler = (
|
||||||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||||
if ((err as any).code) {
|
if ((err as any).code) {
|
||||||
const pgError = err as any;
|
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
|
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
if (pgError.code === "23505") {
|
if (pgError.code === "23505") {
|
||||||
// unique_violation
|
// unique_violation
|
||||||
|
|
@ -52,7 +42,7 @@ export const errorHandler = (
|
||||||
// 기타 무결성 제약 조건 위반
|
// 기타 무결성 제약 조건 위반
|
||||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||||
} else {
|
} else {
|
||||||
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ import {
|
||||||
getDepartmentList, // 부서 목록 조회
|
getDepartmentList, // 부서 목록 조회
|
||||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
|
||||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
getCompanyByCode, // 회사 단건 조회
|
getCompanyByCode, // 회사 단건 조회
|
||||||
|
|
@ -52,10 +50,8 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||||
router.get("/users", getUserList);
|
router.get("/users", getUserList);
|
||||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||||
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
|
||||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||||
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
|
||||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||||
router.put("/profile", updateProfile); // 프로필 수정
|
router.put("/profile", updateProfile); // 프로필 수정
|
||||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,8 @@ router.post("/refresh", AuthController.refreshToken);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/signup
|
* POST /api/auth/signup
|
||||||
* 공차중계 회원가입 API
|
* 회원가입 API
|
||||||
*/
|
*/
|
||||||
router.post("/signup", AuthController.signup);
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/switch-company
|
|
||||||
* WACE 관리자 전용: 다른 회사로 전환
|
|
||||||
*/
|
|
||||||
router.post("/switch-company", AuthController.switchCompany);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,4 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
|
||||||
*/
|
*/
|
||||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/batch-management/auth-services
|
|
||||||
* 인증 토큰 서비스명 목록 조회
|
|
||||||
*/
|
|
||||||
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
/**
|
|
||||||
* 자동 입력 (Auto-Fill) 라우트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
getAutoFillGroups,
|
|
||||||
getAutoFillGroupDetail,
|
|
||||||
createAutoFillGroup,
|
|
||||||
updateAutoFillGroup,
|
|
||||||
deleteAutoFillGroup,
|
|
||||||
getAutoFillMasterOptions,
|
|
||||||
getAutoFillData,
|
|
||||||
} from "../controllers/cascadingAutoFillController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 자동 입력 그룹 관리 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 그룹 목록 조회
|
|
||||||
router.get("/groups", getAutoFillGroups);
|
|
||||||
|
|
||||||
// 그룹 상세 조회 (매핑 포함)
|
|
||||||
router.get("/groups/:groupCode", getAutoFillGroupDetail);
|
|
||||||
|
|
||||||
// 그룹 생성
|
|
||||||
router.post("/groups", createAutoFillGroup);
|
|
||||||
|
|
||||||
// 그룹 수정
|
|
||||||
router.put("/groups/:groupCode", updateAutoFillGroup);
|
|
||||||
|
|
||||||
// 그룹 삭제
|
|
||||||
router.delete("/groups/:groupCode", deleteAutoFillGroup);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 자동 입력 데이터 조회 API (실제 사용)
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 마스터 옵션 목록 조회
|
|
||||||
router.get("/options/:groupCode", getAutoFillMasterOptions);
|
|
||||||
|
|
||||||
// 자동 입력 데이터 조회
|
|
||||||
router.get("/data/:groupCode", getAutoFillData);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/**
|
|
||||||
* 조건부 연쇄 (Conditional Cascading) 라우트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
getConditions,
|
|
||||||
getConditionDetail,
|
|
||||||
createCondition,
|
|
||||||
updateCondition,
|
|
||||||
deleteCondition,
|
|
||||||
getFilteredOptions,
|
|
||||||
} from "../controllers/cascadingConditionController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 조건부 연쇄 규칙 관리 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 규칙 목록 조회
|
|
||||||
router.get("/", getConditions);
|
|
||||||
|
|
||||||
// 규칙 상세 조회
|
|
||||||
router.get("/:conditionId", getConditionDetail);
|
|
||||||
|
|
||||||
// 규칙 생성
|
|
||||||
router.post("/", createCondition);
|
|
||||||
|
|
||||||
// 규칙 수정
|
|
||||||
router.put("/:conditionId", updateCondition);
|
|
||||||
|
|
||||||
// 규칙 삭제
|
|
||||||
router.delete("/:conditionId", deleteCondition);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 조건부 필터링 적용 API (실제 사용)
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 조건에 따른 필터링된 옵션 조회
|
|
||||||
router.get("/filtered-options/:relationCode", getFilteredOptions);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* 다단계 계층 (Hierarchy) 라우트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
getHierarchyGroups,
|
|
||||||
getHierarchyGroupDetail,
|
|
||||||
createHierarchyGroup,
|
|
||||||
updateHierarchyGroup,
|
|
||||||
deleteHierarchyGroup,
|
|
||||||
addLevel,
|
|
||||||
updateLevel,
|
|
||||||
deleteLevel,
|
|
||||||
getLevelOptions,
|
|
||||||
} from "../controllers/cascadingHierarchyController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 계층 그룹 관리 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 그룹 목록 조회
|
|
||||||
router.get("/", getHierarchyGroups);
|
|
||||||
|
|
||||||
// 그룹 상세 조회 (레벨 포함)
|
|
||||||
router.get("/:groupCode", getHierarchyGroupDetail);
|
|
||||||
|
|
||||||
// 그룹 생성
|
|
||||||
router.post("/", createHierarchyGroup);
|
|
||||||
|
|
||||||
// 그룹 수정
|
|
||||||
router.put("/:groupCode", updateHierarchyGroup);
|
|
||||||
|
|
||||||
// 그룹 삭제
|
|
||||||
router.delete("/:groupCode", deleteHierarchyGroup);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 계층 레벨 관리 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 레벨 추가
|
|
||||||
router.post("/:groupCode/levels", addLevel);
|
|
||||||
|
|
||||||
// 레벨 수정
|
|
||||||
router.put("/levels/:levelId", updateLevel);
|
|
||||||
|
|
||||||
// 레벨 삭제
|
|
||||||
router.delete("/levels/:levelId", deleteLevel);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 계층 옵션 조회 API (실제 사용)
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 특정 레벨의 옵션 조회
|
|
||||||
router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
/**
|
|
||||||
* 상호 배제 (Mutual Exclusion) 라우트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
import {
|
|
||||||
getExclusions,
|
|
||||||
getExclusionDetail,
|
|
||||||
createExclusion,
|
|
||||||
updateExclusion,
|
|
||||||
deleteExclusion,
|
|
||||||
validateExclusion,
|
|
||||||
getExcludedOptions,
|
|
||||||
} from "../controllers/cascadingMutualExclusionController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 상호 배제 규칙 관리 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 규칙 목록 조회
|
|
||||||
router.get("/", getExclusions);
|
|
||||||
|
|
||||||
// 규칙 상세 조회
|
|
||||||
router.get("/:exclusionId", getExclusionDetail);
|
|
||||||
|
|
||||||
// 규칙 생성
|
|
||||||
router.post("/", createExclusion);
|
|
||||||
|
|
||||||
// 규칙 수정
|
|
||||||
router.put("/:exclusionId", updateExclusion);
|
|
||||||
|
|
||||||
// 규칙 삭제
|
|
||||||
router.delete("/:exclusionId", deleteExclusion);
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 상호 배제 검증 및 옵션 API (실제 사용)
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
// 상호 배제 검증
|
|
||||||
router.post("/validate/:exclusionCode", validateExclusion);
|
|
||||||
|
|
||||||
// 배제된 옵션 조회
|
|
||||||
router.get("/options/:exclusionCode", getExcludedOptions);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
getCascadingRelations,
|
|
||||||
getCascadingRelationById,
|
|
||||||
getCascadingRelationByCode,
|
|
||||||
createCascadingRelation,
|
|
||||||
updateCascadingRelation,
|
|
||||||
deleteCascadingRelation,
|
|
||||||
getCascadingOptions,
|
|
||||||
getParentOptions,
|
|
||||||
} from "../controllers/cascadingRelationController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// 연쇄 관계 목록 조회
|
|
||||||
router.get("/", getCascadingRelations);
|
|
||||||
|
|
||||||
// 연쇄 관계 상세 조회 (ID)
|
|
||||||
router.get("/:id", getCascadingRelationById);
|
|
||||||
|
|
||||||
// 연쇄 관계 코드로 조회
|
|
||||||
router.get("/code/:code", getCascadingRelationByCode);
|
|
||||||
|
|
||||||
// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
|
||||||
router.get("/parent-options/:code", getParentOptions);
|
|
||||||
|
|
||||||
// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용)
|
|
||||||
router.get("/options/:code", getCascadingOptions);
|
|
||||||
|
|
||||||
// 연쇄 관계 생성
|
|
||||||
router.post("/", createCascadingRelation);
|
|
||||||
|
|
||||||
// 연쇄 관계 수정
|
|
||||||
router.put("/:id", updateCascadingRelation);
|
|
||||||
|
|
||||||
// 연쇄 관계 삭제
|
|
||||||
router.delete("/:id", deleteCascadingRelation);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
getCategoryValueCascadingGroups,
|
|
||||||
getCategoryValueCascadingGroupById,
|
|
||||||
getCategoryValueCascadingByCode,
|
|
||||||
createCategoryValueCascadingGroup,
|
|
||||||
updateCategoryValueCascadingGroup,
|
|
||||||
deleteCategoryValueCascadingGroup,
|
|
||||||
saveCategoryValueCascadingMappings,
|
|
||||||
getCategoryValueCascadingOptions,
|
|
||||||
getCategoryValueCascadingParentOptions,
|
|
||||||
getCategoryValueCascadingChildOptions,
|
|
||||||
getCategoryValueCascadingMappingsByTable,
|
|
||||||
} from "../controllers/categoryValueCascadingController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 카테고리 값 연쇄관계 그룹 CRUD
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 그룹 목록 조회
|
|
||||||
router.get("/groups", getCategoryValueCascadingGroups);
|
|
||||||
|
|
||||||
// 그룹 상세 조회 (ID)
|
|
||||||
router.get("/groups/:groupId", getCategoryValueCascadingGroupById);
|
|
||||||
|
|
||||||
// 관계 코드로 조회
|
|
||||||
router.get("/code/:code", getCategoryValueCascadingByCode);
|
|
||||||
|
|
||||||
// 그룹 생성
|
|
||||||
router.post("/groups", createCategoryValueCascadingGroup);
|
|
||||||
|
|
||||||
// 그룹 수정
|
|
||||||
router.put("/groups/:groupId", updateCategoryValueCascadingGroup);
|
|
||||||
|
|
||||||
// 그룹 삭제
|
|
||||||
router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 카테고리 값 연쇄관계 매핑
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 매핑 일괄 저장
|
|
||||||
router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 연쇄 옵션 조회 (실제 드롭다운에서 사용)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 부모 카테고리 값 목록 조회
|
|
||||||
router.get("/parent-options/:code", getCategoryValueCascadingParentOptions);
|
|
||||||
|
|
||||||
// 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
|
|
||||||
router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
|
|
||||||
|
|
||||||
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
|
|
||||||
router.get("/options/:code", getCategoryValueCascadingOptions);
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 테이블별 매핑 조회 (테이블 목록 표시용)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
|
|
||||||
router.get(
|
|
||||||
"/table/:tableName/mappings",
|
|
||||||
getCategoryValueCascadingMappingsByTable
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -3,8 +3,6 @@ import {
|
||||||
mergeCodeAllTables,
|
mergeCodeAllTables,
|
||||||
getTablesWithColumn,
|
getTablesWithColumn,
|
||||||
previewCodeMerge,
|
previewCodeMerge,
|
||||||
mergeCodeByValue,
|
|
||||||
previewMergeCodeByValue,
|
|
||||||
} from "../controllers/codeMergeController";
|
} from "../controllers/codeMergeController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -15,7 +13,7 @@ router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/merge-all-tables
|
* POST /api/code-merge/merge-all-tables
|
||||||
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||||
* Body: { columnName, oldValue, newValue }
|
* Body: { columnName, oldValue, newValue }
|
||||||
*/
|
*/
|
||||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||||
|
|
@ -28,24 +26,10 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/preview
|
* POST /api/code-merge/preview
|
||||||
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||||
* Body: { columnName, oldValue }
|
* Body: { columnName, oldValue }
|
||||||
*/
|
*/
|
||||||
router.post("/preview", previewCodeMerge);
|
router.post("/preview", previewCodeMerge);
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/code-merge/merge-by-value
|
|
||||||
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
|
||||||
* Body: { oldValue, newValue }
|
|
||||||
*/
|
|
||||||
router.post("/merge-by-value", mergeCodeByValue);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/code-merge/preview-by-value
|
|
||||||
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
|
||||||
* Body: { oldValue }
|
|
||||||
*/
|
|
||||||
router.post("/preview-by-value", previewMergeCodeByValue);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,21 +46,6 @@ router.put("/categories/:categoryCode/codes/reorder", (req, res) =>
|
||||||
commonCodeController.reorderCodes(req, res)
|
commonCodeController.reorderCodes(req, res)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 계층구조 코드 조회 (구체적인 경로를 먼저 배치)
|
|
||||||
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
|
|
||||||
commonCodeController.getHierarchicalCodes(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 코드 트리 조회
|
|
||||||
router.get("/categories/:categoryCode/tree", (req, res) =>
|
|
||||||
commonCodeController.getCodeTree(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 자식 코드 존재 여부 확인
|
|
||||||
router.get("/categories/:categoryCode/codes/:codeValue/has-children", (req, res) =>
|
|
||||||
commonCodeController.hasChildren(req, res)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
|
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
|
||||||
commonCodeController.updateCode(req, res)
|
commonCodeController.updateCode(req, res)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,262 +1,10 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 마스터-디테일 엑셀 API
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보 조회
|
|
||||||
* GET /api/data/master-detail/relation/:screenId
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/master-detail/relation/:screenId",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId } = req.params;
|
|
||||||
|
|
||||||
if (!screenId || isNaN(parseInt(screenId))) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효한 screenId가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
|
||||||
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: null,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
|
||||||
masterTable: relation.masterTable,
|
|
||||||
detailTable: relation.detailTable,
|
|
||||||
joinKey: relation.masterKeyColumn,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: relation,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 관계 조회 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
|
||||||
* POST /api/data/master-detail/download
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/download",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, filters } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
|
|
||||||
if (!screenId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 조회
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. JOIN 데이터 조회
|
|
||||||
const data = await masterDetailExcelService.getJoinedData(
|
|
||||||
relation,
|
|
||||||
companyCode,
|
|
||||||
filters
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 다운로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 업로드
|
|
||||||
* POST /api/data/master-detail/upload
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/upload",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, data } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
const userId = req.user?.userId;
|
|
||||||
|
|
||||||
if (!screenId || !data || !Array.isArray(data)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId와 data 배열이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 조회
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 데이터 업로드
|
|
||||||
const result = await masterDetailExcelService.uploadJoinedData(
|
|
||||||
relation,
|
|
||||||
data,
|
|
||||||
companyCode,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
masterUpdated: result.masterUpdated,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: result.success,
|
|
||||||
data: result,
|
|
||||||
message: result.success
|
|
||||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
|
||||||
: "업로드 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 업로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 간단 모드 엑셀 업로드
|
|
||||||
* - 마스터 정보는 UI에서 선택
|
|
||||||
* - 디테일 정보만 엑셀에서 업로드
|
|
||||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
|
||||||
*
|
|
||||||
* POST /api/data/master-detail/upload-simple
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/upload-simple",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
const userId = req.user?.userId || "system";
|
|
||||||
|
|
||||||
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId와 detailData 배열이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
|
||||||
console.log(` 마스터 필드 값:`, masterFieldValues);
|
|
||||||
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
|
||||||
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
|
||||||
|
|
||||||
// 업로드 실행
|
|
||||||
const result = await masterDetailExcelService.uploadSimple(
|
|
||||||
parseInt(screenId),
|
|
||||||
detailData,
|
|
||||||
masterFieldValues || {},
|
|
||||||
numberingRuleId,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
|
||||||
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
generatedKey: result.generatedKey,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: result.success,
|
|
||||||
data: result,
|
|
||||||
message: result.success
|
|
||||||
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
|
||||||
: "업로드 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 기존 데이터 API
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
@ -950,7 +698,6 @@ router.post(
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const filterConditions = req.body;
|
const filterConditions = req.body;
|
||||||
const userCompany = req.user?.companyCode;
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -959,12 +706,11 @@ router.post(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||||
|
|
||||||
const result = await dataService.deleteGroupRecords(
|
const result = await dataService.deleteGroupRecords(
|
||||||
tableName,
|
tableName,
|
||||||
filterConditions,
|
filterConditions
|
||||||
userCompany // 회사 코드 전달
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -214,81 +214,11 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 플로우 소스 테이블 조회
|
|
||||||
* GET /api/dataflow/node-flows/:flowId/source-table
|
|
||||||
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
|
||||||
*/
|
|
||||||
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { flowId } = req.params;
|
|
||||||
|
|
||||||
const flow = await queryOne<{ flow_data: any }>(
|
|
||||||
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
|
|
||||||
[flowId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!flow) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: "플로우를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const flowData =
|
|
||||||
typeof flow.flow_data === "string"
|
|
||||||
? JSON.parse(flow.flow_data)
|
|
||||||
: flow.flow_data;
|
|
||||||
|
|
||||||
const nodes = flowData.nodes || [];
|
|
||||||
|
|
||||||
// 소스 노드 찾기 (tableSource, externalDBSource 타입)
|
|
||||||
const sourceNode = nodes.find(
|
|
||||||
(node: any) =>
|
|
||||||
node.type === "tableSource" || node.type === "externalDBSource"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sourceNode || !sourceNode.data?.tableName) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
sourceTable: null,
|
|
||||||
sourceNodeType: null,
|
|
||||||
message: "소스 노드가 없거나 테이블명이 설정되지 않았습니다.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`플로우 소스 테이블 조회: flowId=${flowId}, table=${sourceNode.data.tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
sourceTable: sourceNode.data.tableName,
|
|
||||||
sourceNodeType: sourceNode.type,
|
|
||||||
sourceNodeId: sourceNode.id,
|
|
||||||
displayName: sourceNode.data.displayName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("플로우 소스 테이블 조회 실패:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "플로우 소스 테이블을 조회하지 못했습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
"/:flowId/execute",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
try {
|
||||||
const { flowId } = req.params;
|
const { flowId } = req.params;
|
||||||
const contextData = req.body;
|
const contextData = req.body;
|
||||||
|
|
@ -299,12 +229,6 @@ router.post(
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 디버깅: req.user 전체 확인
|
|
||||||
logger.info(`🔍 req.user 전체 정보:`, {
|
|
||||||
user: req.user,
|
|
||||||
hasUser: !!req.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 정보를 contextData에 추가
|
// 사용자 정보를 contextData에 추가
|
||||||
const enrichedContextData = {
|
const enrichedContextData = {
|
||||||
...contextData,
|
...contextData,
|
||||||
|
|
@ -313,12 +237,6 @@ router.post(
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔍 디버깅: enrichedContextData 확인
|
|
||||||
logger.info(`🔍 enrichedContextData:`, {
|
|
||||||
userId: enrichedContextData.userId,
|
|
||||||
companyCode: enrichedContextData.companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 플로우 실행
|
// 플로우 실행
|
||||||
const result = await NodeFlowExecutionService.executeFlow(
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
parseInt(flowId, 10),
|
parseInt(flowId, 10),
|
||||||
|
|
@ -340,7 +258,6 @@ router.post(
|
||||||
: "플로우 실행 중 오류가 발생했습니다.",
|
: "플로우 실행 중 오류가 발생했습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// 공차중계 운전자 API 라우터
|
|
||||||
import { Router } from "express";
|
|
||||||
import { DriverController } from "../controllers/driverController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 필요
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/driver/profile
|
|
||||||
* 운전자 프로필 조회
|
|
||||||
*/
|
|
||||||
router.get("/profile", DriverController.getProfile);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/driver/profile
|
|
||||||
* 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종)
|
|
||||||
*/
|
|
||||||
router.put("/profile", DriverController.updateProfile);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/driver/status
|
|
||||||
* 차량 상태 변경 (대기/정비만)
|
|
||||||
*/
|
|
||||||
router.put("/status", DriverController.updateStatus);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/driver/vehicle
|
|
||||||
* 차량 삭제 (기록 보존)
|
|
||||||
*/
|
|
||||||
router.delete("/vehicle", DriverController.deleteVehicle);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/driver/vehicle
|
|
||||||
* 새 차량 등록
|
|
||||||
*/
|
|
||||||
router.post("/vehicle", DriverController.registerVehicle);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/driver/account
|
|
||||||
* 회원 탈퇴
|
|
||||||
*/
|
|
||||||
router.delete("/account", DriverController.deleteAccount);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -12,8 +12,6 @@ import {
|
||||||
validateFormData,
|
validateFormData,
|
||||||
getTableColumns,
|
getTableColumns,
|
||||||
getTablePrimaryKeys,
|
getTablePrimaryKeys,
|
||||||
saveLocationHistory,
|
|
||||||
getLocationHistory,
|
|
||||||
} from "../controllers/dynamicFormController";
|
} from "../controllers/dynamicFormController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -42,8 +40,4 @@ router.get("/table/:tableName/columns", getTableColumns);
|
||||||
// 테이블 기본키 조회
|
// 테이블 기본키 조회
|
||||||
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
||||||
|
|
||||||
// 위치 이력 (연속 위치 추적)
|
|
||||||
router.post("/location-history", saveLocationHistory);
|
|
||||||
router.get("/location-history/:tripId", getLocationHistory);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
import {
|
|
||||||
findMappingByColumns,
|
|
||||||
saveMappingTemplate,
|
|
||||||
getMappingTemplates,
|
|
||||||
deleteMappingTemplate,
|
|
||||||
} from "../controllers/excelMappingController";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 엑셀 컬럼 구조로 매핑 템플릿 조회
|
|
||||||
router.post("/find", authenticateToken, findMappingByColumns);
|
|
||||||
|
|
||||||
// 매핑 템플릿 저장 (UPSERT)
|
|
||||||
router.post("/save", authenticateToken, saveMappingTemplate);
|
|
||||||
|
|
||||||
// 테이블의 매핑 템플릿 목록 조회
|
|
||||||
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
|
|
||||||
|
|
||||||
// 매핑 템플릿 삭제
|
|
||||||
router.delete("/:id", authenticateToken, deleteMappingTemplate);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -97,8 +97,6 @@ router.post(
|
||||||
const data: ExternalRestApiConnection = {
|
const data: ExternalRestApiConnection = {
|
||||||
...req.body,
|
...req.body,
|
||||||
created_by: req.user?.userId || "system",
|
created_by: req.user?.userId || "system",
|
||||||
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
|
|
||||||
company_code: req.body.company_code || req.user?.companyCode || "*",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,6 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||||
router.post("/move", flowController.moveData);
|
router.post("/move", flowController.moveData);
|
||||||
router.post("/move-batch", flowController.moveBatchData);
|
router.post("/move-batch", flowController.moveBatchData);
|
||||||
|
|
||||||
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
|
|
||||||
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
|
|
||||||
|
|
||||||
// ==================== 오딧 로그 ====================
|
// ==================== 오딧 로그 ====================
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,6 @@ import {
|
||||||
getUserText,
|
getUserText,
|
||||||
getLangText,
|
getLangText,
|
||||||
getBatchTranslations,
|
getBatchTranslations,
|
||||||
|
|
||||||
// 카테고리 관리 API
|
|
||||||
getCategories,
|
|
||||||
getCategoryById,
|
|
||||||
getCategoryPath,
|
|
||||||
|
|
||||||
// 자동 생성 및 오버라이드 API
|
|
||||||
generateKey,
|
|
||||||
previewKey,
|
|
||||||
createOverrideKey,
|
|
||||||
getOverrideKeys,
|
|
||||||
|
|
||||||
// 화면 라벨 다국어 API
|
|
||||||
generateScreenLabelKeys,
|
|
||||||
} from "../controllers/multilangController";
|
} from "../controllers/multilangController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
|
||||||
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
||||||
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
||||||
|
|
||||||
// 카테고리 관리 API
|
|
||||||
router.get("/categories", getCategories); // 카테고리 트리 조회
|
|
||||||
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
|
|
||||||
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
|
|
||||||
|
|
||||||
// 자동 생성 및 오버라이드 API
|
|
||||||
router.post("/keys/generate", generateKey); // 키 자동 생성
|
|
||||||
router.post("/keys/preview", previewKey); // 키 미리보기
|
|
||||||
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
|
||||||
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
|
||||||
|
|
||||||
// 화면 라벨 다국어 자동 생성 API
|
|
||||||
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { createOrder, getOrders } from "../controllers/orderController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 등록
|
||||||
|
* POST /api/orders
|
||||||
|
*/
|
||||||
|
router.post("/", authenticateToken, createOrder);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 목록 조회
|
||||||
|
* GET /api/orders
|
||||||
|
*/
|
||||||
|
router.get("/", authenticateToken, getOrders);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -56,11 +56,6 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
||||||
reportController.uploadImage(req, res, next)
|
reportController.uploadImage(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
// WORD(DOCX) 내보내기
|
|
||||||
router.post("/export-word", (req, res, next) =>
|
|
||||||
reportController.exportToWord(req, res, next)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 리포트 목록
|
// 리포트 목록
|
||||||
router.get("/", (req, res, next) =>
|
router.get("/", (req, res, next) =>
|
||||||
reportController.getReports(req, res, next)
|
reportController.getReports(req, res, next)
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,6 @@ const router = Router();
|
||||||
// 모든 role 라우트에 인증 미들웨어 적용
|
// 모든 role 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
|
||||||
*/
|
|
||||||
// 현재 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/my-groups", getUserRoleGroups);
|
|
||||||
|
|
||||||
// 특정 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 CRUD
|
* 권한 그룹 CRUD
|
||||||
*/
|
*/
|
||||||
|
|
@ -76,4 +67,13 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
||||||
// 메뉴 권한 설정
|
// 메뉴 권한 설정
|
||||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 그룹 조회
|
||||||
|
*/
|
||||||
|
// 현재 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/my-groups", getUserRoleGroups);
|
||||||
|
|
||||||
|
// 특정 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
import {
|
|
||||||
// 화면 그룹
|
|
||||||
getScreenGroups,
|
|
||||||
getScreenGroup,
|
|
||||||
createScreenGroup,
|
|
||||||
updateScreenGroup,
|
|
||||||
deleteScreenGroup,
|
|
||||||
// 화면-그룹 연결
|
|
||||||
addScreenToGroup,
|
|
||||||
removeScreenFromGroup,
|
|
||||||
updateScreenInGroup,
|
|
||||||
// 필드 조인
|
|
||||||
getFieldJoins,
|
|
||||||
createFieldJoin,
|
|
||||||
updateFieldJoin,
|
|
||||||
deleteFieldJoin,
|
|
||||||
// 데이터 흐름
|
|
||||||
getDataFlows,
|
|
||||||
createDataFlow,
|
|
||||||
updateDataFlow,
|
|
||||||
deleteDataFlow,
|
|
||||||
// 화면-테이블 관계
|
|
||||||
getTableRelations,
|
|
||||||
createTableRelation,
|
|
||||||
updateTableRelation,
|
|
||||||
deleteTableRelation,
|
|
||||||
// 화면 레이아웃 요약
|
|
||||||
getScreenLayoutSummary,
|
|
||||||
getMultipleScreenLayoutSummary,
|
|
||||||
// 화면 서브 테이블 관계
|
|
||||||
getScreenSubTables,
|
|
||||||
// 메뉴-화면그룹 동기화
|
|
||||||
syncScreenGroupsToMenuController,
|
|
||||||
syncMenuToScreenGroupsController,
|
|
||||||
getSyncStatusController,
|
|
||||||
syncAllCompaniesController,
|
|
||||||
} from "../controllers/screenGroupController";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면 그룹 (screen_groups)
|
|
||||||
// ============================================================
|
|
||||||
router.get("/groups", getScreenGroups);
|
|
||||||
router.get("/groups/:id", getScreenGroup);
|
|
||||||
router.post("/groups", createScreenGroup);
|
|
||||||
router.put("/groups/:id", updateScreenGroup);
|
|
||||||
router.delete("/groups/:id", deleteScreenGroup);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면-그룹 연결 (screen_group_screens)
|
|
||||||
// ============================================================
|
|
||||||
router.post("/group-screens", addScreenToGroup);
|
|
||||||
router.put("/group-screens/:id", updateScreenInGroup);
|
|
||||||
router.delete("/group-screens/:id", removeScreenFromGroup);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 필드 조인 설정 (screen_field_joins)
|
|
||||||
// ============================================================
|
|
||||||
router.get("/field-joins", getFieldJoins);
|
|
||||||
router.post("/field-joins", createFieldJoin);
|
|
||||||
router.put("/field-joins/:id", updateFieldJoin);
|
|
||||||
router.delete("/field-joins/:id", deleteFieldJoin);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 데이터 흐름 (screen_data_flows)
|
|
||||||
// ============================================================
|
|
||||||
router.get("/data-flows", getDataFlows);
|
|
||||||
router.post("/data-flows", createDataFlow);
|
|
||||||
router.put("/data-flows/:id", updateDataFlow);
|
|
||||||
router.delete("/data-flows/:id", deleteDataFlow);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면-테이블 관계 (screen_table_relations)
|
|
||||||
// ============================================================
|
|
||||||
router.get("/table-relations", getTableRelations);
|
|
||||||
router.post("/table-relations", createTableRelation);
|
|
||||||
router.put("/table-relations/:id", updateTableRelation);
|
|
||||||
router.delete("/table-relations/:id", deleteTableRelation);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면 레이아웃 요약 (미리보기용)
|
|
||||||
// ============================================================
|
|
||||||
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
|
|
||||||
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면 서브 테이블 관계 (조인/참조 테이블)
|
|
||||||
// ============================================================
|
|
||||||
router.post("/sub-tables/batch", getScreenSubTables);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 메뉴-화면그룹 동기화
|
|
||||||
// ============================================================
|
|
||||||
// 동기화 상태 조회
|
|
||||||
router.get("/sync/status", getSyncStatusController);
|
|
||||||
// 화면관리 → 메뉴 동기화
|
|
||||||
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
|
|
||||||
// 메뉴 → 화면관리 동기화
|
|
||||||
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
|
||||||
// 전체 회사 동기화 (최고 관리자만)
|
|
||||||
router.post("/sync/all", syncAllCompaniesController);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
bulkDeleteScreens,
|
|
||||||
checkScreenDependencies,
|
checkScreenDependencies,
|
||||||
restoreScreen,
|
restoreScreen,
|
||||||
permanentDeleteScreen,
|
permanentDeleteScreen,
|
||||||
|
|
@ -45,7 +44,6 @@ router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
|
||||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getCategoryColumns,
|
getCategoryColumns,
|
||||||
getAllCategoryColumns,
|
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
addCategoryValue,
|
addCategoryValue,
|
||||||
updateCategoryValue,
|
updateCategoryValue,
|
||||||
|
|
@ -14,7 +13,6 @@ import {
|
||||||
deleteColumnMapping,
|
deleteColumnMapping,
|
||||||
deleteColumnMappingsByColumn,
|
deleteColumnMappingsByColumn,
|
||||||
getSecondLevelMenus,
|
getSecondLevelMenus,
|
||||||
getCategoryLabelsByCodes,
|
|
||||||
} from "../controllers/tableCategoryValueController";
|
} from "../controllers/tableCategoryValueController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -23,10 +21,6 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
|
||||||
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
|
||||||
router.get("/all-columns", getAllCategoryColumns);
|
|
||||||
|
|
||||||
// 테이블의 카테고리 컬럼 목록 조회
|
// 테이블의 카테고리 컬럼 목록 조회
|
||||||
router.get("/:tableName/columns", getCategoryColumns);
|
router.get("/:tableName/columns", getCategoryColumns);
|
||||||
|
|
||||||
|
|
@ -48,9 +42,6 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||||
// 카테고리 값 순서 변경
|
// 카테고리 값 순서 변경
|
||||||
router.post("/values/reorder", reorderCategoryValues);
|
router.post("/values/reorder", reorderCategoryValues);
|
||||||
|
|
||||||
// 카테고리 코드로 라벨 조회
|
|
||||||
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||||
// ================================================
|
// ================================================
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ import {
|
||||||
getLogData,
|
getLogData,
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
|
||||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -39,15 +37,6 @@ router.use(authenticateToken);
|
||||||
*/
|
*/
|
||||||
router.get("/tables", getTableList);
|
router.get("/tables", getTableList);
|
||||||
|
|
||||||
/**
|
|
||||||
* 두 테이블 간 엔티티 관계 조회
|
|
||||||
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
|
||||||
*
|
|
||||||
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
|
||||||
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
|
||||||
*/
|
|
||||||
router.get("/tables/entity-relations", getTableEntityRelations);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼 정보 조회
|
* 테이블 컬럼 정보 조회
|
||||||
* GET /api/table-management/tables/:tableName/columns
|
* GET /api/table-management/tables/:tableName/columns
|
||||||
|
|
@ -209,17 +198,4 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
*/
|
*/
|
||||||
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 범용 다중 테이블 저장 API
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다중 테이블 저장 (메인 + 서브 테이블)
|
|
||||||
* POST /api/table-management/multi-table-save
|
|
||||||
*
|
|
||||||
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
|
||||||
* 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다.
|
|
||||||
*/
|
|
||||||
router.post("/multi-table-save", multiTableSave);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* 세금계산서 라우터
|
|
||||||
* /api/tax-invoice 경로 처리
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router } from "express";
|
|
||||||
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// 목록 조회
|
|
||||||
router.get("/", TaxInvoiceController.getList);
|
|
||||||
|
|
||||||
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
|
||||||
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
|
||||||
|
|
||||||
// 비용 유형별 통계
|
|
||||||
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
|
||||||
|
|
||||||
// 상세 조회
|
|
||||||
router.get("/:id", TaxInvoiceController.getById);
|
|
||||||
|
|
||||||
// 생성
|
|
||||||
router.post("/", TaxInvoiceController.create);
|
|
||||||
|
|
||||||
// 수정
|
|
||||||
router.put("/:id", TaxInvoiceController.update);
|
|
||||||
|
|
||||||
// 삭제
|
|
||||||
router.delete("/:id", TaxInvoiceController.delete);
|
|
||||||
|
|
||||||
// 발행
|
|
||||||
router.post("/:id/issue", TaxInvoiceController.issue);
|
|
||||||
|
|
||||||
// 취소
|
|
||||||
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* 차량 운행 이력 및 리포트 라우트
|
|
||||||
*/
|
|
||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
startTrip,
|
|
||||||
endTrip,
|
|
||||||
addTripLocation,
|
|
||||||
getTripList,
|
|
||||||
getTripDetail,
|
|
||||||
getActiveTrip,
|
|
||||||
cancelTrip,
|
|
||||||
} from "../controllers/vehicleTripController";
|
|
||||||
import {
|
|
||||||
getDailyReport,
|
|
||||||
getWeeklyReport,
|
|
||||||
getMonthlyReport,
|
|
||||||
getSummaryReport,
|
|
||||||
getDriverReport,
|
|
||||||
getRouteReport,
|
|
||||||
} from "../controllers/vehicleReportController";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 적용
|
|
||||||
router.use(authenticateToken);
|
|
||||||
|
|
||||||
// === 운행 관리 ===
|
|
||||||
// 운행 시작
|
|
||||||
router.post("/trip/start", startTrip);
|
|
||||||
|
|
||||||
// 운행 종료
|
|
||||||
router.post("/trip/end", endTrip);
|
|
||||||
|
|
||||||
// 위치 기록 추가 (연속 추적)
|
|
||||||
router.post("/trip/location", addTripLocation);
|
|
||||||
|
|
||||||
// 활성 운행 조회 (현재 진행 중)
|
|
||||||
router.get("/trip/active", getActiveTrip);
|
|
||||||
|
|
||||||
// 운행 취소
|
|
||||||
router.post("/trip/cancel", cancelTrip);
|
|
||||||
|
|
||||||
// 운행 이력 목록 조회
|
|
||||||
router.get("/trips", getTripList);
|
|
||||||
|
|
||||||
// 운행 상세 조회 (경로 포함)
|
|
||||||
router.get("/trips/:tripId", getTripDetail);
|
|
||||||
|
|
||||||
// === 리포트 ===
|
|
||||||
// 요약 통계 (대시보드용)
|
|
||||||
router.get("/reports/summary", getSummaryReport);
|
|
||||||
|
|
||||||
// 일별 통계
|
|
||||||
router.get("/reports/daily", getDailyReport);
|
|
||||||
|
|
||||||
// 주별 통계
|
|
||||||
router.get("/reports/weekly", getWeeklyReport);
|
|
||||||
|
|
||||||
// 월별 통계
|
|
||||||
router.get("/reports/monthly", getMonthlyReport);
|
|
||||||
|
|
||||||
// 운전자별 통계
|
|
||||||
router.get("/reports/by-driver", getDriverReport);
|
|
||||||
|
|
||||||
// 구간별 통계
|
|
||||||
router.get("/reports/by-route", getRouteReport);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -19,21 +19,15 @@ export class AdminService {
|
||||||
|
|
||||||
// menuType에 따른 WHERE 조건 생성
|
// menuType에 따른 WHERE 조건 생성
|
||||||
const menuTypeCondition =
|
const menuTypeCondition =
|
||||||
menuType !== undefined
|
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
|
||||||
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
|
|
||||||
: "1 = 1";
|
|
||||||
|
|
||||||
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
||||||
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
||||||
const includeInactive = paramMap.includeInactive === true;
|
const includeInactive = paramMap.includeInactive === true;
|
||||||
const isManagementScreen = includeInactive || menuType === undefined;
|
const isManagementScreen = includeInactive || menuType === undefined;
|
||||||
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
||||||
const statusCondition = isManagementScreen
|
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
|
||||||
? "1 = 1"
|
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
|
||||||
: "MENU.STATUS = 'active'";
|
|
||||||
const subStatusCondition = isManagementScreen
|
|
||||||
? "1 = 1"
|
|
||||||
: "MENU_SUB.STATUS = 'active'";
|
|
||||||
|
|
||||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||||
let authFilter = "";
|
let authFilter = "";
|
||||||
|
|
@ -41,11 +35,7 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (
|
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
|
||||||
menuType !== undefined &&
|
|
||||||
userType !== "SUPER_ADMIN" &&
|
|
||||||
!isManagementScreen
|
|
||||||
) {
|
|
||||||
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
||||||
const userRoleGroups = await query<any>(
|
const userRoleGroups = await query<any>(
|
||||||
`
|
`
|
||||||
|
|
@ -65,34 +55,22 @@ export class AdminService {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
|
||||||
// TODO: 권한 체크 다시 활성화 필요
|
|
||||||
logger.info(
|
|
||||||
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
|
||||||
);
|
|
||||||
|
|
||||||
/* [원본 코드 - 권한 그룹 체크]
|
|
||||||
if (userType === "COMPANY_ADMIN") {
|
if (userType === "COMPANY_ADMIN") {
|
||||||
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
|
||||||
if (userRoleGroups.length > 0) {
|
if (userRoleGroups.length > 0) {
|
||||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||||
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
|
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
||||||
authFilter = `
|
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
||||||
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM rel_menu_auth rma
|
|
||||||
WHERE rma.menu_objid = MENU.OBJID
|
|
||||||
AND rma.auth_objid = ANY($${paramIndex + 1})
|
|
||||||
AND rma.read_yn = 'Y'
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
|
const companyParamIndex = paramIndex;
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴도 권한 체크
|
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
||||||
unionFilter = `
|
unionFilter = `
|
||||||
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
|
AND (
|
||||||
|
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
||||||
|
OR (
|
||||||
|
MENU_SUB.COMPANY_CODE = '*'
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM rel_menu_auth rma
|
FROM rel_menu_auth rma
|
||||||
|
|
@ -100,18 +78,23 @@ export class AdminService {
|
||||||
AND rma.auth_objid = ANY($${paramIndex})
|
AND rma.auth_objid = ANY($${paramIndex})
|
||||||
AND rma.read_yn = 'Y'
|
AND rma.read_yn = 'Y'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
`;
|
`;
|
||||||
queryParams.push(roleObjids);
|
queryParams.push(roleObjids);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
|
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
|
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
||||||
logger.warn(
|
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||||
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
||||||
|
queryParams.push(userCompanyCode);
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(
|
||||||
|
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
|
||||||
);
|
);
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자: 권한 그룹 필수
|
// 일반 사용자: 권한 그룹 필수
|
||||||
|
|
@ -148,12 +131,7 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
|
||||||
} else if (
|
|
||||||
menuType !== undefined &&
|
|
||||||
userType === "SUPER_ADMIN" &&
|
|
||||||
!isManagementScreen
|
|
||||||
) {
|
|
||||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||||
|
|
@ -420,18 +398,9 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||||
// TODO: 권한 체크 다시 활성화 필요
|
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
||||||
logger.info(
|
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
|
||||||
);
|
|
||||||
authFilter = "";
|
|
||||||
unionFilter = "";
|
|
||||||
|
|
||||||
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
|
|
||||||
if (userType === "SUPER_ADMIN") {
|
|
||||||
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
|
||||||
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
|
||||||
authFilter = "";
|
authFilter = "";
|
||||||
unionFilter = "";
|
unionFilter = "";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -488,7 +457,6 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
// 2. 회사별 필터링 조건 생성
|
// 2. 회사별 필터링 조건 생성
|
||||||
let companyFilter = "";
|
let companyFilter = "";
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ export class AuthService {
|
||||||
[userInfo.company_code || "ILSHIN"]
|
[userInfo.company_code || "ILSHIN"]
|
||||||
);
|
);
|
||||||
|
|
||||||
const companyName = companyResult.length > 0 ? companyResult[0].company_name : undefined;
|
const companyName =
|
||||||
|
companyResult.length > 0 ? companyResult[0].company_name : undefined;
|
||||||
|
|
||||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||||
|
|
@ -344,11 +345,11 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공차중계 회원가입 처리
|
* 회원가입 처리
|
||||||
* - user_info 테이블에 사용자 정보 저장
|
* - user_info 테이블에 사용자 정보 저장
|
||||||
* - vehicles 테이블에 차량 정보 저장
|
* - vehicles 테이블에 차량 정보 저장 (공차중계용)
|
||||||
*/
|
*/
|
||||||
static async signupDriver(data: {
|
static async signupUser(data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
password: string;
|
password: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
|
@ -412,10 +413,9 @@ export class AuthService {
|
||||||
vehicle_number,
|
vehicle_number,
|
||||||
company_code,
|
company_code,
|
||||||
user_type,
|
user_type,
|
||||||
signup_type,
|
|
||||||
status,
|
status,
|
||||||
regdate
|
regdate
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
|
|
@ -425,12 +425,12 @@ export class AuthService {
|
||||||
vehicleNumber,
|
vehicleNumber,
|
||||||
"COMPANY_13", // 기본 회사 코드
|
"COMPANY_13", // 기본 회사 코드
|
||||||
null, // user_type: null
|
null, // user_type: null
|
||||||
"DRIVER", // signup_type: 공차중계 회원가입 사용자
|
|
||||||
"active", // status: active
|
"active", // status: active
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. 차량 정보 저장 (vehicles)
|
// 5. 차량 정보 저장 (vehicles) - 공차중계용
|
||||||
|
// status = 'off': 앱 미사용/로그아웃 상태
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO vehicles (
|
`INSERT INTO vehicles (
|
||||||
vehicle_number,
|
vehicle_number,
|
||||||
|
|
@ -448,20 +448,20 @@ export class AuthService {
|
||||||
vehicleType || null,
|
vehicleType || null,
|
||||||
userName,
|
userName,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
"off", // 초기 상태: off (대기)
|
"off", // 초기 상태: off (앱 미사용)
|
||||||
"COMPANY_13", // 기본 회사 코드
|
"COMPANY_13", // 기본 회사 코드
|
||||||
userId, // 사용자 ID 연결
|
userId, // 사용자 ID 연결
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
logger.info(`회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "회원가입이 완료되었습니다.",
|
message: "회원가입이 완료되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("공차중계 회원가입 오류:", error);
|
logger.error("회원가입 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import cron, { ScheduledTask } from "node-cron";
|
||||||
import { BatchService } from "./batchService";
|
import { BatchService } from "./batchService";
|
||||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { query } from "../database/db";
|
|
||||||
|
|
||||||
export class BatchSchedulerService {
|
export class BatchSchedulerService {
|
||||||
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
||||||
|
|
@ -65,18 +64,12 @@ export class BatchSchedulerService {
|
||||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const task = cron.schedule(
|
const task = cron.schedule(config.cron_schedule, async () => {
|
||||||
config.cron_schedule,
|
|
||||||
async () => {
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||||
);
|
);
|
||||||
await this.executeBatchConfig(config);
|
await this.executeBatchConfig(config);
|
||||||
},
|
});
|
||||||
{
|
|
||||||
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.scheduledTasks.set(config.id, task);
|
this.scheduledTasks.set(config.id, task);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -221,16 +214,9 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블별로 매핑을 그룹화
|
// 테이블별로 매핑을 그룹화
|
||||||
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
|
|
||||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||||
const fixedMappingsGlobal: typeof config.batch_mappings = [];
|
|
||||||
|
|
||||||
for (const mapping of config.batch_mappings) {
|
for (const mapping of config.batch_mappings) {
|
||||||
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
|
|
||||||
if (mapping.mapping_type === "fixed") {
|
|
||||||
fixedMappingsGlobal.push(mapping);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
||||||
if (!tableGroups.has(key)) {
|
if (!tableGroups.has(key)) {
|
||||||
tableGroups.set(key, []);
|
tableGroups.set(key, []);
|
||||||
|
|
@ -238,14 +224,6 @@ export class BatchSchedulerService {
|
||||||
tableGroups.get(key)!.push(mapping);
|
tableGroups.get(key)!.push(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
|
|
||||||
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
|
|
||||||
logger.warn(
|
|
||||||
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
|
|
||||||
);
|
|
||||||
return { totalRecords, successRecords, failedRecords };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 테이블 그룹별로 처리
|
// 각 테이블 그룹별로 처리
|
||||||
for (const [tableKey, mappings] of tableGroups) {
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -266,46 +244,10 @@ export class BatchSchedulerService {
|
||||||
"./batchExternalDbService"
|
"./batchExternalDbService"
|
||||||
);
|
);
|
||||||
|
|
||||||
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
|
|
||||||
let apiKey = firstMapping.from_api_key || "";
|
|
||||||
if (config.auth_service_name) {
|
|
||||||
let tokenQuery: string;
|
|
||||||
let tokenParams: any[];
|
|
||||||
|
|
||||||
if (config.company_code === "*") {
|
|
||||||
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
|
|
||||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
|
||||||
WHERE service_name = $1
|
|
||||||
ORDER BY created_date DESC LIMIT 1`;
|
|
||||||
tokenParams = [config.auth_service_name];
|
|
||||||
} else {
|
|
||||||
// 일반 회사 배치: 자신의 회사 토큰만 조회
|
|
||||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
|
||||||
WHERE service_name = $1 AND company_code = $2
|
|
||||||
ORDER BY created_date DESC LIMIT 1`;
|
|
||||||
tokenParams = [config.auth_service_name, config.company_code];
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResult = await query<{ access_token: string }>(
|
|
||||||
tokenQuery,
|
|
||||||
tokenParams
|
|
||||||
);
|
|
||||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
|
||||||
apiKey = tokenResult[0].access_token;
|
|
||||||
logger.info(
|
|
||||||
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||||
firstMapping.from_api_url!,
|
firstMapping.from_api_url!,
|
||||||
apiKey,
|
firstMapping.from_api_key!,
|
||||||
firstMapping.from_table_name,
|
firstMapping.from_table_name,
|
||||||
(firstMapping.from_api_method as
|
(firstMapping.from_api_method as
|
||||||
| "GET"
|
| "GET"
|
||||||
|
|
@ -324,36 +266,7 @@ export class BatchSchedulerService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiResult.success && apiResult.data) {
|
if (apiResult.success && apiResult.data) {
|
||||||
// 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출
|
|
||||||
if (config.data_array_path) {
|
|
||||||
const extractArrayByPath = (obj: any, path: string): any[] => {
|
|
||||||
if (!path) return Array.isArray(obj) ? obj : [obj];
|
|
||||||
const keys = path.split(".");
|
|
||||||
let current = obj;
|
|
||||||
for (const key of keys) {
|
|
||||||
if (current === null || current === undefined) return [];
|
|
||||||
current = current[key];
|
|
||||||
}
|
|
||||||
return Array.isArray(current)
|
|
||||||
? current
|
|
||||||
: current
|
|
||||||
? [current]
|
|
||||||
: [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
|
|
||||||
const rawData =
|
|
||||||
Array.isArray(apiResult.data) && apiResult.data.length === 1
|
|
||||||
? apiResult.data[0]
|
|
||||||
: apiResult.data;
|
|
||||||
|
|
||||||
fromData = extractArrayByPath(rawData, config.data_array_path);
|
|
||||||
logger.info(
|
|
||||||
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
fromData = apiResult.data;
|
fromData = apiResult.data;
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -385,11 +298,6 @@ export class BatchSchedulerService {
|
||||||
const mappedData = fromData.map((row) => {
|
const mappedData = fromData.map((row) => {
|
||||||
const mappedRow: any = {};
|
const mappedRow: any = {};
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
|
|
||||||
if (mapping.mapping_type === "fixed") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB → REST API 배치인지 확인
|
// DB → REST API 배치인지 확인
|
||||||
if (
|
if (
|
||||||
firstMapping.to_connection_type === "restapi" &&
|
firstMapping.to_connection_type === "restapi" &&
|
||||||
|
|
@ -407,13 +315,6 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용)
|
|
||||||
for (const fixedMapping of fixedMappingsGlobal) {
|
|
||||||
// from_column_name에 고정값이 저장되어 있음
|
|
||||||
mappedRow[fixedMapping.to_column_name] =
|
|
||||||
fixedMapping.from_column_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||||
// - 배치 설정에 company_code가 있고
|
// - 배치 설정에 company_code가 있고
|
||||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||||
|
|
@ -483,14 +384,12 @@ export class BatchSchedulerService {
|
||||||
insertResult = { successCount: 0, failedCount: 0 };
|
insertResult = { successCount: 0, failedCount: 0 };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
|
// DB에 데이터 삽입
|
||||||
insertResult = await BatchService.insertDataToTable(
|
insertResult = await BatchService.insertDataToTable(
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
mappedData,
|
mappedData,
|
||||||
firstMapping.to_connection_type as "internal" | "external",
|
firstMapping.to_connection_type as "internal" | "external",
|
||||||
firstMapping.to_connection_id || undefined,
|
firstMapping.to_connection_id || undefined
|
||||||
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
|
|
||||||
config.conflict_key || undefined
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,8 @@ export class BatchService {
|
||||||
// 배치 설정 생성
|
// 배치 설정 생성
|
||||||
const batchConfigResult = await client.query(
|
const batchConfigResult = await client.query(
|
||||||
`INSERT INTO batch_configs
|
`INSERT INTO batch_configs
|
||||||
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
|
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.batchName,
|
data.batchName,
|
||||||
|
|
@ -185,10 +185,6 @@ export class BatchService {
|
||||||
data.cronSchedule,
|
data.cronSchedule,
|
||||||
data.isActive || "Y",
|
data.isActive || "Y",
|
||||||
data.companyCode,
|
data.companyCode,
|
||||||
data.saveMode || "INSERT",
|
|
||||||
data.conflictKey || null,
|
|
||||||
data.authServiceName || null,
|
|
||||||
data.dataArrayPath || null,
|
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -205,8 +201,8 @@ export class BatchService {
|
||||||
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
||||||
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
||||||
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
||||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
|
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, $26, $27, NOW())
|
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, $26, NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
batchConfig.id,
|
batchConfig.id,
|
||||||
|
|
@ -234,7 +230,6 @@ export class BatchService {
|
||||||
mapping.to_api_method,
|
mapping.to_api_method,
|
||||||
mapping.to_api_body,
|
mapping.to_api_body,
|
||||||
mapping.mapping_order || index + 1,
|
mapping.mapping_order || index + 1,
|
||||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -316,22 +311,6 @@ export class BatchService {
|
||||||
updateFields.push(`is_active = $${paramIndex++}`);
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
updateValues.push(data.isActive);
|
updateValues.push(data.isActive);
|
||||||
}
|
}
|
||||||
if (data.saveMode !== undefined) {
|
|
||||||
updateFields.push(`save_mode = $${paramIndex++}`);
|
|
||||||
updateValues.push(data.saveMode);
|
|
||||||
}
|
|
||||||
if (data.conflictKey !== undefined) {
|
|
||||||
updateFields.push(`conflict_key = $${paramIndex++}`);
|
|
||||||
updateValues.push(data.conflictKey || null);
|
|
||||||
}
|
|
||||||
if (data.authServiceName !== undefined) {
|
|
||||||
updateFields.push(`auth_service_name = $${paramIndex++}`);
|
|
||||||
updateValues.push(data.authServiceName || null);
|
|
||||||
}
|
|
||||||
if (data.dataArrayPath !== undefined) {
|
|
||||||
updateFields.push(`data_array_path = $${paramIndex++}`);
|
|
||||||
updateValues.push(data.dataArrayPath || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배치 설정 업데이트
|
// 배치 설정 업데이트
|
||||||
const batchConfigResult = await client.query(
|
const batchConfigResult = await client.query(
|
||||||
|
|
@ -360,8 +339,8 @@ export class BatchService {
|
||||||
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
||||||
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
||||||
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
||||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date)
|
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, $26, $27, NOW())
|
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, $26, NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
|
|
@ -389,7 +368,6 @@ export class BatchService {
|
||||||
mapping.to_api_method,
|
mapping.to_api_method,
|
||||||
mapping.to_api_body,
|
mapping.to_api_body,
|
||||||
mapping.mapping_order || index + 1,
|
mapping.mapping_order || index + 1,
|
||||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
|
||||||
userId,
|
userId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -576,7 +554,9 @@ export class BatchService {
|
||||||
try {
|
try {
|
||||||
if (connectionType === "internal") {
|
if (connectionType === "internal") {
|
||||||
// 내부 DB 데이터 조회
|
// 내부 DB 데이터 조회
|
||||||
const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
|
const data = await query<any>(
|
||||||
|
`SELECT * FROM ${tableName} LIMIT 10`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
|
@ -749,27 +729,19 @@ export class BatchService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||||
* @param tableName 테이블명
|
|
||||||
* @param data 삽입할 데이터 배열
|
|
||||||
* @param connectionType 연결 타입 (internal/external)
|
|
||||||
* @param connectionId 외부 연결 ID
|
|
||||||
* @param saveMode 저장 모드 (INSERT/UPSERT)
|
|
||||||
* @param conflictKey UPSERT 시 충돌 기준 컬럼명
|
|
||||||
*/
|
*/
|
||||||
static async insertDataToTable(
|
static async insertDataToTable(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: any[],
|
data: any[],
|
||||||
connectionType: "internal" | "external" = "internal",
|
connectionType: "internal" | "external" = "internal",
|
||||||
connectionId?: number,
|
connectionId?: number
|
||||||
saveMode: "INSERT" | "UPSERT" = "INSERT",
|
|
||||||
conflictKey?: string
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
`[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
|
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
|
@ -781,54 +753,24 @@ export class BatchService {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
|
||||||
// 각 레코드를 개별적으로 삽입
|
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||||
for (const record of data) {
|
for (const record of data) {
|
||||||
try {
|
try {
|
||||||
const columns = Object.keys(record);
|
const columns = Object.keys(record);
|
||||||
const values = Object.values(record);
|
const values = Object.values(record);
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values
|
||||||
|
.map((_, i) => `$${i + 1}`)
|
||||||
let queryStr: string;
|
|
||||||
|
|
||||||
if (saveMode === "UPSERT" && conflictKey) {
|
|
||||||
// UPSERT 모드: ON CONFLICT DO UPDATE
|
|
||||||
// 충돌 키를 제외한 컬럼들만 UPDATE
|
|
||||||
const updateColumns = columns.filter(
|
|
||||||
(col) => col !== conflictKey
|
|
||||||
);
|
|
||||||
|
|
||||||
// 업데이트할 컬럼이 없으면 DO NOTHING 사용
|
|
||||||
if (updateColumns.length === 0) {
|
|
||||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
|
|
||||||
VALUES (${placeholders})
|
|
||||||
ON CONFLICT (${conflictKey})
|
|
||||||
DO NOTHING`;
|
|
||||||
} else {
|
|
||||||
const updateSet = updateColumns
|
|
||||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
// updated_date 컬럼이 있으면 현재 시간으로 업데이트
|
const queryStr = `INSERT INTO ${tableName} (${columns.join(
|
||||||
const hasUpdatedDate = columns.includes("updated_date");
|
", "
|
||||||
const finalUpdateSet = hasUpdatedDate
|
)}) VALUES (${placeholders})`;
|
||||||
? `${updateSet}, updated_date = NOW()`
|
|
||||||
: updateSet;
|
|
||||||
|
|
||||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
|
|
||||||
VALUES (${placeholders})
|
|
||||||
ON CONFLICT (${conflictKey})
|
|
||||||
DO UPDATE SET ${finalUpdateSet}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// INSERT 모드: 기존 방식
|
|
||||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(queryStr, values);
|
await query(queryStr, values);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (insertError) {
|
} catch (insertError) {
|
||||||
console.error(
|
console.error(
|
||||||
`내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
|
`내부 DB 데이터 삽입 실패 (${tableName}):`,
|
||||||
insertError
|
insertError
|
||||||
);
|
);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
|
|
@ -837,13 +779,7 @@ export class BatchService {
|
||||||
|
|
||||||
return { successCount, failedCount };
|
return { successCount, failedCount };
|
||||||
} else if (connectionType === "external" && connectionId) {
|
} else if (connectionType === "external" && connectionId) {
|
||||||
// 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
|
// 외부 DB에 데이터 삽입
|
||||||
if (saveMode === "UPSERT") {
|
|
||||||
console.warn(
|
|
||||||
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await BatchExternalDbService.insertDataToTable(
|
const result = await BatchExternalDbService.insertDataToTable(
|
||||||
connectionId,
|
connectionId,
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -863,7 +799,7 @@ export class BatchService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
|
console.error(`데이터 삽입 오류 (${tableName}):`, error);
|
||||||
return { successCount: 0, failedCount: data ? data.length : 0 };
|
return { successCount: 0, failedCount: data ? data.length : 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ export interface CodeInfo {
|
||||||
is_active: string;
|
is_active: string;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
||||||
parent_code_value?: string | null; // 계층구조: 부모 코드값
|
|
||||||
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
|
||||||
created_date?: Date | null;
|
created_date?: Date | null;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
updated_date?: Date | null;
|
updated_date?: Date | null;
|
||||||
|
|
@ -63,8 +61,6 @@ export interface CreateCodeData {
|
||||||
description?: string;
|
description?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
isActive?: string;
|
isActive?: string;
|
||||||
parentCodeValue?: string; // 계층구조: 부모 코드값
|
|
||||||
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CommonCodeService {
|
export class CommonCodeService {
|
||||||
|
|
@ -90,12 +86,11 @@ export class CommonCodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
// company_code = '*'인 공통 데이터도 함께 조회
|
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
values.push(userCompanyCode);
|
values.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
// 최고 관리자는 모든 데이터 조회 가능
|
// 최고 관리자는 모든 데이터 조회 가능
|
||||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||||
|
|
@ -121,7 +116,7 @@ export class CommonCodeService {
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// code_category 테이블에서만 조회 (comm_code 제거)
|
// 카테고리 조회
|
||||||
const categories = await query<CodeCategory>(
|
const categories = await query<CodeCategory>(
|
||||||
`SELECT * FROM code_category
|
`SELECT * FROM code_category
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -139,7 +134,7 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -229,7 +224,7 @@ export class CommonCodeService {
|
||||||
paramIndex,
|
paramIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
// 코드 조회
|
||||||
const codes = await query<CodeInfo>(
|
const codes = await query<CodeInfo>(
|
||||||
`SELECT * FROM code_info
|
`SELECT * FROM code_info
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -247,9 +242,20 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
|
||||||
|
categoryCode,
|
||||||
|
menuObjid,
|
||||||
|
codes: codes.map((c) => ({
|
||||||
|
code_value: c.code_value,
|
||||||
|
code_name: c.code_name,
|
||||||
|
menu_objid: c.menu_objid,
|
||||||
|
company_code: c.company_code,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
return { data: codes, total };
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
|
@ -409,22 +415,11 @@ export class CommonCodeService {
|
||||||
menuObjid: number
|
menuObjid: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 계층구조: depth 계산 (부모가 있으면 부모의 depth + 1, 없으면 1)
|
|
||||||
let depth = 1;
|
|
||||||
if (data.parentCodeValue) {
|
|
||||||
const parentCode = await queryOne<CodeInfo>(
|
|
||||||
`SELECT depth FROM code_info
|
|
||||||
WHERE code_category = $1 AND code_value = $2 AND company_code = $3`,
|
|
||||||
[categoryCode, data.parentCodeValue, companyCode]
|
|
||||||
);
|
|
||||||
depth = parentCode ? (parentCode.depth || 1) + 1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = await queryOne<CodeInfo>(
|
const code = await queryOne<CodeInfo>(
|
||||||
`INSERT INTO code_info
|
`INSERT INTO code_info
|
||||||
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
||||||
is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date)
|
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW())
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
categoryCode,
|
categoryCode,
|
||||||
|
|
@ -435,15 +430,13 @@ export class CommonCodeService {
|
||||||
data.sortOrder || 0,
|
data.sortOrder || 0,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
companyCode,
|
companyCode,
|
||||||
data.parentCodeValue || null,
|
|
||||||
depth,
|
|
||||||
createdBy,
|
createdBy,
|
||||||
createdBy,
|
createdBy,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})`
|
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})`
|
||||||
);
|
);
|
||||||
return code;
|
return code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -508,24 +501,6 @@ export class CommonCodeService {
|
||||||
updateFields.push(`is_active = $${paramIndex++}`);
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
values.push(activeValue);
|
values.push(activeValue);
|
||||||
}
|
}
|
||||||
// 계층구조: 부모 코드값 수정
|
|
||||||
if (data.parentCodeValue !== undefined) {
|
|
||||||
updateFields.push(`parent_code_value = $${paramIndex++}`);
|
|
||||||
values.push(data.parentCodeValue || null);
|
|
||||||
|
|
||||||
// depth도 함께 업데이트
|
|
||||||
let newDepth = 1;
|
|
||||||
if (data.parentCodeValue) {
|
|
||||||
const parentCode = await queryOne<CodeInfo>(
|
|
||||||
`SELECT depth FROM code_info
|
|
||||||
WHERE code_category = $1 AND code_value = $2`,
|
|
||||||
[categoryCode, data.parentCodeValue]
|
|
||||||
);
|
|
||||||
newDepth = parentCode ? (parentCode.depth || 1) + 1 : 1;
|
|
||||||
}
|
|
||||||
updateFields.push(`depth = $${paramIndex++}`);
|
|
||||||
values.push(newDepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
||||||
|
|
@ -882,170 +857,4 @@ export class CommonCodeService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 계층구조 코드 조회 (특정 depth 또는 부모코드 기준)
|
|
||||||
* @param categoryCode 카테고리 코드
|
|
||||||
* @param parentCodeValue 부모 코드값 (없으면 최상위 코드만 조회)
|
|
||||||
* @param depth 특정 깊이만 조회 (선택)
|
|
||||||
*/
|
|
||||||
async getHierarchicalCodes(
|
|
||||||
categoryCode: string,
|
|
||||||
parentCodeValue?: string | null,
|
|
||||||
depth?: number,
|
|
||||||
userCompanyCode?: string,
|
|
||||||
menuObjid?: number
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
|
|
||||||
const values: any[] = [categoryCode];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
// 부모 코드값 필터링
|
|
||||||
if (parentCodeValue === null || parentCodeValue === undefined) {
|
|
||||||
// 최상위 코드 (부모가 없는 코드)
|
|
||||||
whereConditions.push("(parent_code_value IS NULL OR parent_code_value = '')");
|
|
||||||
} else if (parentCodeValue !== '') {
|
|
||||||
whereConditions.push(`parent_code_value = $${paramIndex}`);
|
|
||||||
values.push(parentCodeValue);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 특정 깊이 필터링
|
|
||||||
if (depth !== undefined) {
|
|
||||||
whereConditions.push(`depth = $${paramIndex}`);
|
|
||||||
values.push(depth);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메뉴별 필터링 (형제 메뉴 포함)
|
|
||||||
if (menuObjid) {
|
|
||||||
const { getSiblingMenuObjids } = await import('./menuService');
|
|
||||||
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
|
||||||
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
|
||||||
values.push(siblingMenuObjids);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사별 필터링
|
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
|
||||||
values.push(userCompanyCode);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
|
||||||
|
|
||||||
const codes = await query<CodeInfo>(
|
|
||||||
`SELECT * FROM code_info
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY sort_order ASC, code_value ASC`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`계층구조 코드 조회: ${categoryCode}, 부모: ${parentCodeValue || '최상위'}, 깊이: ${depth || '전체'} - ${codes.length}개`
|
|
||||||
);
|
|
||||||
|
|
||||||
return codes;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`계층구조 코드 조회 중 오류 (${categoryCode}):`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 계층구조 코드 트리 전체 조회 (카테고리 기준)
|
|
||||||
*/
|
|
||||||
async getCodeTree(
|
|
||||||
categoryCode: string,
|
|
||||||
userCompanyCode?: string,
|
|
||||||
menuObjid?: number
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"];
|
|
||||||
const values: any[] = [categoryCode];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
// 메뉴별 필터링 (형제 메뉴 포함)
|
|
||||||
if (menuObjid) {
|
|
||||||
const { getSiblingMenuObjids } = await import('./menuService');
|
|
||||||
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
|
|
||||||
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
|
|
||||||
values.push(siblingMenuObjids);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사별 필터링
|
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
|
||||||
values.push(userCompanyCode);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
|
||||||
|
|
||||||
const allCodes = await query<CodeInfo>(
|
|
||||||
`SELECT * FROM code_info
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY depth ASC, sort_order ASC, code_value ASC`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
// 트리 구조로 변환
|
|
||||||
const buildTree = (codes: CodeInfo[], parentValue: string | null = null): any[] => {
|
|
||||||
return codes
|
|
||||||
.filter(code => {
|
|
||||||
const codeParent = code.parent_code_value || null;
|
|
||||||
return codeParent === parentValue;
|
|
||||||
})
|
|
||||||
.map(code => ({
|
|
||||||
...code,
|
|
||||||
children: buildTree(codes, code.code_value)
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tree = buildTree(allCodes);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`코드 트리 조회 완료: ${categoryCode} - 전체 ${allCodes.length}개`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
flat: allCodes,
|
|
||||||
tree
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`코드 트리 조회 중 오류 (${categoryCode}):`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자식 코드가 있는지 확인
|
|
||||||
*/
|
|
||||||
async hasChildren(
|
|
||||||
categoryCode: string,
|
|
||||||
codeValue: string,
|
|
||||||
companyCode?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
let sql = `SELECT COUNT(*) as count FROM code_info
|
|
||||||
WHERE code_category = $1 AND parent_code_value = $2`;
|
|
||||||
const values: any[] = [categoryCode, codeValue];
|
|
||||||
|
|
||||||
if (companyCode && companyCode !== "*") {
|
|
||||||
sql += ` AND company_code = $3`;
|
|
||||||
values.push(companyCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await queryOne<{ count: string }>(sql, values);
|
|
||||||
const count = parseInt(result?.count || "0");
|
|
||||||
|
|
||||||
return count > 0;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`자식 코드 확인 중 오류 (${categoryCode}.${codeValue}):`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ class DataService {
|
||||||
|
|
||||||
case "base_price":
|
case "base_price":
|
||||||
// base_price = true인 행 찾기
|
// base_price = true인 행 찾기
|
||||||
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
|
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "current_date":
|
case "current_date":
|
||||||
|
|
@ -128,11 +128,8 @@ class DataService {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||||
|
|
||||||
selectedRow =
|
selectedRow = rows.find(row => {
|
||||||
rows.find((row) => {
|
const startDate = row.start_date ? new Date(row.start_date) : null;
|
||||||
const startDate = row.start_date
|
|
||||||
? new Date(row.start_date)
|
|
||||||
: null;
|
|
||||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||||
|
|
||||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -233,17 +230,12 @@ class DataService {
|
||||||
|
|
||||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(
|
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||||
tableName,
|
|
||||||
"company_code"
|
|
||||||
);
|
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
queryParams.push(userCompany);
|
queryParams.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(
|
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
||||||
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,10 +246,7 @@ class DataService {
|
||||||
key !== "limit" &&
|
key !== "limit" &&
|
||||||
key !== "offset" &&
|
key !== "offset" &&
|
||||||
key !== "orderBy" &&
|
key !== "orderBy" &&
|
||||||
key !== "userLang" &&
|
key !== "userLang"
|
||||||
key !== "page" &&
|
|
||||||
key !== "pageSize" &&
|
|
||||||
key !== "size"
|
|
||||||
) {
|
) {
|
||||||
// 컬럼명 검증 (SQL 인젝션 방지)
|
// 컬럼명 검증 (SQL 인젝션 방지)
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||||
|
|
@ -519,8 +508,7 @@ class DataService {
|
||||||
const entityJoinService = new EntityJoinService();
|
const entityJoinService = new EntityJoinService();
|
||||||
|
|
||||||
// Entity Join 구성 감지
|
// Entity Join 구성 감지
|
||||||
const joinConfigs =
|
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||||
await entityJoinService.detectEntityJoins(tableName);
|
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||||
|
|
@ -545,14 +533,14 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map((row) => {
|
return rows.map(row => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(value.getDate()).padStart(2, "0");
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -563,10 +551,7 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(
|
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
||||||
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
|
|
||||||
normalizedRows[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||||
if (groupByColumns.length > 0) {
|
if (groupByColumns.length > 0) {
|
||||||
|
|
@ -589,10 +574,7 @@ class DataService {
|
||||||
if (groupConditions.length > 0) {
|
if (groupConditions.length > 0) {
|
||||||
const groupWhereClause = groupConditions.join(" AND ");
|
const groupWhereClause = groupConditions.join(" AND ");
|
||||||
|
|
||||||
console.log(
|
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
||||||
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
|
|
||||||
groupValues
|
|
||||||
);
|
|
||||||
|
|
||||||
// 그룹핑 기준으로 모든 레코드 조회
|
// 그룹핑 기준으로 모든 레코드 조회
|
||||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||||
|
|
@ -605,9 +587,7 @@ class DataService {
|
||||||
const groupResult = await pool.query(groupQuery, groupValues);
|
const groupResult = await pool.query(groupQuery, groupValues);
|
||||||
|
|
||||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||||
console.log(
|
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
||||||
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -662,8 +642,7 @@ class DataService {
|
||||||
dataFilter?: any, // 🆕 데이터 필터
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||||
deduplication?: {
|
deduplication?: { // 🆕 중복 제거 설정
|
||||||
// 🆕 중복 제거 설정
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupByColumn: string;
|
groupByColumn: string;
|
||||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
|
@ -687,8 +666,7 @@ class DataService {
|
||||||
if (enableEntityJoin) {
|
if (enableEntityJoin) {
|
||||||
try {
|
try {
|
||||||
const { entityJoinService } = await import("./entityJoinService");
|
const { entityJoinService } = await import("./entityJoinService");
|
||||||
const joinConfigs =
|
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
||||||
await entityJoinService.detectEntityJoins(rightTable);
|
|
||||||
|
|
||||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||||
if (displayColumns && Array.isArray(displayColumns)) {
|
if (displayColumns && Array.isArray(displayColumns)) {
|
||||||
|
|
@ -696,8 +674,8 @@ class DataService {
|
||||||
const tableColumns: Record<string, Set<string>> = {};
|
const tableColumns: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
for (const col of displayColumns) {
|
for (const col of displayColumns) {
|
||||||
if (col.name && col.name.includes(".")) {
|
if (col.name && col.name.includes('.')) {
|
||||||
const [refTable, refColumn] = col.name.split(".");
|
const [refTable, refColumn] = col.name.split('.');
|
||||||
if (!tableColumns[refTable]) {
|
if (!tableColumns[refTable]) {
|
||||||
tableColumns[refTable] = new Set();
|
tableColumns[refTable] = new Set();
|
||||||
}
|
}
|
||||||
|
|
@ -708,18 +686,14 @@ class DataService {
|
||||||
// 각 테이블별로 처리
|
// 각 테이블별로 처리
|
||||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||||
// 이미 조인 설정에 있는지 확인
|
// 이미 조인 설정에 있는지 확인
|
||||||
const existingJoins = joinConfigs.filter(
|
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
||||||
(jc) => jc.referenceTable === refTable
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingJoins.length > 0) {
|
if (existingJoins.length > 0) {
|
||||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||||
for (const refColumn of refColumns) {
|
for (const refColumn of refColumns) {
|
||||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||||
const existingJoin = existingJoins.find(
|
const existingJoin = existingJoins.find(
|
||||||
(jc) =>
|
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
||||||
jc.displayColumns.length === 1 &&
|
|
||||||
jc.displayColumns[0] === refColumn
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!existingJoin) {
|
if (!existingJoin) {
|
||||||
|
|
@ -734,9 +708,7 @@ class DataService {
|
||||||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||||
};
|
};
|
||||||
joinConfigs.push(newJoin);
|
joinConfigs.push(newJoin);
|
||||||
console.log(
|
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
||||||
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -746,9 +718,7 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(
|
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
||||||
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
|
|
||||||
);
|
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
|
|
@ -765,10 +735,7 @@ class DataService {
|
||||||
|
|
||||||
// 회사별 필터링
|
// 회사별 필터링
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(
|
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||||
rightTable,
|
|
||||||
"company_code"
|
|
||||||
);
|
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
|
|
@ -777,41 +744,25 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||||
if (
|
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||||
dataFilter &&
|
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
||||||
dataFilter.enabled &&
|
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
||||||
dataFilter.filters &&
|
|
||||||
dataFilter.filters.length > 0
|
|
||||||
) {
|
|
||||||
const { buildDataFilterWhereClause } = await import(
|
|
||||||
"../utils/dataFilterUtil"
|
|
||||||
);
|
|
||||||
const filterResult = buildDataFilterWhereClause(
|
|
||||||
dataFilter,
|
|
||||||
"main",
|
|
||||||
paramIndex
|
|
||||||
);
|
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(
|
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||||
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
|
|
||||||
filterResult.whereClause
|
|
||||||
);
|
|
||||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause =
|
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||||
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
|
||||||
|
|
||||||
// Entity 조인 쿼리 빌드
|
// Entity 조인 쿼리 빌드
|
||||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||||
const selectColumns = ["*"];
|
const selectColumns = ["*"];
|
||||||
|
|
||||||
const { query: finalQuery, aliasMap } =
|
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
||||||
entityJoinService.buildJoinQuery(
|
|
||||||
rightTable,
|
rightTable,
|
||||||
joinConfigs,
|
joinConfigs,
|
||||||
selectColumns,
|
selectColumns,
|
||||||
|
|
@ -828,13 +779,13 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결
|
// 🔧 날짜 타입 타임존 문제 해결
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map((row) => {
|
return rows.map(row => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(value.getDate()).padStart(2, "0");
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -845,20 +796,14 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(
|
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
||||||
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = normalizedRows;
|
let finalData = normalizedRows;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(
|
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||||
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
|
||||||
);
|
|
||||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||||
console.log(
|
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
||||||
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -893,40 +838,23 @@ class DataService {
|
||||||
|
|
||||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(
|
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||||
rightTable,
|
|
||||||
"company_code"
|
|
||||||
);
|
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(
|
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
||||||
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
if (
|
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||||
dataFilter &&
|
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
||||||
dataFilter.enabled &&
|
|
||||||
dataFilter.filters &&
|
|
||||||
dataFilter.filters.length > 0
|
|
||||||
) {
|
|
||||||
const filterResult = buildDataFilterWhereClause(
|
|
||||||
dataFilter,
|
|
||||||
"r",
|
|
||||||
paramIndex
|
|
||||||
);
|
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(
|
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||||
`🔍 데이터 필터 적용 (${rightTable}):`,
|
|
||||||
filterResult.whereClause
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -943,13 +871,9 @@ class DataService {
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = result;
|
let finalData = result;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(
|
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||||
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
|
||||||
);
|
|
||||||
finalData = this.deduplicateData(result, deduplication);
|
finalData = this.deduplicateData(result, deduplication);
|
||||||
console.log(
|
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
||||||
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -983,31 +907,8 @@ class DataService {
|
||||||
return validation.error!;
|
return validation.error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
const columns = Object.keys(data);
|
||||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
const values = Object.values(data);
|
||||||
const validColumnNames = new Set(
|
|
||||||
tableColumns.map((col: any) => col.column_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidColumns: string[] = [];
|
|
||||||
const filteredData = Object.fromEntries(
|
|
||||||
Object.entries(data).filter(([key]) => {
|
|
||||||
if (validColumnNames.has(key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
invalidColumns.push(key);
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (invalidColumns.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = Object.keys(filteredData);
|
|
||||||
const values = Object.values(filteredData);
|
|
||||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||||
|
|
||||||
|
|
@ -1050,32 +951,9 @@ class DataService {
|
||||||
|
|
||||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||||
const relationInfo = data._relationInfo;
|
const relationInfo = data._relationInfo;
|
||||||
let cleanData = { ...data };
|
const cleanData = { ...data };
|
||||||
delete cleanData._relationInfo;
|
delete cleanData._relationInfo;
|
||||||
|
|
||||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
|
||||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
|
||||||
const validColumnNames = new Set(
|
|
||||||
tableColumns.map((col: any) => col.column_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidColumns: string[] = [];
|
|
||||||
cleanData = Object.fromEntries(
|
|
||||||
Object.entries(cleanData).filter(([key]) => {
|
|
||||||
if (validColumnNames.has(key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
invalidColumns.push(key);
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (invalidColumns.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary Key 컬럼 찾기
|
// Primary Key 컬럼 찾기
|
||||||
const pkResult = await query<{ attname: string }>(
|
const pkResult = await query<{ attname: string }>(
|
||||||
`SELECT a.attname
|
`SELECT a.attname
|
||||||
|
|
@ -1115,14 +993,8 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||||
if (
|
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
||||||
relationInfo &&
|
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
||||||
relationInfo.rightTable &&
|
|
||||||
relationInfo.leftColumn &&
|
|
||||||
relationInfo.rightColumn
|
|
||||||
) {
|
|
||||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
|
|
||||||
relationInfo;
|
|
||||||
const newLeftValue = cleanData[leftColumn];
|
const newLeftValue = cleanData[leftColumn];
|
||||||
|
|
||||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||||
|
|
@ -1140,13 +1012,8 @@ class DataService {
|
||||||
SET "${rightColumn}" = $1
|
SET "${rightColumn}" = $1
|
||||||
WHERE "${rightColumn}" = $2
|
WHERE "${rightColumn}" = $2
|
||||||
`;
|
`;
|
||||||
const updateResult = await query(updateRelatedQuery, [
|
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
||||||
newLeftValue,
|
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
||||||
oldLeftValue,
|
|
||||||
]);
|
|
||||||
console.log(
|
|
||||||
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
|
|
||||||
);
|
|
||||||
} catch (relError) {
|
} catch (relError) {
|
||||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||||
|
|
@ -1192,23 +1059,14 @@ class DataService {
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
|
||||||
pkColumns: pkResult.map((r) => r.attname),
|
|
||||||
pkCount: pkResult.length,
|
|
||||||
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
|
||||||
inputIdType: typeof id,
|
|
||||||
});
|
|
||||||
|
|
||||||
let whereClauses: string[] = [];
|
let whereClauses: string[] = [];
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
|
|
||||||
if (pkResult.length > 1) {
|
if (pkResult.length > 1) {
|
||||||
// 복합키인 경우: id가 객체여야 함
|
// 복합키인 경우: id가 객체여야 함
|
||||||
console.log(
|
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
|
||||||
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof id === "object" && !Array.isArray(id)) {
|
if (typeof id === 'object' && !Array.isArray(id)) {
|
||||||
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
||||||
pkResult.forEach((pk, index) => {
|
pkResult.forEach((pk, index) => {
|
||||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||||
|
|
@ -1223,34 +1081,18 @@ class DataService {
|
||||||
// 단일키인 경우
|
// 단일키인 경우
|
||||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||||
whereClauses.push(`"${pkColumn}" = $1`);
|
whereClauses.push(`"${pkColumn}" = $1`);
|
||||||
params.push(typeof id === "object" ? id[pkColumn] : id);
|
params.push(typeof id === 'object' ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
// 삭제된 행이 없으면 실패 처리
|
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
||||||
if (result.length === 0) {
|
|
||||||
console.warn(
|
|
||||||
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
|
||||||
{ whereClauses, params }
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
|
||||||
error: "RECORD_NOT_FOUND",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result[0], // 삭제된 레코드 정보 반환
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||||
|
|
@ -1264,14 +1106,10 @@ class DataService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||||
* @param tableName 테이블명
|
|
||||||
* @param filterConditions 삭제 조건
|
|
||||||
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
|
||||||
*/
|
*/
|
||||||
async deleteGroupRecords(
|
async deleteGroupRecords(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
filterConditions: Record<string, any>,
|
filterConditions: Record<string, any>
|
||||||
userCompany?: string
|
|
||||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||||
try {
|
try {
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1283,7 +1121,6 @@ class DataService {
|
||||||
const whereValues: any[] = [];
|
const whereValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 사용자 필터 조건 추가
|
|
||||||
for (const [key, value] of Object.entries(filterConditions)) {
|
for (const [key, value] of Object.entries(filterConditions)) {
|
||||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||||
whereValues.push(value);
|
whereValues.push(value);
|
||||||
|
|
@ -1291,31 +1128,13 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whereConditions.length === 0) {
|
if (whereConditions.length === 0) {
|
||||||
return {
|
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
||||||
success: false,
|
|
||||||
message: "삭제 조건이 없습니다.",
|
|
||||||
error: "NO_CONDITIONS",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
|
|
||||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
|
||||||
if (hasCompanyCode && userCompany && userCompany !== "*") {
|
|
||||||
whereConditions.push(`"company_code" = $${paramIndex}`);
|
|
||||||
whereValues.push(userCompany);
|
|
||||||
paramIndex++;
|
|
||||||
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, {
|
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
||||||
tableName,
|
|
||||||
conditions: filterConditions,
|
|
||||||
userCompany,
|
|
||||||
whereClause,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await pool.query(deleteQuery, whereValues);
|
const result = await pool.query(deleteQuery, whereValues);
|
||||||
|
|
||||||
|
|
@ -1344,9 +1163,7 @@ class DataService {
|
||||||
records: Array<Record<string, any>>,
|
records: Array<Record<string, any>>,
|
||||||
userCompany?: string,
|
userCompany?: string,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<
|
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||||
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
|
||||||
> {
|
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 권한 검증
|
// 테이블 접근 권한 검증
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1385,10 +1202,7 @@ class DataService {
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||||
|
|
||||||
console.log(`📋 기존 레코드 조회:`, {
|
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
||||||
query: selectQuery,
|
|
||||||
values: whereValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||||
|
|
||||||
|
|
@ -1404,8 +1218,8 @@ class DataService {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
|
|
||||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||||
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -1442,11 +1256,8 @@ class DataService {
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
// Date 타입 처리
|
// Date 타입 처리
|
||||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||||
return (
|
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||||
existingValue.toISOString().split("T")[0] ===
|
|
||||||
newValue.split("T")[0]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 비교
|
// 문자열 비교
|
||||||
|
|
@ -1461,8 +1272,7 @@ class DataService {
|
||||||
let updateParamIndex = 1;
|
let updateParamIndex = 1;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fullRecord)) {
|
for (const [key, value] of Object.entries(fullRecord)) {
|
||||||
if (key !== pkColumn) {
|
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
||||||
// Primary Key는 업데이트하지 않음
|
|
||||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||||
updateValues.push(value);
|
updateValues.push(value);
|
||||||
updateParamIndex++;
|
updateParamIndex++;
|
||||||
|
|
@ -1484,21 +1294,15 @@ class DataService {
|
||||||
// INSERT: 기존 레코드가 없으면 삽입
|
// INSERT: 기존 레코드가 없으면 삽입
|
||||||
|
|
||||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||||
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
|
||||||
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
|
||||||
const recordWithMeta: Record<string, any> = {
|
const recordWithMeta: Record<string, any> = {
|
||||||
...recordWithoutCreatedDate,
|
...fullRecord,
|
||||||
id: uuidv4(), // 새 ID 생성
|
id: uuidv4(), // 새 ID 생성
|
||||||
created_date: "NOW()",
|
created_date: "NOW()",
|
||||||
updated_date: "NOW()",
|
updated_date: "NOW()",
|
||||||
};
|
};
|
||||||
|
|
||||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||||
if (
|
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||||
!recordWithMeta.company_code &&
|
|
||||||
userCompany &&
|
|
||||||
userCompany !== "*"
|
|
||||||
) {
|
|
||||||
recordWithMeta.company_code = userCompany;
|
recordWithMeta.company_code = userCompany;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1507,8 +1311,8 @@ class DataService {
|
||||||
recordWithMeta.writer = userId;
|
recordWithMeta.writer = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertFields = Object.keys(recordWithMeta).filter(
|
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
||||||
(key) => recordWithMeta[key] !== "NOW()"
|
recordWithMeta[key] !== "NOW()"
|
||||||
);
|
);
|
||||||
const insertPlaceholders: string[] = [];
|
const insertPlaceholders: string[] = [];
|
||||||
const insertValues: any[] = [];
|
const insertValues: any[] = [];
|
||||||
|
|
@ -1525,16 +1329,11 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
|
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
||||||
.map((f) => `"${f}"`)
|
|
||||||
.join(", ")})
|
|
||||||
VALUES (${insertPlaceholders.join(", ")})
|
VALUES (${insertPlaceholders.join(", ")})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`➕ INSERT 쿼리:`, {
|
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
||||||
query: insertQuery,
|
|
||||||
values: insertValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
await pool.query(insertQuery, insertValues);
|
await pool.query(insertQuery, insertValues);
|
||||||
inserted++;
|
inserted++;
|
||||||
|
|
@ -1555,11 +1354,8 @@ class DataService {
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||||
return (
|
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||||
existingValue.toISOString().split("T")[0] ===
|
|
||||||
newValue.split("T")[0]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
import tableCategoryValueService from "./tableCategoryValueService";
|
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -104,16 +103,12 @@ export class DynamicFormService {
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
// DATE 타입이면 문자열 그대로 유지
|
// DATE 타입이면 문자열 그대로 유지
|
||||||
if (lowerDataType === "date") {
|
if (lowerDataType === "date") {
|
||||||
console.log(
|
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
|
||||||
`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`
|
|
||||||
);
|
|
||||||
return value; // 문자열 그대로 반환
|
return value; // 문자열 그대로 반환
|
||||||
}
|
}
|
||||||
// TIMESTAMP 타입이면 Date 객체로 변환
|
// TIMESTAMP 타입이면 Date 객체로 변환
|
||||||
else {
|
else {
|
||||||
console.log(
|
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
|
||||||
`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`
|
|
||||||
);
|
|
||||||
return new Date(value + "T00:00:00");
|
return new Date(value + "T00:00:00");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,8 +250,7 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
||||||
dataToInsert.regdate = new Date();
|
dataToInsert.regdate = new Date();
|
||||||
}
|
}
|
||||||
// created_date는 항상 현재 시간으로 설정 (기존 값 무시)
|
if (tableColumns.includes("created_date") && !dataToInsert.created_date) {
|
||||||
if (tableColumns.includes("created_date")) {
|
|
||||||
dataToInsert.created_date = new Date();
|
dataToInsert.created_date = new Date();
|
||||||
}
|
}
|
||||||
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
||||||
|
|
@ -319,9 +313,7 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
||||||
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
console.log(
|
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
|
||||||
`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`
|
|
||||||
);
|
|
||||||
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -363,11 +355,7 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파싱된 배열이 있으면 처리
|
// 파싱된 배열이 있으면 처리
|
||||||
if (
|
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||||
parsedArray &&
|
|
||||||
Array.isArray(parsedArray) &&
|
|
||||||
parsedArray.length > 0
|
|
||||||
) {
|
|
||||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||||
let targetTable: string | undefined;
|
let targetTable: string | undefined;
|
||||||
|
|
@ -376,7 +364,9 @@ export class DynamicFormService {
|
||||||
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
||||||
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
||||||
targetTable = parsedArray[0]._targetTable;
|
targetTable = parsedArray[0]._targetTable;
|
||||||
actualData = parsedArray.map(({ _targetTable, ...item }) => item);
|
actualData = parsedArray.map(
|
||||||
|
({ _targetTable, ...item }) => item
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
repeaterData.push({
|
repeaterData.push({
|
||||||
|
|
@ -398,7 +388,7 @@ export class DynamicFormService {
|
||||||
const separateRepeaterData: typeof repeaterData = [];
|
const separateRepeaterData: typeof repeaterData = [];
|
||||||
const mergedRepeaterData: typeof repeaterData = [];
|
const mergedRepeaterData: typeof repeaterData = [];
|
||||||
|
|
||||||
repeaterData.forEach((repeater) => {
|
repeaterData.forEach(repeater => {
|
||||||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||||
// 다른 테이블: 나중에 별도 저장
|
// 다른 테이블: 나중에 별도 저장
|
||||||
separateRepeaterData.push(repeater);
|
separateRepeaterData.push(repeater);
|
||||||
|
|
@ -428,24 +418,6 @@ export class DynamicFormService {
|
||||||
dataToInsert,
|
dataToInsert,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
|
|
||||||
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
|
|
||||||
const companyCodeForCategory = company_code || "*";
|
|
||||||
const { convertedData: categoryConvertedData, conversions } =
|
|
||||||
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
|
|
||||||
tableName,
|
|
||||||
companyCodeForCategory,
|
|
||||||
dataToInsert
|
|
||||||
);
|
|
||||||
|
|
||||||
if (conversions.length > 0) {
|
|
||||||
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
|
|
||||||
// 변환된 데이터로 교체
|
|
||||||
Object.assign(dataToInsert, categoryConvertedData);
|
|
||||||
} else {
|
|
||||||
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||||||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||||||
const columnInfo = await this.getTableColumnInfo(tableName);
|
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||||
|
|
@ -525,39 +497,14 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||||
if (mergedRepeaterData.length > 0) {
|
if (mergedRepeaterData.length > 0) {
|
||||||
console.log(
|
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
||||||
`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`
|
|
||||||
);
|
|
||||||
|
|
||||||
result = [];
|
result = [];
|
||||||
|
|
||||||
for (const repeater of mergedRepeaterData) {
|
for (const repeater of mergedRepeaterData) {
|
||||||
for (const item of repeater.data) {
|
for (const item of repeater.data) {
|
||||||
// 헤더 + 품목을 병합
|
// 헤더 + 품목을 병합
|
||||||
// item에서 created_date 제거 (dataToInsert의 현재 시간 유지)
|
const rawMergedData = { ...dataToInsert, ...item };
|
||||||
const { created_date: _, ...itemWithoutCreatedDate } = item;
|
|
||||||
const rawMergedData = {
|
|
||||||
...dataToInsert,
|
|
||||||
...itemWithoutCreatedDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
|
||||||
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
|
||||||
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
|
|
||||||
const isExistingRecord = rawMergedData._existingRecord === true;
|
|
||||||
|
|
||||||
if (!isExistingRecord) {
|
|
||||||
// 새 레코드: id 제거하여 새 UUID 자동 생성
|
|
||||||
const oldId = rawMergedData.id;
|
|
||||||
delete rawMergedData.id;
|
|
||||||
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
|
|
||||||
} else {
|
|
||||||
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메타 플래그 제거
|
|
||||||
delete rawMergedData._isNewItem;
|
|
||||||
delete rawMergedData._existingRecord;
|
|
||||||
|
|
||||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||||
|
|
@ -566,9 +513,7 @@ export class DynamicFormService {
|
||||||
Object.keys(rawMergedData).forEach((columnName) => {
|
Object.keys(rawMergedData).forEach((columnName) => {
|
||||||
// 실제 테이블 컬럼인지 확인
|
// 실제 테이블 컬럼인지 확인
|
||||||
if (validColumnNames.includes(columnName)) {
|
if (validColumnNames.includes(columnName)) {
|
||||||
const column = columnInfo.find(
|
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||||
(col) => col.column_name === columnName
|
|
||||||
);
|
|
||||||
if (column) {
|
if (column) {
|
||||||
// 타입 변환
|
// 타입 변환
|
||||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||||
|
|
@ -579,17 +524,13 @@ export class DynamicFormService {
|
||||||
mergedData[columnName] = rawMergedData[columnName];
|
mergedData[columnName] = rawMergedData[columnName];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
|
||||||
`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedColumns = Object.keys(mergedData);
|
const mergedColumns = Object.keys(mergedData);
|
||||||
const mergedValues: any[] = Object.values(mergedData);
|
const mergedValues: any[] = Object.values(mergedData);
|
||||||
const mergedPlaceholders = mergedValues
|
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
.map((_, index) => `$${index + 1}`)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
let mergedUpsertQuery: string;
|
let mergedUpsertQuery: string;
|
||||||
if (primaryKeys.length > 0) {
|
if (primaryKeys.length > 0) {
|
||||||
|
|
@ -773,19 +714,12 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (새로 추가)
|
// 🎯 제어관리 실행 (새로 추가)
|
||||||
try {
|
try {
|
||||||
// savedData 또는 insertedRecord에서 company_code 추출
|
|
||||||
const recordCompanyCode =
|
|
||||||
(insertedRecord as Record<string, any>)?.company_code ||
|
|
||||||
dataToInsert.company_code ||
|
|
||||||
"*";
|
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
insertedRecord as Record<string, any>,
|
insertedRecord as Record<string, any>,
|
||||||
"insert",
|
"insert",
|
||||||
created_by || "system",
|
created_by || "system"
|
||||||
recordCompanyCode
|
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -873,11 +807,6 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("updated_at")) {
|
if (tableColumns.includes("updated_at")) {
|
||||||
changedFields.updated_at = new Date();
|
changedFields.updated_at = new Date();
|
||||||
}
|
}
|
||||||
// updated_date 컬럼도 지원 (sales_order_mng 등)
|
|
||||||
if (tableColumns.includes("updated_date")) {
|
|
||||||
changedFields.updated_date = new Date();
|
|
||||||
console.log("📅 updated_date 자동 추가:", changedFields.updated_date);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||||||
|
|
||||||
|
|
@ -896,10 +825,10 @@ export class DynamicFormService {
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1 AND table_schema = 'public'
|
WHERE table_name = $1 AND table_schema = 'public'
|
||||||
`;
|
`;
|
||||||
const columnTypesResult = await query<{
|
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
|
||||||
column_name: string;
|
columnTypesQuery,
|
||||||
data_type: string;
|
[tableName]
|
||||||
}>(columnTypesQuery, [tableName]);
|
);
|
||||||
const columnTypes: Record<string, string> = {};
|
const columnTypes: Record<string, string> = {};
|
||||||
columnTypesResult.forEach((row) => {
|
columnTypesResult.forEach((row) => {
|
||||||
columnTypes[row.column_name] = row.data_type;
|
columnTypes[row.column_name] = row.data_type;
|
||||||
|
|
@ -912,24 +841,12 @@ export class DynamicFormService {
|
||||||
.map((key, index) => {
|
.map((key, index) => {
|
||||||
const dataType = columnTypes[key];
|
const dataType = columnTypes[key];
|
||||||
// 숫자 타입인 경우 명시적 캐스팅
|
// 숫자 타입인 경우 명시적 캐스팅
|
||||||
if (
|
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
|
||||||
dataType === "integer" ||
|
|
||||||
dataType === "bigint" ||
|
|
||||||
dataType === "smallint"
|
|
||||||
) {
|
|
||||||
return `${key} = $${index + 1}::integer`;
|
return `${key} = $${index + 1}::integer`;
|
||||||
} else if (
|
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
|
||||||
dataType === "numeric" ||
|
|
||||||
dataType === "decimal" ||
|
|
||||||
dataType === "real" ||
|
|
||||||
dataType === "double precision"
|
|
||||||
) {
|
|
||||||
return `${key} = $${index + 1}::numeric`;
|
return `${key} = $${index + 1}::numeric`;
|
||||||
} else if (dataType === "boolean") {
|
} else if (dataType === 'boolean') {
|
||||||
return `${key} = $${index + 1}::boolean`;
|
return `${key} = $${index + 1}::boolean`;
|
||||||
} else if (dataType === "jsonb" || dataType === "json") {
|
|
||||||
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
|
|
||||||
return `${key} = $${index + 1}::jsonb`;
|
|
||||||
} else {
|
} else {
|
||||||
// 문자열 타입은 캐스팅 불필요
|
// 문자열 타입은 캐스팅 불필요
|
||||||
return `${key} = $${index + 1}`;
|
return `${key} = $${index + 1}`;
|
||||||
|
|
@ -937,36 +854,18 @@ export class DynamicFormService {
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
|
const values: any[] = Object.values(changedFields);
|
||||||
const values: any[] = Object.keys(changedFields).map((key) => {
|
|
||||||
const value = changedFields[key];
|
|
||||||
const dataType = columnTypes[key];
|
|
||||||
|
|
||||||
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
|
|
||||||
if (
|
|
||||||
(dataType === "jsonb" || dataType === "json") &&
|
|
||||||
(Array.isArray(value) ||
|
|
||||||
(typeof value === "object" && value !== null))
|
|
||||||
) {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
values.push(id); // WHERE 조건용 ID 추가
|
values.push(id); // WHERE 조건용 ID 추가
|
||||||
|
|
||||||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||||||
const pkDataType = columnTypes[primaryKeyColumn];
|
const pkDataType = columnTypes[primaryKeyColumn];
|
||||||
let pkCast = "";
|
let pkCast = '';
|
||||||
if (
|
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
|
||||||
pkDataType === "integer" ||
|
pkCast = '::integer';
|
||||||
pkDataType === "bigint" ||
|
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
|
||||||
pkDataType === "smallint"
|
pkCast = '::numeric';
|
||||||
) {
|
} else if (pkDataType === 'uuid') {
|
||||||
pkCast = "::integer";
|
pkCast = '::uuid';
|
||||||
} else if (pkDataType === "numeric" || pkDataType === "decimal") {
|
|
||||||
pkCast = "::numeric";
|
|
||||||
} else if (pkDataType === "uuid") {
|
|
||||||
pkCast = "::uuid";
|
|
||||||
}
|
}
|
||||||
// text, varchar 등은 캐스팅 불필요
|
// text, varchar 등은 캐스팅 불필요
|
||||||
|
|
||||||
|
|
@ -1155,19 +1054,12 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (UPDATE 트리거)
|
// 🎯 제어관리 실행 (UPDATE 트리거)
|
||||||
try {
|
try {
|
||||||
// updatedRecord에서 company_code 추출
|
|
||||||
const recordCompanyCode =
|
|
||||||
(updatedRecord as Record<string, any>)?.company_code ||
|
|
||||||
company_code ||
|
|
||||||
"*";
|
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
updatedRecord as Record<string, any>,
|
updatedRecord as Record<string, any>,
|
||||||
"update",
|
"update",
|
||||||
updated_by || "system",
|
updated_by || "system"
|
||||||
recordCompanyCode
|
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1192,18 +1084,12 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
* @param id 삭제할 레코드 ID
|
|
||||||
* @param tableName 테이블명
|
|
||||||
* @param companyCode 회사 코드
|
|
||||||
* @param userId 사용자 ID
|
|
||||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string,
|
companyCode?: string,
|
||||||
userId?: string,
|
userId?: string
|
||||||
screenId?: number
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1274,15 +1160,7 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||||||
console.log("📊 SQL 파라미터:", [id]);
|
console.log("📊 SQL 파라미터:", [id]);
|
||||||
|
|
||||||
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
|
const result = await query<any>(deleteQuery, [id]);
|
||||||
const result = await transaction(async (client) => {
|
|
||||||
// 이력 트리거에서 사용할 사용자 정보 설정
|
|
||||||
if (userId) {
|
|
||||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
|
||||||
}
|
|
||||||
const res = await client.query(deleteQuery, [id]);
|
|
||||||
return res.rows;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||||
|
|
||||||
|
|
@ -1312,23 +1190,13 @@ export class DynamicFormService {
|
||||||
try {
|
try {
|
||||||
if (result && Array.isArray(result) && result.length > 0) {
|
if (result && Array.isArray(result) && result.length > 0) {
|
||||||
const deletedRecord = result[0] as Record<string, any>;
|
const deletedRecord = result[0] as Record<string, any>;
|
||||||
// deletedRecord에서 company_code 추출
|
|
||||||
const recordCompanyCode =
|
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
|
||||||
|
|
||||||
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
|
||||||
if (screenId && screenId > 0) {
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
screenId,
|
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
deletedRecord,
|
deletedRecord,
|
||||||
"delete",
|
"delete",
|
||||||
userId || "system",
|
userId || "system"
|
||||||
recordCompanyCode
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1627,15 +1495,13 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 제어관리 실행 (화면에 설정된 경우)
|
* 제어관리 실행 (화면에 설정된 경우)
|
||||||
* 다중 제어를 순서대로 순차 실행 지원
|
|
||||||
*/
|
*/
|
||||||
private async executeDataflowControlIfConfigured(
|
private async executeDataflowControlIfConfigured(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
savedData: Record<string, any>,
|
savedData: Record<string, any>,
|
||||||
triggerType: "insert" | "update" | "delete",
|
triggerType: "insert" | "update" | "delete",
|
||||||
userId: string = "system",
|
userId: string = "system"
|
||||||
companyCode: string = "*"
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||||
|
|
@ -1664,253 +1530,47 @@ export class DynamicFormService {
|
||||||
componentId: layout.component_id,
|
componentId: layout.component_id,
|
||||||
componentType: properties?.componentType,
|
componentType: properties?.componentType,
|
||||||
actionType: properties?.componentConfig?.action?.type,
|
actionType: properties?.componentConfig?.action?.type,
|
||||||
enableDataflowControl:
|
enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl,
|
||||||
properties?.webTypeConfig?.enableDataflowControl,
|
|
||||||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||||
hasDiagramId:
|
hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
|
||||||
hasFlowControls:
|
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||||
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
|
||||||
const buttonActionType = properties?.componentConfig?.action?.type;
|
|
||||||
const isMatchingAction =
|
|
||||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
|
||||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
isMatchingAction &&
|
properties?.componentConfig?.action?.type === "save" &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true &&
|
||||||
|
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
|
||||||
|
|
||||||
// 다중 제어 설정 확인 (flowControls 배열)
|
|
||||||
const flowControls = dataflowConfig?.flowControls || [];
|
|
||||||
|
|
||||||
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
|
|
||||||
if (flowControls.length > 0) {
|
|
||||||
controlConfigFound = true;
|
controlConfigFound = true;
|
||||||
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`);
|
const diagramId =
|
||||||
|
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
|
||||||
|
const relationshipId =
|
||||||
|
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
|
||||||
|
|
||||||
// 순서대로 정렬
|
console.log(`🎯 제어관리 설정 발견:`, {
|
||||||
const sortedControls = [...flowControls].sort(
|
|
||||||
(a: any, b: any) => (a.order || 0) - (b.order || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 다중 제어 순차 실행
|
|
||||||
await this.executeMultipleFlowControls(
|
|
||||||
sortedControls,
|
|
||||||
savedData,
|
|
||||||
screenId,
|
|
||||||
tableName,
|
|
||||||
triggerType,
|
|
||||||
userId,
|
|
||||||
companyCode
|
|
||||||
);
|
|
||||||
} else if (dataflowConfig?.selectedDiagramId) {
|
|
||||||
// 기존 단일 제어 실행 (하위 호환성)
|
|
||||||
controlConfigFound = true;
|
|
||||||
const diagramId = dataflowConfig.selectedDiagramId;
|
|
||||||
const relationshipId = dataflowConfig.selectedRelationshipId;
|
|
||||||
|
|
||||||
console.log(`🎯 단일 제어관리 설정 발견:`, {
|
|
||||||
componentId: layout.component_id,
|
componentId: layout.component_id,
|
||||||
diagramId,
|
diagramId,
|
||||||
relationshipId,
|
relationshipId,
|
||||||
triggerType,
|
triggerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.executeSingleFlowControl(
|
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
||||||
diagramId,
|
|
||||||
relationshipId,
|
|
||||||
savedData,
|
|
||||||
screenId,
|
|
||||||
tableName,
|
|
||||||
triggerType,
|
|
||||||
userId,
|
|
||||||
companyCode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 첫 번째 설정된 버튼의 제어관리만 실행
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controlConfigFound) {
|
|
||||||
console.log(`ℹ️ 제어관리 설정이 없습니다. (화면 ID: ${screenId})`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
|
|
||||||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다중 제어 순차 실행
|
|
||||||
*/
|
|
||||||
private async executeMultipleFlowControls(
|
|
||||||
flowControls: Array<{
|
|
||||||
id: string;
|
|
||||||
flowId: number;
|
|
||||||
flowName: string;
|
|
||||||
executionTiming: string;
|
|
||||||
order: number;
|
|
||||||
}>,
|
|
||||||
savedData: Record<string, any>,
|
|
||||||
screenId: number,
|
|
||||||
tableName: string,
|
|
||||||
triggerType: "insert" | "update" | "delete",
|
|
||||||
userId: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<void> {
|
|
||||||
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`);
|
|
||||||
|
|
||||||
const { NodeFlowExecutionService } = await import(
|
|
||||||
"./nodeFlowExecutionService"
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: Array<{
|
|
||||||
order: number;
|
|
||||||
flowId: number;
|
|
||||||
flowName: string;
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
duration: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < flowControls.length; i++) {
|
|
||||||
const control = flowControls[i];
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 유효하지 않은 flowId 스킵
|
|
||||||
if (!control.flowId || control.flowId <= 0) {
|
|
||||||
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
|
|
||||||
results.push({
|
|
||||||
order: control.order,
|
|
||||||
flowId: control.flowId,
|
|
||||||
flowName: control.flowName,
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 flowId",
|
|
||||||
duration: 0,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
|
||||||
control.flowId,
|
|
||||||
{
|
|
||||||
sourceData: [savedData],
|
|
||||||
dataSourceType: "formData",
|
|
||||||
buttonId: "save-button",
|
|
||||||
screenId: screenId,
|
|
||||||
userId: userId,
|
|
||||||
companyCode: companyCode,
|
|
||||||
formData: savedData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
order: control.order,
|
|
||||||
flowId: control.flowId,
|
|
||||||
flowName: control.flowName,
|
|
||||||
success: executionResult.success,
|
|
||||||
message: executionResult.message,
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (executionResult.success) {
|
|
||||||
console.log(
|
|
||||||
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
|
|
||||||
);
|
|
||||||
// 이전 제어 실패 시 다음 제어 실행 중단
|
|
||||||
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
console.error(
|
|
||||||
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
order: control.order,
|
|
||||||
flowId: control.flowId,
|
|
||||||
flowName: control.flowName,
|
|
||||||
success: false,
|
|
||||||
message: error.message || "실행 오류",
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 오류 발생 시 다음 제어 실행 중단
|
|
||||||
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행 결과 요약
|
|
||||||
const successCount = results.filter((r) => r.success).length;
|
|
||||||
const failCount = results.filter((r) => !r.success).length;
|
|
||||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
|
||||||
|
|
||||||
console.log(`\n📊 다중 제어 실행 완료:`, {
|
|
||||||
total: flowControls.length,
|
|
||||||
executed: results.length,
|
|
||||||
success: successCount,
|
|
||||||
failed: failCount,
|
|
||||||
totalDuration: `${totalDuration}ms`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단일 제어 실행 (기존 로직, 하위 호환성)
|
|
||||||
*/
|
|
||||||
private async executeSingleFlowControl(
|
|
||||||
diagramId: number,
|
|
||||||
relationshipId: string | null,
|
|
||||||
savedData: Record<string, any>,
|
|
||||||
screenId: number,
|
|
||||||
tableName: string,
|
|
||||||
triggerType: "insert" | "update" | "delete",
|
|
||||||
userId: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<void> {
|
|
||||||
let controlResult: any;
|
let controlResult: any;
|
||||||
|
|
||||||
if (!relationshipId) {
|
if (!relationshipId) {
|
||||||
// 노드 플로우 실행
|
// 노드 플로우 실행
|
||||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||||
const { NodeFlowExecutionService } = await import(
|
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||||
"./nodeFlowExecutionService"
|
|
||||||
);
|
|
||||||
|
|
||||||
const executionResult = await NodeFlowExecutionService.executeFlow(
|
const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, {
|
||||||
diagramId,
|
|
||||||
{
|
|
||||||
sourceData: [savedData],
|
sourceData: [savedData],
|
||||||
dataSourceType: "formData",
|
dataSourceType: "formData",
|
||||||
buttonId: "save-button",
|
buttonId: "save-button",
|
||||||
screenId: screenId,
|
screenId: screenId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
companyCode: companyCode,
|
|
||||||
formData: savedData,
|
formData: savedData,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
controlResult = {
|
controlResult = {
|
||||||
success: executionResult.success,
|
success: executionResult.success,
|
||||||
|
|
@ -1926,9 +1586,7 @@ export class DynamicFormService {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 관계 기반 제어관리 실행
|
// 관계 기반 제어관리 실행
|
||||||
console.log(
|
console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`);
|
||||||
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
|
||||||
);
|
|
||||||
controlResult = await this.dataflowControlService.executeDataflowControl(
|
controlResult = await this.dataflowControlService.executeDataflowControl(
|
||||||
diagramId,
|
diagramId,
|
||||||
relationshipId,
|
relationshipId,
|
||||||
|
|
@ -1950,14 +1608,31 @@ export class DynamicFormService {
|
||||||
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
|
||||||
if (controlResult.errors && controlResult.errors.length > 0) {
|
if (controlResult.errors && controlResult.errors.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
|
||||||
controlResult.errors
|
controlResult.errors
|
||||||
);
|
);
|
||||||
|
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
|
||||||
|
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
|
||||||
|
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controlConfigFound) {
|
||||||
|
console.log(`ℹ️ 제어관리 설정이 없습니다. (화면 ID: ${screenId})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
|
||||||
|
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1994,13 +1669,11 @@ export class DynamicFormService {
|
||||||
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
||||||
`;
|
`;
|
||||||
const columnResult = await client.query(columnQuery, [tableName]);
|
const columnResult = await client.query(columnQuery, [tableName]);
|
||||||
const existingColumns = columnResult.rows.map(
|
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
|
||||||
(row: any) => row.column_name
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasUpdatedBy = existingColumns.includes("updated_by");
|
const hasUpdatedBy = existingColumns.includes('updated_by');
|
||||||
const hasUpdatedAt = existingColumns.includes("updated_at");
|
const hasUpdatedAt = existingColumns.includes('updated_at');
|
||||||
const hasCompanyCode = existingColumns.includes("company_code");
|
const hasCompanyCode = existingColumns.includes('company_code');
|
||||||
|
|
||||||
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
||||||
hasUpdatedBy,
|
hasUpdatedBy,
|
||||||
|
|
@ -2058,192 +1731,6 @@ export class DynamicFormService {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치 이력 저장 (연속 위치 추적용)
|
|
||||||
*/
|
|
||||||
async saveLocationHistory(data: {
|
|
||||||
userId: string;
|
|
||||||
companyCode: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
accuracy?: number;
|
|
||||||
altitude?: number;
|
|
||||||
speed?: number;
|
|
||||||
heading?: number;
|
|
||||||
tripId?: string;
|
|
||||||
tripStatus?: string;
|
|
||||||
departure?: string;
|
|
||||||
arrival?: string;
|
|
||||||
departureName?: string;
|
|
||||||
destinationName?: string;
|
|
||||||
recordedAt?: string;
|
|
||||||
vehicleId?: number;
|
|
||||||
}): Promise<{ id: number }> {
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("📍 [saveLocationHistory] 저장 시작:", data);
|
|
||||||
|
|
||||||
const sqlQuery = `
|
|
||||||
INSERT INTO vehicle_location_history (
|
|
||||||
user_id,
|
|
||||||
company_code,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
accuracy,
|
|
||||||
altitude,
|
|
||||||
speed,
|
|
||||||
heading,
|
|
||||||
trip_id,
|
|
||||||
trip_status,
|
|
||||||
departure,
|
|
||||||
arrival,
|
|
||||||
departure_name,
|
|
||||||
destination_name,
|
|
||||||
recorded_at,
|
|
||||||
vehicle_id
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params = [
|
|
||||||
data.userId,
|
|
||||||
data.companyCode,
|
|
||||||
data.latitude,
|
|
||||||
data.longitude,
|
|
||||||
data.accuracy || null,
|
|
||||||
data.altitude || null,
|
|
||||||
data.speed || null,
|
|
||||||
data.heading || null,
|
|
||||||
data.tripId || null,
|
|
||||||
data.tripStatus || "active",
|
|
||||||
data.departure || null,
|
|
||||||
data.arrival || null,
|
|
||||||
data.departureName || null,
|
|
||||||
data.destinationName || null,
|
|
||||||
data.recordedAt ? new Date(data.recordedAt) : new Date(),
|
|
||||||
data.vehicleId || null,
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await client.query(sqlQuery, params);
|
|
||||||
|
|
||||||
console.log("✅ [saveLocationHistory] 저장 완료:", {
|
|
||||||
id: result.rows[0]?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { id: result.rows[0]?.id };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ [saveLocationHistory] 오류:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치 이력 조회 (경로 조회용)
|
|
||||||
*/
|
|
||||||
async getLocationHistory(params: {
|
|
||||||
companyCode: string;
|
|
||||||
tripId?: string;
|
|
||||||
userId?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<any[]> {
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("📍 [getLocationHistory] 조회 시작:", params);
|
|
||||||
|
|
||||||
const conditions: string[] = [];
|
|
||||||
const queryParams: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// 멀티테넌시: company_code 필터
|
|
||||||
if (params.companyCode && params.companyCode !== "*") {
|
|
||||||
conditions.push(`company_code = $${paramIndex}`);
|
|
||||||
queryParams.push(params.companyCode);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// trip_id 필터
|
|
||||||
if (params.tripId) {
|
|
||||||
conditions.push(`trip_id = $${paramIndex}`);
|
|
||||||
queryParams.push(params.tripId);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// user_id 필터
|
|
||||||
if (params.userId) {
|
|
||||||
conditions.push(`user_id = $${paramIndex}`);
|
|
||||||
queryParams.push(params.userId);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 범위 필터
|
|
||||||
if (params.startDate) {
|
|
||||||
conditions.push(`recorded_at >= $${paramIndex}`);
|
|
||||||
queryParams.push(new Date(params.startDate));
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.endDate) {
|
|
||||||
conditions.push(`recorded_at <= $${paramIndex}`);
|
|
||||||
queryParams.push(new Date(params.endDate));
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause =
|
|
||||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
||||||
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
|
||||||
|
|
||||||
const sqlQuery = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
vehicle_id,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
accuracy,
|
|
||||||
altitude,
|
|
||||||
speed,
|
|
||||||
heading,
|
|
||||||
trip_id,
|
|
||||||
trip_status,
|
|
||||||
departure,
|
|
||||||
arrival,
|
|
||||||
departure_name,
|
|
||||||
destination_name,
|
|
||||||
recorded_at,
|
|
||||||
created_at,
|
|
||||||
company_code
|
|
||||||
FROM vehicle_location_history
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY recorded_at ASC
|
|
||||||
${limitClause}
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery);
|
|
||||||
console.log("🔍 [getLocationHistory] 파라미터:", queryParams);
|
|
||||||
|
|
||||||
const result = await client.query(sqlQuery, queryParams);
|
|
||||||
|
|
||||||
console.log("✅ [getLocationHistory] 조회 완료:", {
|
|
||||||
count: result.rowCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ [getLocationHistory] 오류:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 싱글톤 인스턴스 생성 및 export
|
// 싱글톤 인스턴스 생성 및 export
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ export class EntityJoinService {
|
||||||
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
|
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
|
||||||
logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`);
|
logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`);
|
||||||
|
|
||||||
// 참조 테이블의 모든 컬럼 이름 가져오기
|
// 참조 테이블의 모든 컬럼 이름 가져오기
|
||||||
const tableColumnsResult = await query<{ column_name: string }>(
|
const tableColumnsResult = await query<{ column_name: string }>(
|
||||||
|
|
@ -148,34 +148,10 @@ export class EntityJoinService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tableColumnsResult.length > 0) {
|
if (tableColumnsResult.length > 0) {
|
||||||
const allColumns = tableColumnsResult.map((col) => col.column_name);
|
displayColumns = tableColumnsResult.map((col) => col.column_name);
|
||||||
|
|
||||||
// 🆕 표시용 컬럼 자동 감지 (우선순위 순서)
|
|
||||||
// 1. *_name 컬럼 (item_name, customer_name 등)
|
|
||||||
// 2. name 컬럼
|
|
||||||
// 3. label 컬럼
|
|
||||||
// 4. title 컬럼
|
|
||||||
// 5. 참조 컬럼 (referenceColumn)
|
|
||||||
const nameColumn = allColumns.find(
|
|
||||||
(col) => col.endsWith("_name") && col !== "company_name"
|
|
||||||
);
|
|
||||||
const simpleNameColumn = allColumns.find((col) => col === "name");
|
|
||||||
const labelColumn = allColumns.find(
|
|
||||||
(col) => col === "label" || col.endsWith("_label")
|
|
||||||
);
|
|
||||||
const titleColumn = allColumns.find((col) => col === "title");
|
|
||||||
|
|
||||||
// 우선순위에 따라 표시 컬럼 선택
|
|
||||||
const displayColumn =
|
|
||||||
nameColumn ||
|
|
||||||
simpleNameColumn ||
|
|
||||||
labelColumn ||
|
|
||||||
titleColumn ||
|
|
||||||
referenceColumn;
|
|
||||||
displayColumns = [displayColumn];
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
|
`✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
|
||||||
|
displayColumns.join(", ")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 테이블 컬럼을 못 찾으면 기본값 사용
|
// 테이블 컬럼을 못 찾으면 기본값 사용
|
||||||
|
|
@ -186,13 +162,8 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||||
// 단일 컬럼: manager + user_name → manager_user_name
|
const aliasColumn = `${column.column_name}_name`;
|
||||||
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
|
||||||
const firstDisplayColumn = displayColumns[0] || "name";
|
|
||||||
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
|
||||||
|
|
||||||
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
|
||||||
|
|
||||||
const joinConfig: EntityJoinConfig = {
|
const joinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
|
|
@ -432,25 +403,18 @@ export class EntityJoinService {
|
||||||
const fromClause = `FROM ${tableName} main`;
|
const fromClause = `FROM ${tableName} main`;
|
||||||
|
|
||||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||||
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
|
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
|
|
||||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
|
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
|
||||||
if (config.referenceTable === "user_info") {
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
|
||||||
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
import { getPool } from "../database/db";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export interface ExcelMappingTemplate {
|
|
||||||
id?: number;
|
|
||||||
tableName: string;
|
|
||||||
excelColumns: string[];
|
|
||||||
excelColumnsHash: string;
|
|
||||||
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
|
|
||||||
companyCode: string;
|
|
||||||
createdDate?: Date;
|
|
||||||
updatedDate?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExcelMappingService {
|
|
||||||
/**
|
|
||||||
* 엑셀 컬럼 목록으로 해시 생성
|
|
||||||
* 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
|
|
||||||
*/
|
|
||||||
generateColumnsHash(columns: string[]): string {
|
|
||||||
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
|
|
||||||
const sortedColumns = [...columns].sort();
|
|
||||||
const columnsString = sortedColumns.join("|");
|
|
||||||
return crypto.createHash("md5").update(columnsString).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
|
||||||
* 동일한 컬럼 구조가 있으면 기존 매핑 반환
|
|
||||||
*/
|
|
||||||
async findMappingByColumns(
|
|
||||||
tableName: string,
|
|
||||||
excelColumns: string[],
|
|
||||||
companyCode: string
|
|
||||||
): Promise<ExcelMappingTemplate | null> {
|
|
||||||
try {
|
|
||||||
const hash = this.generateColumnsHash(excelColumns);
|
|
||||||
|
|
||||||
logger.info("엑셀 매핑 템플릿 조회", {
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
hash,
|
|
||||||
companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
table_name as "tableName",
|
|
||||||
excel_columns as "excelColumns",
|
|
||||||
excel_columns_hash as "excelColumnsHash",
|
|
||||||
column_mappings as "columnMappings",
|
|
||||||
company_code as "companyCode",
|
|
||||||
created_date as "createdDate",
|
|
||||||
updated_date as "updatedDate"
|
|
||||||
FROM excel_mapping_template
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND excel_columns_hash = $2
|
|
||||||
ORDER BY updated_date DESC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
params = [tableName, hash];
|
|
||||||
} else {
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
table_name as "tableName",
|
|
||||||
excel_columns as "excelColumns",
|
|
||||||
excel_columns_hash as "excelColumnsHash",
|
|
||||||
column_mappings as "columnMappings",
|
|
||||||
company_code as "companyCode",
|
|
||||||
created_date as "createdDate",
|
|
||||||
updated_date as "updatedDate"
|
|
||||||
FROM excel_mapping_template
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND excel_columns_hash = $2
|
|
||||||
AND (company_code = $3 OR company_code = '*')
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
|
|
||||||
updated_date DESC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
params = [tableName, hash, companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
|
||||||
logger.info("기존 매핑 템플릿 발견", {
|
|
||||||
id: result.rows[0].id,
|
|
||||||
tableName,
|
|
||||||
});
|
|
||||||
return result.rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
|
|
||||||
return null;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 매핑 템플릿 저장 (UPSERT)
|
|
||||||
* 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
|
|
||||||
*/
|
|
||||||
async saveMappingTemplate(
|
|
||||||
tableName: string,
|
|
||||||
excelColumns: string[],
|
|
||||||
columnMappings: Record<string, string | null>,
|
|
||||||
companyCode: string,
|
|
||||||
userId?: string
|
|
||||||
): Promise<ExcelMappingTemplate> {
|
|
||||||
try {
|
|
||||||
const hash = this.generateColumnsHash(excelColumns);
|
|
||||||
|
|
||||||
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
hash,
|
|
||||||
columnMappings,
|
|
||||||
companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
INSERT INTO excel_mapping_template (
|
|
||||||
table_name,
|
|
||||||
excel_columns,
|
|
||||||
excel_columns_hash,
|
|
||||||
column_mappings,
|
|
||||||
company_code,
|
|
||||||
created_date,
|
|
||||||
updated_date
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
|
||||||
ON CONFLICT (table_name, excel_columns_hash, company_code)
|
|
||||||
DO UPDATE SET
|
|
||||||
column_mappings = EXCLUDED.column_mappings,
|
|
||||||
updated_date = NOW()
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
table_name as "tableName",
|
|
||||||
excel_columns as "excelColumns",
|
|
||||||
excel_columns_hash as "excelColumnsHash",
|
|
||||||
column_mappings as "columnMappings",
|
|
||||||
company_code as "companyCode",
|
|
||||||
created_date as "createdDate",
|
|
||||||
updated_date as "updatedDate"
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await pool.query(query, [
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
hash,
|
|
||||||
JSON.stringify(columnMappings),
|
|
||||||
companyCode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.info("매핑 템플릿 저장 완료", {
|
|
||||||
id: result.rows[0].id,
|
|
||||||
tableName,
|
|
||||||
hash,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블의 모든 매핑 템플릿 조회
|
|
||||||
*/
|
|
||||||
async getMappingTemplates(
|
|
||||||
tableName: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<ExcelMappingTemplate[]> {
|
|
||||||
try {
|
|
||||||
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
table_name as "tableName",
|
|
||||||
excel_columns as "excelColumns",
|
|
||||||
excel_columns_hash as "excelColumnsHash",
|
|
||||||
column_mappings as "columnMappings",
|
|
||||||
company_code as "companyCode",
|
|
||||||
created_date as "createdDate",
|
|
||||||
updated_date as "updatedDate"
|
|
||||||
FROM excel_mapping_template
|
|
||||||
WHERE table_name = $1
|
|
||||||
ORDER BY updated_date DESC
|
|
||||||
`;
|
|
||||||
params = [tableName];
|
|
||||||
} else {
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
table_name as "tableName",
|
|
||||||
excel_columns as "excelColumns",
|
|
||||||
excel_columns_hash as "excelColumnsHash",
|
|
||||||
column_mappings as "columnMappings",
|
|
||||||
company_code as "companyCode",
|
|
||||||
created_date as "createdDate",
|
|
||||||
updated_date as "updatedDate"
|
|
||||||
FROM excel_mapping_template
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND (company_code = $2 OR company_code = '*')
|
|
||||||
ORDER BY updated_date DESC
|
|
||||||
`;
|
|
||||||
params = [tableName, companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
|
|
||||||
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 매핑 템플릿 삭제
|
|
||||||
*/
|
|
||||||
async deleteMappingTemplate(
|
|
||||||
id: number,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
logger.info("매핑 템플릿 삭제", { id, companyCode });
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
|
|
||||||
params = [id];
|
|
||||||
} else {
|
|
||||||
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
|
|
||||||
params = [id, companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
|
|
||||||
if (result.rowCount && result.rowCount > 0) {
|
|
||||||
logger.info("매핑 템플릿 삭제 완료", { id });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
|
|
||||||
return false;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new ExcelMappingService();
|
|
||||||
|
|
||||||
|
|
@ -113,7 +113,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
lastUsedAt: Date;
|
lastUsedAt: Date;
|
||||||
activeConnections = 0;
|
activeConnections = 0;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
private isPoolClosed = false;
|
|
||||||
|
|
||||||
constructor(config: ExternalDbConnection) {
|
constructor(config: ExternalDbConnection) {
|
||||||
this.connectionId = config.id!;
|
this.connectionId = config.id!;
|
||||||
|
|
@ -132,9 +131,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
connectTimeout: (config.connection_timeout || 30) * 1000,
|
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||||
// 연결 유지 및 자동 재연결 설정
|
|
||||||
enableKeepAlive: true,
|
|
||||||
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
|
|
||||||
ssl:
|
ssl:
|
||||||
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -157,33 +153,11 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
|
|
||||||
async query(sql: string, params?: any[]): Promise<any> {
|
async query(sql: string, params?: any[]): Promise<any> {
|
||||||
this.lastUsedAt = new Date();
|
this.lastUsedAt = new Date();
|
||||||
|
|
||||||
// 연결 풀이 닫힌 상태인지 확인
|
|
||||||
if (this.isPoolClosed) {
|
|
||||||
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows] = await this.pool.execute(sql, params);
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error: any) {
|
|
||||||
// 연결 닫힘 오류 감지
|
|
||||||
if (
|
|
||||||
error.message.includes("closed state") ||
|
|
||||||
error.code === "PROTOCOL_CONNECTION_LOST" ||
|
|
||||||
error.code === "ECONNRESET"
|
|
||||||
) {
|
|
||||||
this.isPoolClosed = true;
|
|
||||||
logger.warn(
|
|
||||||
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this.isPoolClosed = true;
|
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||||
|
|
@ -191,10 +165,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
isHealthy(): boolean {
|
||||||
// 연결 풀이 닫혔으면 비정상
|
|
||||||
if (this.isPoolClosed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return this.activeConnections < this.maxConnections;
|
return this.activeConnections < this.maxConnections;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,11 +230,9 @@ export class ExternalDbConnectionPoolService {
|
||||||
): Promise<ConnectionPoolWrapper> {
|
): Promise<ConnectionPoolWrapper> {
|
||||||
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||||
|
|
||||||
// DB 연결 정보 조회 (실제 비밀번호 포함)
|
// DB 연결 정보 조회
|
||||||
const connectionResult =
|
const connectionResult =
|
||||||
await ExternalDbConnectionService.getConnectionByIdWithPassword(
|
await ExternalDbConnectionService.getConnectionById(connectionId);
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connectionResult.success || !connectionResult.data) {
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||||
|
|
@ -328,19 +296,16 @@ export class ExternalDbConnectionPoolService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직)
|
* 쿼리 실행 (자동으로 연결 풀 관리)
|
||||||
*/
|
*/
|
||||||
async executeQuery(
|
async executeQuery(
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
sql: string,
|
sql: string,
|
||||||
params?: any[],
|
params?: any[]
|
||||||
retryCount = 0
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const MAX_RETRIES = 2;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pool = await this.getPool(connectionId);
|
const pool = await this.getPool(connectionId);
|
||||||
|
|
||||||
|
try {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||||
);
|
);
|
||||||
|
|
@ -349,29 +314,7 @@ export class ExternalDbConnectionPoolService {
|
||||||
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// 연결 끊김 오류인 경우 재시도
|
|
||||||
const isConnectionError =
|
|
||||||
error.message?.includes("closed state") ||
|
|
||||||
error.message?.includes("연결 풀이 닫힌 상태") ||
|
|
||||||
error.code === "PROTOCOL_CONNECTION_LOST" ||
|
|
||||||
error.code === "ECONNRESET" ||
|
|
||||||
error.code === "ETIMEDOUT";
|
|
||||||
|
|
||||||
if (isConnectionError && retryCount < MAX_RETRIES) {
|
|
||||||
logger.warn(
|
|
||||||
`🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 기존 풀 제거 후 새로 생성
|
|
||||||
await this.removePool(connectionId);
|
|
||||||
|
|
||||||
// 잠시 대기 후 재시도
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
return this.executeQuery(connectionId, sql, params, retryCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 조건 적용
|
// 필터 조건 적용
|
||||||
if (filter.db_type) {
|
if (filter.db_type) {
|
||||||
whereConditions.push(`e.db_type = $${paramIndex++}`);
|
whereConditions.push(`db_type = $${paramIndex++}`);
|
||||||
params.push(filter.db_type);
|
params.push(filter.db_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
whereConditions.push(`e.is_active = $${paramIndex++}`);
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
||||||
if (filter.search && filter.search.trim()) {
|
if (filter.search && filter.search.trim()) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`(e.connection_name ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex})`
|
`(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
);
|
);
|
||||||
params.push(`%${filter.search.trim()}%`);
|
params.push(`%${filter.search.trim()}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -72,12 +72,9 @@ export class ExternalDbConnectionService {
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const connections = await query<any>(
|
const connections = await query<any>(
|
||||||
`SELECT e.*,
|
`SELECT * FROM external_db_connections
|
||||||
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
|
|
||||||
FROM external_db_connections e
|
|
||||||
LEFT JOIN company_mng c ON e.company_code = c.company_code
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY e.is_active DESC, e.connection_name ASC`,
|
ORDER BY is_active DESC, connection_name ASC`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,15 @@ export class ExternalRestApiConnectionService {
|
||||||
try {
|
try {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
|
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
e.default_method,
|
default_method,
|
||||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||||
e.default_request_body AS default_body,
|
default_request_body AS default_body,
|
||||||
e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
e.company_code, e.is_active, e.created_date, e.created_by,
|
company_code, is_active, created_date, created_by,
|
||||||
e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message,
|
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||||
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
|
FROM external_rest_api_connections
|
||||||
FROM external_rest_api_connections e
|
|
||||||
LEFT JOIN company_mng c ON e.company_code = c.company_code
|
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -50,7 +48,7 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
query += ` AND e.company_code = $${paramIndex}`;
|
query += ` AND company_code = $${paramIndex}`;
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
||||||
|
|
@ -58,14 +56,14 @@ export class ExternalRestApiConnectionService {
|
||||||
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND e.company_code = $${paramIndex}`;
|
query += ` AND company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND e.company_code = $${paramIndex}`;
|
query += ` AND company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -73,14 +71,14 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 활성 상태 필터
|
// 활성 상태 필터
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
query += ` AND e.is_active = $${paramIndex}`;
|
query += ` AND is_active = $${paramIndex}`;
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 타입 필터
|
// 인증 타입 필터
|
||||||
if (filter.auth_type) {
|
if (filter.auth_type) {
|
||||||
query += ` AND e.auth_type = $${paramIndex}`;
|
query += ` AND auth_type = $${paramIndex}`;
|
||||||
params.push(filter.auth_type);
|
params.push(filter.auth_type);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -88,9 +86,9 @@ export class ExternalRestApiConnectionService {
|
||||||
// 검색어 필터 (연결명, 설명, URL)
|
// 검색어 필터 (연결명, 설명, URL)
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
query += ` AND (
|
query += ` AND (
|
||||||
e.connection_name ILIKE $${paramIndex} OR
|
connection_name ILIKE $${paramIndex} OR
|
||||||
e.description ILIKE $${paramIndex} OR
|
description ILIKE $${paramIndex} OR
|
||||||
e.base_url ILIKE $${paramIndex}
|
base_url ILIKE $${paramIndex}
|
||||||
)`;
|
)`;
|
||||||
params.push(`%${filter.search}%`);
|
params.push(`%${filter.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -209,8 +207,8 @@ export class ExternalRestApiConnectionService {
|
||||||
connection_name, description, base_url, endpoint_path, default_headers,
|
connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
default_method, default_request_body,
|
default_method, default_request_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_by, save_to_history
|
company_code, is_active, created_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -230,13 +228,11 @@ export class ExternalRestApiConnectionService {
|
||||||
data.company_code || "*",
|
data.company_code || "*",
|
||||||
data.is_active || "Y",
|
data.is_active || "Y",
|
||||||
data.created_by || "system",
|
data.created_by || "system",
|
||||||
data.save_to_history || "N",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 디버깅: 저장하려는 데이터 로깅
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||||
connection_name: data.connection_name,
|
connection_name: data.connection_name,
|
||||||
company_code: data.company_code,
|
|
||||||
default_method: data.default_method,
|
default_method: data.default_method,
|
||||||
endpoint_path: data.endpoint_path,
|
endpoint_path: data.endpoint_path,
|
||||||
base_url: data.base_url,
|
base_url: data.base_url,
|
||||||
|
|
@ -378,12 +374,6 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.save_to_history !== undefined) {
|
|
||||||
updateFields.push(`save_to_history = $${paramIndex}`);
|
|
||||||
params.push(data.save_to_history);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.updated_by !== undefined) {
|
if (data.updated_by !== undefined) {
|
||||||
updateFields.push(`updated_by = $${paramIndex}`);
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
params.push(data.updated_by);
|
params.push(data.updated_by);
|
||||||
|
|
@ -1101,150 +1091,4 @@ export class ExternalRestApiConnectionService {
|
||||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 다중 REST API 데이터 조회 및 병합
|
|
||||||
* 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환
|
|
||||||
*/
|
|
||||||
static async fetchMultipleData(
|
|
||||||
configs: Array<{
|
|
||||||
connectionId: number;
|
|
||||||
endpoint: string;
|
|
||||||
jsonPath: string;
|
|
||||||
alias: string;
|
|
||||||
}>,
|
|
||||||
userCompanyCode?: string
|
|
||||||
): Promise<ApiResponse<{
|
|
||||||
rows: any[];
|
|
||||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
|
|
||||||
total: number;
|
|
||||||
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
|
|
||||||
}>> {
|
|
||||||
try {
|
|
||||||
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
|
|
||||||
|
|
||||||
// 각 API에서 데이터 조회
|
|
||||||
const results = await Promise.all(
|
|
||||||
configs.map(async (config) => {
|
|
||||||
try {
|
|
||||||
const result = await this.fetchData(
|
|
||||||
config.connectionId,
|
|
||||||
config.endpoint,
|
|
||||||
config.jsonPath,
|
|
||||||
userCompanyCode
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
connectionId: config.connectionId,
|
|
||||||
connectionName: result.data.connectionInfo.connectionName,
|
|
||||||
alias: config.alias,
|
|
||||||
rows: result.data.rows,
|
|
||||||
columns: result.data.columns,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
connectionId: config.connectionId,
|
|
||||||
connectionName: "",
|
|
||||||
alias: config.alias,
|
|
||||||
rows: [],
|
|
||||||
columns: [],
|
|
||||||
error: result.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`API ${config.connectionId} 조회 오류:`, error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
connectionId: config.connectionId,
|
|
||||||
connectionName: "",
|
|
||||||
alias: config.alias,
|
|
||||||
rows: [],
|
|
||||||
columns: [],
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 성공한 결과만 필터링
|
|
||||||
const successfulResults = results.filter(r => r.success);
|
|
||||||
|
|
||||||
if (successfulResults.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "모든 REST API 조회에 실패했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "ALL_APIS_FAILED",
|
|
||||||
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컬럼 병합 (별칭 적용)
|
|
||||||
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
|
|
||||||
|
|
||||||
for (const result of successfulResults) {
|
|
||||||
for (const col of result.columns) {
|
|
||||||
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
|
|
||||||
mergedColumns.push({
|
|
||||||
columnName: prefixedColumnName,
|
|
||||||
columnLabel: `${col.columnLabel} (${result.connectionName})`,
|
|
||||||
dataType: col.dataType,
|
|
||||||
sourceApi: result.connectionName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
|
|
||||||
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
|
|
||||||
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
|
|
||||||
const mergedRows: any[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < maxRows; i++) {
|
|
||||||
const mergedRow: any = {};
|
|
||||||
|
|
||||||
for (const result of successfulResults) {
|
|
||||||
const row = result.rows[i] || {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(row)) {
|
|
||||||
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
|
|
||||||
mergedRow[prefixedKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mergedRows.push(mergedRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
rows: mergedRows,
|
|
||||||
columns: mergedColumns,
|
|
||||||
total: mergedRows.length,
|
|
||||||
sources: successfulResults.map(r => ({
|
|
||||||
connectionId: r.connectionId,
|
|
||||||
connectionName: r.connectionName,
|
|
||||||
rowCount: r.rows.length,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("다중 REST API 데이터 조회 오류:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "다중 REST API 데이터 조회에 실패했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "MULTI_FETCH_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,6 @@ export class FlowDataMoveService {
|
||||||
// 내부 DB 처리 (기존 로직)
|
// 내부 DB 처리 (기존 로직)
|
||||||
return await db.transaction(async (client) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
|
||||||
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
|
||||||
userId || "system",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
@ -689,14 +684,6 @@ export class FlowDataMoveService {
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
async (externalClient, dbType) => {
|
async (externalClient, dbType) => {
|
||||||
try {
|
try {
|
||||||
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
|
||||||
if (dbType.toLowerCase() === "postgresql") {
|
|
||||||
await externalClient.query(
|
|
||||||
"SELECT set_config('app.user_id', $1, true)",
|
|
||||||
[userId || "system"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 단계 정보 조회 (내부 DB에서)
|
// 1. 단계 정보 조회 (내부 DB에서)
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
|
||||||
|
|
@ -27,21 +27,13 @@ export class FlowDefinitionService {
|
||||||
tableName: request.tableName,
|
tableName: request.tableName,
|
||||||
dbSourceType: request.dbSourceType,
|
dbSourceType: request.dbSourceType,
|
||||||
dbConnectionId: request.dbConnectionId,
|
dbConnectionId: request.dbConnectionId,
|
||||||
restApiConnectionId: request.restApiConnectionId,
|
|
||||||
restApiEndpoint: request.restApiEndpoint,
|
|
||||||
restApiJsonPath: request.restApiJsonPath,
|
|
||||||
restApiConnections: request.restApiConnections,
|
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO flow_definition (
|
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
||||||
name, description, table_name, db_source_type, db_connection_id,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
|
|
||||||
rest_api_connections, company_code, created_by
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -51,10 +43,6 @@ export class FlowDefinitionService {
|
||||||
request.tableName || null,
|
request.tableName || null,
|
||||||
request.dbSourceType || "internal",
|
request.dbSourceType || "internal",
|
||||||
request.dbConnectionId || null,
|
request.dbConnectionId || null,
|
||||||
request.restApiConnectionId || null,
|
|
||||||
request.restApiEndpoint || null,
|
|
||||||
request.restApiJsonPath || "response",
|
|
||||||
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
|
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
];
|
];
|
||||||
|
|
@ -211,19 +199,6 @@ export class FlowDefinitionService {
|
||||||
* DB 행을 FlowDefinition 객체로 변환
|
* DB 행을 FlowDefinition 객체로 변환
|
||||||
*/
|
*/
|
||||||
private mapToFlowDefinition(row: any): FlowDefinition {
|
private mapToFlowDefinition(row: any): FlowDefinition {
|
||||||
// rest_api_connections 파싱 (JSONB → 배열)
|
|
||||||
let restApiConnections = undefined;
|
|
||||||
if (row.rest_api_connections) {
|
|
||||||
try {
|
|
||||||
restApiConnections = typeof row.rest_api_connections === 'string'
|
|
||||||
? JSON.parse(row.rest_api_connections)
|
|
||||||
: row.rest_api_connections;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to parse rest_api_connections:", e);
|
|
||||||
restApiConnections = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|
@ -231,12 +206,6 @@ export class FlowDefinitionService {
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
dbSourceType: row.db_source_type || "internal",
|
dbSourceType: row.db_source_type || "internal",
|
||||||
dbConnectionId: row.db_connection_id,
|
dbConnectionId: row.db_connection_id,
|
||||||
// REST API 관련 필드 (단일)
|
|
||||||
restApiConnectionId: row.rest_api_connection_id,
|
|
||||||
restApiEndpoint: row.rest_api_endpoint,
|
|
||||||
restApiJsonPath: row.rest_api_json_path,
|
|
||||||
// 다중 REST API 관련 필드
|
|
||||||
restApiConnections: restApiConnections,
|
|
||||||
companyCode: row.company_code || "*",
|
companyCode: row.company_code || "*",
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
|
|
|
||||||
|
|
@ -263,139 +263,4 @@ export class FlowExecutionService {
|
||||||
tableName: result[0].table_name,
|
tableName: result[0].table_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 스텝 데이터 업데이트 (인라인 편집)
|
|
||||||
* 원본 테이블의 데이터를 직접 업데이트합니다.
|
|
||||||
*/
|
|
||||||
async updateStepData(
|
|
||||||
flowId: number,
|
|
||||||
stepId: number,
|
|
||||||
recordId: string,
|
|
||||||
updateData: Record<string, any>,
|
|
||||||
userId: string,
|
|
||||||
companyCode?: string
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
try {
|
|
||||||
// 1. 플로우 정의 조회
|
|
||||||
const flowDef = await this.flowDefinitionService.findById(flowId);
|
|
||||||
if (!flowDef) {
|
|
||||||
throw new Error(`Flow definition not found: ${flowId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 스텝 조회
|
|
||||||
const step = await this.flowStepService.findById(stepId);
|
|
||||||
if (!step) {
|
|
||||||
throw new Error(`Flow step not found: ${stepId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 테이블명 결정
|
|
||||||
const tableName = step.tableName || flowDef.tableName;
|
|
||||||
if (!tableName) {
|
|
||||||
throw new Error("Table name not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Primary Key 컬럼 결정 (기본값: id)
|
|
||||||
const primaryKeyColumn = flowDef.primaryKey || "id";
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. SET 절 생성
|
|
||||||
const updateColumns = Object.keys(updateData);
|
|
||||||
if (updateColumns.length === 0) {
|
|
||||||
throw new Error("No columns to update");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 외부 DB vs 내부 DB 구분
|
|
||||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
|
||||||
// 외부 DB 업데이트
|
|
||||||
console.log(
|
|
||||||
"✅ [updateStepData] Using EXTERNAL DB:",
|
|
||||||
flowDef.dbConnectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 외부 DB 연결 정보 조회
|
|
||||||
const connectionResult = await db.query(
|
|
||||||
"SELECT * FROM external_db_connection WHERE id = $1",
|
|
||||||
[flowDef.dbConnectionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (connectionResult.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`External DB connection not found: ${flowDef.dbConnectionId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = connectionResult[0];
|
|
||||||
const dbType = connection.db_type?.toLowerCase();
|
|
||||||
|
|
||||||
// DB 타입에 따른 placeholder 및 쿼리 생성
|
|
||||||
let setClause: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (dbType === "mysql" || dbType === "mariadb") {
|
|
||||||
// MySQL/MariaDB: ? placeholder
|
|
||||||
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
|
||||||
params = [...Object.values(updateData), recordId];
|
|
||||||
} else if (dbType === "mssql") {
|
|
||||||
// MSSQL: @p1, @p2 placeholder
|
|
||||||
setClause = updateColumns
|
|
||||||
.map((col, idx) => `[${col}] = @p${idx + 1}`)
|
|
||||||
.join(", ");
|
|
||||||
params = [...Object.values(updateData), recordId];
|
|
||||||
} else {
|
|
||||||
// PostgreSQL: $1, $2 placeholder
|
|
||||||
setClause = updateColumns
|
|
||||||
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
|
||||||
.join(", ");
|
|
||||||
params = [...Object.values(updateData), recordId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
|
||||||
|
|
||||||
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
|
||||||
console.log(`📝 [updateStepData] Params:`, params);
|
|
||||||
|
|
||||||
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
|
||||||
} else {
|
|
||||||
// 내부 DB 업데이트
|
|
||||||
console.log("✅ [updateStepData] Using INTERNAL DB");
|
|
||||||
|
|
||||||
const setClause = updateColumns
|
|
||||||
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
|
||||||
.join(", ");
|
|
||||||
const params = [...Object.values(updateData), recordId];
|
|
||||||
|
|
||||||
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
|
||||||
|
|
||||||
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
|
||||||
console.log(`📝 [updateStepData] Params:`, params);
|
|
||||||
|
|
||||||
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
|
||||||
// (트리거에서 changed_by를 기록하기 위함)
|
|
||||||
await db.transaction(async (client) => {
|
|
||||||
// 안전한 파라미터 바인딩 방식 사용
|
|
||||||
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
|
||||||
userId,
|
|
||||||
]);
|
|
||||||
await client.query(updateQuery, params);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
|
|
||||||
{
|
|
||||||
updatedFields: updateColumns,
|
|
||||||
userId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ [updateStepData] Error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,908 +0,0 @@
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 처리 서비스
|
|
||||||
*
|
|
||||||
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
|
||||||
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 인터페이스 정의
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보
|
|
||||||
*/
|
|
||||||
export interface MasterDetailRelation {
|
|
||||||
masterTable: string;
|
|
||||||
detailTable: string;
|
|
||||||
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
|
||||||
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
|
||||||
masterColumns: ColumnInfo[];
|
|
||||||
detailColumns: ColumnInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 정보
|
|
||||||
*/
|
|
||||||
export interface ColumnInfo {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
inputType: string;
|
|
||||||
isFromMaster: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 분할 패널 설정
|
|
||||||
*/
|
|
||||||
export interface SplitPanelConfig {
|
|
||||||
leftPanel: {
|
|
||||||
tableName: string;
|
|
||||||
columns: Array<{ name: string; label: string; width?: number }>;
|
|
||||||
};
|
|
||||||
rightPanel: {
|
|
||||||
tableName: string;
|
|
||||||
columns: Array<{ name: string; label: string; width?: number }>;
|
|
||||||
relation?: {
|
|
||||||
type: string;
|
|
||||||
foreignKey?: string;
|
|
||||||
leftColumn?: string;
|
|
||||||
// 복합키 지원 (새로운 방식)
|
|
||||||
keys?: Array<{
|
|
||||||
leftColumn: string;
|
|
||||||
rightColumn: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 다운로드 결과
|
|
||||||
*/
|
|
||||||
export interface ExcelDownloadData {
|
|
||||||
headers: string[]; // 컬럼 라벨들
|
|
||||||
columns: string[]; // 컬럼명들
|
|
||||||
data: Record<string, any>[];
|
|
||||||
masterColumns: string[]; // 마스터 컬럼 목록
|
|
||||||
detailColumns: string[]; // 디테일 컬럼 목록
|
|
||||||
joinKey: string; // 조인 키
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 업로드 결과
|
|
||||||
*/
|
|
||||||
export interface ExcelUploadResult {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
masterUpdated: number;
|
|
||||||
detailInserted: number;
|
|
||||||
detailDeleted: number;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 서비스 클래스
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
class MasterDetailExcelService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 화면 ID로 분할 패널 설정 조회
|
|
||||||
*/
|
|
||||||
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
|
||||||
try {
|
|
||||||
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
|
||||||
|
|
||||||
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
|
||||||
const result = await queryOne<any>(
|
|
||||||
`SELECT properties->>'componentConfig' as config
|
|
||||||
FROM screen_layouts
|
|
||||||
WHERE screen_id = $1
|
|
||||||
AND component_type = 'component'
|
|
||||||
AND properties->>'componentType' = 'split-panel-layout'
|
|
||||||
LIMIT 1`,
|
|
||||||
[screenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result || !result.config) {
|
|
||||||
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = typeof result.config === "string"
|
|
||||||
? JSON.parse(result.config)
|
|
||||||
: result.config;
|
|
||||||
|
|
||||||
logger.info(`분할 패널 설정 발견:`, {
|
|
||||||
leftTable: config.leftPanel?.tableName,
|
|
||||||
rightTable: config.rightPanel?.tableName,
|
|
||||||
relation: config.rightPanel?.relation,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
leftPanel: config.leftPanel,
|
|
||||||
rightPanel: config.rightPanel,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* column_labels에서 Entity 관계 정보 조회
|
|
||||||
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
|
||||||
*/
|
|
||||||
async getEntityRelation(
|
|
||||||
detailTable: string,
|
|
||||||
masterTable: string
|
|
||||||
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
|
||||||
try {
|
|
||||||
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
|
||||||
|
|
||||||
const result = await queryOne<any>(
|
|
||||||
`SELECT column_name, reference_column
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND input_type = 'entity'
|
|
||||||
AND reference_table = $2
|
|
||||||
LIMIT 1`,
|
|
||||||
[detailTable, masterTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
detailFkColumn: result.column_name,
|
|
||||||
masterKeyColumn: result.reference_column,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블의 컬럼 라벨 정보 조회
|
|
||||||
*/
|
|
||||||
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
|
||||||
try {
|
|
||||||
const result = await query<any>(
|
|
||||||
`SELECT column_name, column_label
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1`,
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelMap = new Map<string, string>();
|
|
||||||
for (const row of result) {
|
|
||||||
labelMap.set(row.column_name, row.column_label || row.column_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return labelMap;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보 조합
|
|
||||||
*/
|
|
||||||
async getMasterDetailRelation(
|
|
||||||
screenId: number
|
|
||||||
): Promise<MasterDetailRelation | null> {
|
|
||||||
try {
|
|
||||||
// 1. 분할 패널 설정 조회
|
|
||||||
const splitPanel = await this.getSplitPanelConfig(screenId);
|
|
||||||
if (!splitPanel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const masterTable = splitPanel.leftPanel.tableName;
|
|
||||||
const detailTable = splitPanel.rightPanel.tableName;
|
|
||||||
|
|
||||||
if (!masterTable || !detailTable) {
|
|
||||||
logger.warn("마스터 또는 디테일 테이블명 없음");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
|
||||||
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
|
||||||
let masterKeyColumn: string | undefined;
|
|
||||||
let detailFkColumn: string | undefined;
|
|
||||||
|
|
||||||
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
|
||||||
if (relationKeys && relationKeys.length > 0) {
|
|
||||||
// keys 배열에서 첫 번째 키 사용
|
|
||||||
masterKeyColumn = relationKeys[0].leftColumn;
|
|
||||||
detailFkColumn = relationKeys[0].rightColumn;
|
|
||||||
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
|
||||||
} else {
|
|
||||||
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
|
||||||
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
|
||||||
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
|
||||||
if (!masterKeyColumn || !detailFkColumn) {
|
|
||||||
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
|
||||||
if (entityRelation) {
|
|
||||||
masterKeyColumn = entityRelation.masterKeyColumn;
|
|
||||||
detailFkColumn = entityRelation.detailFkColumn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!masterKeyColumn || !detailFkColumn) {
|
|
||||||
logger.warn("조인 키 정보를 찾을 수 없음");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 컬럼 라벨 정보 조회
|
|
||||||
const masterLabels = await this.getColumnLabels(masterTable);
|
|
||||||
const detailLabels = await this.getColumnLabels(detailTable);
|
|
||||||
|
|
||||||
// 5. 마스터 컬럼 정보 구성
|
|
||||||
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
|
||||||
name: col.name,
|
|
||||||
label: masterLabels.get(col.name) || col.label || col.name,
|
|
||||||
inputType: "text",
|
|
||||||
isFromMaster: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
|
||||||
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
|
||||||
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
|
||||||
.map(col => ({
|
|
||||||
name: col.name,
|
|
||||||
label: detailLabels.get(col.name) || col.label || col.name,
|
|
||||||
inputType: "text",
|
|
||||||
isFromMaster: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
|
||||||
masterTable,
|
|
||||||
detailTable,
|
|
||||||
masterKeyColumn,
|
|
||||||
detailFkColumn,
|
|
||||||
masterColumnCount: masterColumns.length,
|
|
||||||
detailColumnCount: detailColumns.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
masterTable,
|
|
||||||
detailTable,
|
|
||||||
masterKeyColumn,
|
|
||||||
detailFkColumn,
|
|
||||||
masterColumns,
|
|
||||||
detailColumns,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
|
||||||
*/
|
|
||||||
async getJoinedData(
|
|
||||||
relation: MasterDetailRelation,
|
|
||||||
companyCode: string,
|
|
||||||
filters?: Record<string, any>
|
|
||||||
): Promise<ExcelDownloadData> {
|
|
||||||
try {
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
|
||||||
|
|
||||||
// 조인 컬럼과 일반 컬럼 분리
|
|
||||||
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
|
||||||
const entityJoins: Array<{
|
|
||||||
refTable: string;
|
|
||||||
refColumn: string;
|
|
||||||
sourceColumn: string;
|
|
||||||
alias: string;
|
|
||||||
displayColumn: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// SELECT 절 구성
|
|
||||||
const selectParts: string[] = [];
|
|
||||||
let aliasIndex = 0;
|
|
||||||
|
|
||||||
// 마스터 컬럼 처리
|
|
||||||
for (const col of masterColumns) {
|
|
||||||
if (col.name.includes(".")) {
|
|
||||||
// 조인 컬럼: 테이블명.컬럼명
|
|
||||||
const [refTable, displayColumn] = col.name.split(".");
|
|
||||||
const alias = `ej${aliasIndex++}`;
|
|
||||||
|
|
||||||
// column_labels에서 FK 컬럼 찾기
|
|
||||||
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
|
||||||
if (fkColumn) {
|
|
||||||
entityJoins.push({
|
|
||||||
refTable,
|
|
||||||
refColumn: fkColumn.referenceColumn,
|
|
||||||
sourceColumn: fkColumn.sourceColumn,
|
|
||||||
alias,
|
|
||||||
displayColumn,
|
|
||||||
});
|
|
||||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
||||||
} else {
|
|
||||||
// FK를 못 찾으면 NULL로 처리
|
|
||||||
selectParts.push(`NULL AS "${col.name}"`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 일반 컬럼
|
|
||||||
selectParts.push(`m."${col.name}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디테일 컬럼 처리
|
|
||||||
for (const col of detailColumns) {
|
|
||||||
if (col.name.includes(".")) {
|
|
||||||
// 조인 컬럼: 테이블명.컬럼명
|
|
||||||
const [refTable, displayColumn] = col.name.split(".");
|
|
||||||
const alias = `ej${aliasIndex++}`;
|
|
||||||
|
|
||||||
// column_labels에서 FK 컬럼 찾기
|
|
||||||
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
|
||||||
if (fkColumn) {
|
|
||||||
entityJoins.push({
|
|
||||||
refTable,
|
|
||||||
refColumn: fkColumn.referenceColumn,
|
|
||||||
sourceColumn: fkColumn.sourceColumn,
|
|
||||||
alias,
|
|
||||||
displayColumn,
|
|
||||||
});
|
|
||||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
||||||
} else {
|
|
||||||
selectParts.push(`NULL AS "${col.name}"`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 일반 컬럼
|
|
||||||
selectParts.push(`d."${col.name}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClause = selectParts.join(", ");
|
|
||||||
|
|
||||||
// 엔티티 조인 절 구성
|
|
||||||
const entityJoinClauses = entityJoins.map(ej =>
|
|
||||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
|
||||||
).join("\n ");
|
|
||||||
|
|
||||||
// WHERE 절 구성
|
|
||||||
const whereConditions: string[] = [];
|
|
||||||
const params: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// 회사 코드 필터 (최고 관리자 제외)
|
|
||||||
if (companyCode && companyCode !== "*") {
|
|
||||||
whereConditions.push(`m.company_code = $${paramIndex}`);
|
|
||||||
params.push(companyCode);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 필터 적용
|
|
||||||
if (filters) {
|
|
||||||
for (const [key, value] of Object.entries(filters)) {
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
// 조인 컬럼인지 확인
|
|
||||||
if (key.includes(".")) continue;
|
|
||||||
// 마스터 테이블 컬럼인지 확인
|
|
||||||
const isMasterCol = masterColumns.some(c => c.name === key);
|
|
||||||
const tableAlias = isMasterCol ? "m" : "d";
|
|
||||||
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// JOIN 쿼리 실행
|
|
||||||
const sql = `
|
|
||||||
SELECT ${selectClause}
|
|
||||||
FROM "${masterTable}" m
|
|
||||||
LEFT JOIN "${detailTable}" d
|
|
||||||
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
|
||||||
AND m.company_code = d.company_code
|
|
||||||
${entityJoinClauses}
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY m."${masterKeyColumn}", d.id
|
|
||||||
`;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
|
||||||
|
|
||||||
const data = await query<any>(sql, params);
|
|
||||||
|
|
||||||
// 헤더 및 컬럼 정보 구성
|
|
||||||
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
|
||||||
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
headers,
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
masterColumns: masterColumns.map(c => c.name),
|
|
||||||
detailColumns: detailColumns.map(c => c.name),
|
|
||||||
joinKey: masterKeyColumn,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
|
||||||
*/
|
|
||||||
private async findForeignKeyColumn(
|
|
||||||
sourceTable: string,
|
|
||||||
referenceTable: string
|
|
||||||
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
|
||||||
try {
|
|
||||||
const result = await query<{ column_name: string; reference_column: string }>(
|
|
||||||
`SELECT column_name, reference_column
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND reference_table = $2
|
|
||||||
AND input_type = 'entity'
|
|
||||||
LIMIT 1`,
|
|
||||||
[sourceTable, referenceTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
return {
|
|
||||||
sourceColumn: result[0].column_name,
|
|
||||||
referenceColumn: result[0].reference_column,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
|
||||||
*
|
|
||||||
* 처리 로직:
|
|
||||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
|
||||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
|
||||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
|
||||||
* 4. 새 디테일 데이터 INSERT
|
|
||||||
*/
|
|
||||||
async uploadJoinedData(
|
|
||||||
relation: MasterDetailRelation,
|
|
||||||
data: Record<string, any>[],
|
|
||||||
companyCode: string,
|
|
||||||
userId?: string
|
|
||||||
): Promise<ExcelUploadResult> {
|
|
||||||
const result: ExcelUploadResult = {
|
|
||||||
success: false,
|
|
||||||
masterInserted: 0,
|
|
||||||
masterUpdated: 0,
|
|
||||||
detailInserted: 0,
|
|
||||||
detailDeleted: 0,
|
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
|
||||||
|
|
||||||
// 1. 데이터를 마스터 키로 그룹화
|
|
||||||
const groupedData = new Map<string, Record<string, any>[]>();
|
|
||||||
|
|
||||||
for (const row of data) {
|
|
||||||
const masterKey = row[masterKeyColumn];
|
|
||||||
if (!masterKey) {
|
|
||||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groupedData.has(masterKey)) {
|
|
||||||
groupedData.set(masterKey, []);
|
|
||||||
}
|
|
||||||
groupedData.get(masterKey)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
|
||||||
|
|
||||||
// 2. 각 그룹 처리
|
|
||||||
for (const [masterKey, rows] of groupedData.entries()) {
|
|
||||||
try {
|
|
||||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
|
||||||
const masterData: Record<string, any> = {};
|
|
||||||
for (const col of masterColumns) {
|
|
||||||
if (rows[0][col.name] !== undefined) {
|
|
||||||
masterData[col.name] = rows[0][col.name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사 코드, 작성자 추가
|
|
||||||
masterData.company_code = companyCode;
|
|
||||||
if (userId) {
|
|
||||||
masterData.writer = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. 마스터 UPSERT
|
|
||||||
const existingMaster = await client.query(
|
|
||||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
|
||||||
[masterKey, companyCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingMaster.rows.length > 0) {
|
|
||||||
// UPDATE
|
|
||||||
const updateCols = Object.keys(masterData)
|
|
||||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
|
||||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
|
||||||
const updateValues = Object.keys(masterData)
|
|
||||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
|
||||||
.map(k => masterData[k]);
|
|
||||||
|
|
||||||
if (updateCols.length > 0) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE "${masterTable}"
|
|
||||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
|
||||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
|
||||||
[...updateValues, masterKey, companyCode]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result.masterUpdated++;
|
|
||||||
} else {
|
|
||||||
// INSERT
|
|
||||||
const insertCols = Object.keys(masterData);
|
|
||||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const insertValues = insertCols.map(k => masterData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
|
||||||
insertValues
|
|
||||||
);
|
|
||||||
result.masterInserted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2c. 기존 디테일 삭제
|
|
||||||
const deleteResult = await client.query(
|
|
||||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
|
||||||
[masterKey, companyCode]
|
|
||||||
);
|
|
||||||
result.detailDeleted += deleteResult.rowCount || 0;
|
|
||||||
|
|
||||||
// 2d. 새 디테일 INSERT
|
|
||||||
for (const row of rows) {
|
|
||||||
const detailData: Record<string, any> = {};
|
|
||||||
|
|
||||||
// FK 컬럼 추가
|
|
||||||
detailData[detailFkColumn] = masterKey;
|
|
||||||
detailData.company_code = companyCode;
|
|
||||||
if (userId) {
|
|
||||||
detailData.writer = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디테일 컬럼 데이터 추출
|
|
||||||
for (const col of detailColumns) {
|
|
||||||
if (row[col.name] !== undefined) {
|
|
||||||
detailData[col.name] = row[col.name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertCols = Object.keys(detailData);
|
|
||||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const insertValues = insertCols.map(k => detailData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
|
||||||
insertValues
|
|
||||||
);
|
|
||||||
result.detailInserted++;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
masterUpdated: result.masterUpdated,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
detailDeleted: result.detailDeleted,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 간단 모드 업로드
|
|
||||||
*
|
|
||||||
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
|
||||||
* 채번 규칙을 통해 마스터 키 자동 생성
|
|
||||||
*
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @param detailData 디테일 데이터 배열
|
|
||||||
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
|
||||||
* @param numberingRuleId 채번 규칙 ID (optional)
|
|
||||||
* @param companyCode 회사 코드
|
|
||||||
* @param userId 사용자 ID
|
|
||||||
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
|
||||||
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
|
||||||
*/
|
|
||||||
async uploadSimple(
|
|
||||||
screenId: number,
|
|
||||||
detailData: Record<string, any>[],
|
|
||||||
masterFieldValues: Record<string, any>,
|
|
||||||
numberingRuleId: string | undefined,
|
|
||||||
companyCode: string,
|
|
||||||
userId: string,
|
|
||||||
afterUploadFlowId?: string,
|
|
||||||
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
detailInserted: number;
|
|
||||||
generatedKey: string;
|
|
||||||
errors: string[];
|
|
||||||
controlResult?: any;
|
|
||||||
}> {
|
|
||||||
const result: {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
detailInserted: number;
|
|
||||||
generatedKey: string;
|
|
||||||
errors: string[];
|
|
||||||
controlResult?: any;
|
|
||||||
} = {
|
|
||||||
success: false,
|
|
||||||
masterInserted: 0,
|
|
||||||
detailInserted: 0,
|
|
||||||
generatedKey: "",
|
|
||||||
errors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 정보 조회
|
|
||||||
const relation = await this.getMasterDetailRelation(screenId);
|
|
||||||
if (!relation) {
|
|
||||||
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
|
||||||
|
|
||||||
// 2. 채번 처리
|
|
||||||
let generatedKey: string;
|
|
||||||
|
|
||||||
if (numberingRuleId) {
|
|
||||||
// 채번 규칙으로 키 생성
|
|
||||||
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
|
||||||
} else {
|
|
||||||
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
|
||||||
generatedKey = masterFieldValues[masterKeyColumn];
|
|
||||||
if (!generatedKey) {
|
|
||||||
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.generatedKey = generatedKey;
|
|
||||||
logger.info(`채번 결과: ${generatedKey}`);
|
|
||||||
|
|
||||||
// 3. 마스터 레코드 생성
|
|
||||||
const masterData: Record<string, any> = {
|
|
||||||
...masterFieldValues,
|
|
||||||
[masterKeyColumn]: generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
writer: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마스터 컬럼명 목록 구성
|
|
||||||
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
|
||||||
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const masterValues = masterCols.map(k => masterData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
|
||||||
masterValues
|
|
||||||
);
|
|
||||||
result.masterInserted = 1;
|
|
||||||
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
|
||||||
|
|
||||||
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
|
||||||
const insertedDetailRows: Record<string, any>[] = [];
|
|
||||||
|
|
||||||
for (const row of detailData) {
|
|
||||||
try {
|
|
||||||
const detailRowData: Record<string, any> = {
|
|
||||||
...row,
|
|
||||||
[detailFkColumn]: generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
writer: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 빈 값 필터링 및 id 제외
|
|
||||||
const detailCols = Object.keys(detailRowData).filter(k =>
|
|
||||||
k !== "id" &&
|
|
||||||
detailRowData[k] !== undefined &&
|
|
||||||
detailRowData[k] !== null &&
|
|
||||||
detailRowData[k] !== ""
|
|
||||||
);
|
|
||||||
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const detailValues = detailCols.map(k => detailRowData[k]);
|
|
||||||
|
|
||||||
// RETURNING *로 삽입된 데이터 반환받기
|
|
||||||
const insertResult = await client.query(
|
|
||||||
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
|
||||||
RETURNING *`,
|
|
||||||
detailValues
|
|
||||||
);
|
|
||||||
|
|
||||||
if (insertResult.rows && insertResult.rows[0]) {
|
|
||||||
insertedDetailRows.push(insertResult.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.detailInserted++;
|
|
||||||
} catch (error: any) {
|
|
||||||
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
|
||||||
logger.error(`디테일 행 처리 실패:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
generatedKey: result.generatedKey,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 업로드 후 제어 실행 (단일 또는 다중)
|
|
||||||
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
|
||||||
? afterUploadFlows // 다중 제어
|
|
||||||
: afterUploadFlowId
|
|
||||||
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (flowsToExecute.length > 0 && result.success) {
|
|
||||||
try {
|
|
||||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
|
||||||
|
|
||||||
// 마스터 데이터 구성
|
|
||||||
const masterData = {
|
|
||||||
...masterFieldValues,
|
|
||||||
[relation!.masterKeyColumn]: result.generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controlResults: any[] = [];
|
|
||||||
|
|
||||||
// 순서대로 제어 실행
|
|
||||||
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
|
||||||
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
|
||||||
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
|
||||||
|
|
||||||
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
|
||||||
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
|
||||||
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
|
||||||
const controlResult = await NodeFlowExecutionService.executeFlow(
|
|
||||||
parseInt(flow.flowId),
|
|
||||||
{
|
|
||||||
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
|
||||||
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
|
||||||
buttonId: "excel-upload-button",
|
|
||||||
screenId: screenId,
|
|
||||||
userId: userId,
|
|
||||||
companyCode: companyCode,
|
|
||||||
formData: masterData,
|
|
||||||
// 추가 컨텍스트: 마스터/디테일 정보
|
|
||||||
masterData: masterData,
|
|
||||||
detailData: insertedDetailRows,
|
|
||||||
masterTable: relation!.masterTable,
|
|
||||||
detailTable: relation!.detailTable,
|
|
||||||
masterKeyColumn: relation!.masterKeyColumn,
|
|
||||||
detailFkColumn: relation!.detailFkColumn,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
controlResults.push({
|
|
||||||
flowId: flow.flowId,
|
|
||||||
order: flow.order,
|
|
||||||
success: controlResult.success,
|
|
||||||
message: controlResult.message,
|
|
||||||
executedNodes: controlResult.nodes?.length || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
result.controlResult = {
|
|
||||||
success: controlResults.every(r => r.success),
|
|
||||||
executedFlows: controlResults.length,
|
|
||||||
results: controlResults,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
|
||||||
} catch (controlError: any) {
|
|
||||||
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
|
||||||
result.controlResult = {
|
|
||||||
success: false,
|
|
||||||
message: `제어 실행 실패: ${controlError.message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
|
||||||
*/
|
|
||||||
private async generateNumberWithRule(
|
|
||||||
client: any,
|
|
||||||
ruleId: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// 기존 numberingRuleService를 사용하여 코드 할당
|
|
||||||
const { numberingRuleService } = await import("./numberingRuleService");
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
|
||||||
|
|
||||||
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
|
||||||
|
|
||||||
return generatedCode;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const masterDetailExcelService = new MasterDetailExcelService();
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,969 +0,0 @@
|
||||||
import { getPool } from "../database/db";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메뉴-화면그룹 동기화 서비스
|
|
||||||
*
|
|
||||||
* 양방향 동기화:
|
|
||||||
* 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화
|
|
||||||
* 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 타입 정의
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
interface SyncResult {
|
|
||||||
success: boolean;
|
|
||||||
created: number;
|
|
||||||
linked: number;
|
|
||||||
skipped: number;
|
|
||||||
errors: string[];
|
|
||||||
details: SyncDetail[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncDetail {
|
|
||||||
action: 'created' | 'linked' | 'skipped' | 'error';
|
|
||||||
sourceName: string;
|
|
||||||
sourceId: number | string;
|
|
||||||
targetId?: number | string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 화면관리 → 메뉴 동기화
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* screen_groups를 menu_info로 동기화
|
|
||||||
*
|
|
||||||
* 로직:
|
|
||||||
* 1. 해당 회사의 screen_groups 조회 (폴더 구조)
|
|
||||||
* 2. 이미 menu_objid가 연결된 것은 제외
|
|
||||||
* 3. 이름으로 기존 menu_info 매칭 시도
|
|
||||||
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
|
||||||
* - 매칭 안되면: menu_info에 새로 생성
|
|
||||||
* 4. 계층 구조(parent) 유지
|
|
||||||
*/
|
|
||||||
export async function syncScreenGroupsToMenu(
|
|
||||||
companyCode: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<SyncResult> {
|
|
||||||
const result: SyncResult = {
|
|
||||||
success: true,
|
|
||||||
created: 0,
|
|
||||||
linked: 0,
|
|
||||||
skipped: 0,
|
|
||||||
errors: [],
|
|
||||||
details: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
|
|
||||||
|
|
||||||
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
|
|
||||||
const screenGroupsQuery = `
|
|
||||||
SELECT
|
|
||||||
sg.id,
|
|
||||||
sg.group_name,
|
|
||||||
sg.group_code,
|
|
||||||
sg.parent_group_id,
|
|
||||||
sg.group_level,
|
|
||||||
sg.display_order,
|
|
||||||
sg.description,
|
|
||||||
sg.icon,
|
|
||||||
sg.menu_objid,
|
|
||||||
-- 부모 그룹의 menu_objid도 조회 (계층 연결용)
|
|
||||||
parent.menu_objid as parent_menu_objid
|
|
||||||
FROM screen_groups sg
|
|
||||||
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
|
|
||||||
WHERE sg.company_code = $1
|
|
||||||
ORDER BY sg.group_level ASC, sg.display_order ASC
|
|
||||||
`;
|
|
||||||
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
|
|
||||||
|
|
||||||
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
|
|
||||||
// 경로 기반 매칭을 위해 부모 이름도 조회
|
|
||||||
const existingMenusQuery = `
|
|
||||||
SELECT
|
|
||||||
m.objid,
|
|
||||||
m.menu_name_kor,
|
|
||||||
m.parent_obj_id,
|
|
||||||
m.screen_group_id,
|
|
||||||
p.menu_name_kor as parent_name
|
|
||||||
FROM menu_info m
|
|
||||||
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
|
|
||||||
WHERE m.company_code = $1 AND m.menu_type = 1
|
|
||||||
`;
|
|
||||||
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
|
|
||||||
|
|
||||||
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
|
|
||||||
// 단순 이름 매칭도 유지 (하위 호환)
|
|
||||||
const menuByPath: Map<string, any> = new Map();
|
|
||||||
const menuByName: Map<string, any> = new Map();
|
|
||||||
existingMenusResult.rows.forEach((menu: any) => {
|
|
||||||
if (!menu.screen_group_id) {
|
|
||||||
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
|
|
||||||
const parentName = menu.parent_name?.trim().toLowerCase() || '';
|
|
||||||
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
|
|
||||||
|
|
||||||
menuByPath.set(pathKey, menu);
|
|
||||||
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
|
||||||
if (!menuByName.has(menuName)) {
|
|
||||||
menuByName.set(menuName, menu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
|
||||||
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
|
||||||
|
|
||||||
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
|
||||||
// 없으면 생성
|
|
||||||
let userMenuRootObjid: number | null = null;
|
|
||||||
const rootMenuQuery = `
|
|
||||||
SELECT objid FROM menu_info
|
|
||||||
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
|
|
||||||
ORDER BY seq ASC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
|
|
||||||
|
|
||||||
if (rootMenuResult.rows.length > 0) {
|
|
||||||
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
|
||||||
} else {
|
|
||||||
// 루트 메뉴가 없으면 생성
|
|
||||||
const newObjid = Date.now();
|
|
||||||
const createRootQuery = `
|
|
||||||
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
|
||||||
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
|
||||||
RETURNING objid
|
|
||||||
`;
|
|
||||||
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
|
||||||
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
|
||||||
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
|
||||||
const groupToMenuMap: Map<number, number> = new Map();
|
|
||||||
|
|
||||||
// screen_groups의 부모 이름 조회를 위한 매핑
|
|
||||||
const groupIdToName: Map<number, string> = new Map();
|
|
||||||
screenGroupsResult.rows.forEach((g: any) => {
|
|
||||||
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
|
|
||||||
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
|
|
||||||
const topLevelCompanyFolderIds = new Set<number>();
|
|
||||||
for (const group of screenGroupsResult.rows) {
|
|
||||||
if (group.group_level === 0 && group.parent_group_id === null) {
|
|
||||||
topLevelCompanyFolderIds.add(group.id);
|
|
||||||
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
|
|
||||||
groupToMenuMap.set(group.id, userMenuRootObjid!);
|
|
||||||
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 각 screen_group 처리
|
|
||||||
for (const group of screenGroupsResult.rows) {
|
|
||||||
const groupId = group.id;
|
|
||||||
const groupName = group.group_name?.trim();
|
|
||||||
const groupNameLower = groupName?.toLowerCase() || '';
|
|
||||||
|
|
||||||
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
|
|
||||||
if (topLevelCompanyFolderIds.has(groupId)) {
|
|
||||||
result.skipped++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'skipped',
|
|
||||||
sourceName: groupName,
|
|
||||||
sourceId: groupId,
|
|
||||||
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
|
|
||||||
if (group.menu_objid) {
|
|
||||||
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
|
|
||||||
|
|
||||||
if (menuExists) {
|
|
||||||
// 메뉴가 존재하면 스킵
|
|
||||||
result.skipped++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'skipped',
|
|
||||||
sourceName: groupName,
|
|
||||||
sourceId: groupId,
|
|
||||||
targetId: group.menu_objid,
|
|
||||||
reason: '이미 메뉴와 연결됨',
|
|
||||||
});
|
|
||||||
groupToMenuMap.set(groupId, Number(group.menu_objid));
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
// 메뉴가 삭제되었으면 연결 해제하고 재생성
|
|
||||||
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
|
|
||||||
[groupId]
|
|
||||||
);
|
|
||||||
// 계속 진행하여 재생성 또는 재연결
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 부모 그룹 이름 조회 (경로 기반 매칭용)
|
|
||||||
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
|
|
||||||
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
|
|
||||||
|
|
||||||
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
|
||||||
let matchedMenu = menuByPath.get(pathKey);
|
|
||||||
if (!matchedMenu) {
|
|
||||||
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
|
||||||
matchedMenu = menuByName.get(groupNameLower);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedMenu) {
|
|
||||||
// 매칭된 메뉴와 연결
|
|
||||||
const menuObjid = Number(matchedMenu.objid);
|
|
||||||
|
|
||||||
// screen_groups에 menu_objid 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
||||||
[menuObjid, groupId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// menu_info에 screen_group_id 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
||||||
[groupId, menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
groupToMenuMap.set(groupId, menuObjid);
|
|
||||||
result.linked++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'linked',
|
|
||||||
sourceName: groupName,
|
|
||||||
sourceId: groupId,
|
|
||||||
targetId: menuObjid,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
|
|
||||||
menuByPath.delete(pathKey);
|
|
||||||
menuByName.delete(groupNameLower);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 새 메뉴 생성
|
|
||||||
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
|
||||||
|
|
||||||
// 부모 메뉴 objid 결정
|
|
||||||
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
|
||||||
let parentMenuObjid = userMenuRootObjid;
|
|
||||||
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
|
|
||||||
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
|
|
||||||
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
|
|
||||||
} else if (group.parent_group_id && group.parent_menu_objid) {
|
|
||||||
// 기존 parent_menu_objid가 실제로 존재하는지 확인
|
|
||||||
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
|
|
||||||
if (parentMenuExists) {
|
|
||||||
parentMenuObjid = Number(group.parent_menu_objid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
|
|
||||||
let nextSeq = 1;
|
|
||||||
const maxSeqQuery = `
|
|
||||||
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
|
|
||||||
FROM menu_info
|
|
||||||
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
|
|
||||||
`;
|
|
||||||
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
|
|
||||||
if (maxSeqResult.rows.length > 0) {
|
|
||||||
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// menu_info에 삽입
|
|
||||||
const insertMenuQuery = `
|
|
||||||
INSERT INTO menu_info (
|
|
||||||
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
|
||||||
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
|
|
||||||
RETURNING objid
|
|
||||||
`;
|
|
||||||
await client.query(insertMenuQuery, [
|
|
||||||
newObjid,
|
|
||||||
parentMenuObjid,
|
|
||||||
groupName,
|
|
||||||
group.group_code || groupName,
|
|
||||||
nextSeq,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
groupId,
|
|
||||||
group.description || null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// screen_groups에 menu_objid 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
||||||
[newObjid, groupId]
|
|
||||||
);
|
|
||||||
|
|
||||||
groupToMenuMap.set(groupId, newObjid);
|
|
||||||
result.created++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'created',
|
|
||||||
sourceName: groupName,
|
|
||||||
sourceId: groupId,
|
|
||||||
targetId: newObjid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
logger.info("화면관리 → 메뉴 동기화 완료", {
|
|
||||||
companyCode,
|
|
||||||
created: result.created,
|
|
||||||
linked: result.linked,
|
|
||||||
skipped: result.skipped
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
|
|
||||||
result.success = false;
|
|
||||||
result.errors.push(error.message);
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 메뉴 → 화면관리 동기화
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* menu_info를 screen_groups로 동기화
|
|
||||||
*
|
|
||||||
* 로직:
|
|
||||||
* 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회
|
|
||||||
* 2. 이미 screen_group_id가 연결된 것은 제외
|
|
||||||
* 3. 이름으로 기존 screen_groups 매칭 시도
|
|
||||||
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
|
||||||
* - 매칭 안되면: screen_groups에 새로 생성 (폴더로)
|
|
||||||
* 4. 계층 구조(parent) 유지
|
|
||||||
*/
|
|
||||||
export async function syncMenuToScreenGroups(
|
|
||||||
companyCode: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<SyncResult> {
|
|
||||||
const result: SyncResult = {
|
|
||||||
success: true,
|
|
||||||
created: 0,
|
|
||||||
linked: 0,
|
|
||||||
skipped: 0,
|
|
||||||
errors: [],
|
|
||||||
details: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
|
|
||||||
|
|
||||||
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
|
|
||||||
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
|
|
||||||
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
|
|
||||||
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
|
|
||||||
|
|
||||||
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
|
|
||||||
const menusQuery = `
|
|
||||||
SELECT
|
|
||||||
m.objid,
|
|
||||||
m.menu_name_kor,
|
|
||||||
m.menu_name_eng,
|
|
||||||
m.parent_obj_id,
|
|
||||||
m.seq,
|
|
||||||
m.menu_url,
|
|
||||||
m.menu_desc,
|
|
||||||
m.screen_group_id,
|
|
||||||
-- 부모 메뉴의 screen_group_id도 조회 (계층 연결용)
|
|
||||||
parent.screen_group_id as parent_screen_group_id
|
|
||||||
FROM menu_info m
|
|
||||||
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
|
|
||||||
WHERE m.company_code = $1 AND m.menu_type = 1
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
|
|
||||||
m.parent_obj_id,
|
|
||||||
m.seq
|
|
||||||
`;
|
|
||||||
const menusResult = await client.query(menusQuery, [companyCode]);
|
|
||||||
|
|
||||||
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
|
|
||||||
const existingGroupsQuery = `
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.group_name,
|
|
||||||
g.menu_objid,
|
|
||||||
g.parent_group_id,
|
|
||||||
p.group_name as parent_name
|
|
||||||
FROM screen_groups g
|
|
||||||
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
|
|
||||||
WHERE g.company_code = $1
|
|
||||||
`;
|
|
||||||
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
|
|
||||||
|
|
||||||
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
|
|
||||||
// 단순 이름 매칭도 유지 (하위 호환)
|
|
||||||
const groupByPath: Map<string, any> = new Map();
|
|
||||||
const groupByName: Map<string, any> = new Map();
|
|
||||||
existingGroupsResult.rows.forEach((group: any) => {
|
|
||||||
if (!group.menu_objid) {
|
|
||||||
const groupName = group.group_name?.trim().toLowerCase() || '';
|
|
||||||
const parentName = group.parent_name?.trim().toLowerCase() || '';
|
|
||||||
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
|
|
||||||
|
|
||||||
groupByPath.set(pathKey, group);
|
|
||||||
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
|
||||||
if (!groupByName.has(groupName)) {
|
|
||||||
groupByName.set(groupName, group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 그룹의 id 집합 (삭제 확인용)
|
|
||||||
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
|
|
||||||
|
|
||||||
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
|
|
||||||
let companyFolderId: number | null = null;
|
|
||||||
const companyFolderQuery = `
|
|
||||||
SELECT id FROM screen_groups
|
|
||||||
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
|
|
||||||
|
|
||||||
if (companyFolderResult.rows.length > 0) {
|
|
||||||
companyFolderId = companyFolderResult.rows[0].id;
|
|
||||||
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
|
|
||||||
} else {
|
|
||||||
// 회사 폴더가 없으면 생성
|
|
||||||
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
|
|
||||||
let nextRootOrder = 1;
|
|
||||||
const maxRootOrderQuery = `
|
|
||||||
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
|
|
||||||
FROM screen_groups
|
|
||||||
WHERE parent_group_id IS NULL
|
|
||||||
`;
|
|
||||||
const maxRootOrderResult = await client.query(maxRootOrderQuery);
|
|
||||||
if (maxRootOrderResult.rows.length > 0) {
|
|
||||||
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createFolderQuery = `
|
|
||||||
INSERT INTO screen_groups (
|
|
||||||
group_name, group_code, parent_group_id, group_level,
|
|
||||||
display_order, company_code, writer, hierarchy_path
|
|
||||||
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
const createFolderResult = await client.query(createFolderQuery, [
|
|
||||||
companyName,
|
|
||||||
companyCode.toLowerCase(),
|
|
||||||
nextRootOrder,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
]);
|
|
||||||
companyFolderId = createFolderResult.rows[0].id;
|
|
||||||
|
|
||||||
// hierarchy_path 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
|
||||||
[`/${companyFolderId}/`, companyFolderId]
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
|
|
||||||
const menuToGroupMap: Map<number, number> = new Map();
|
|
||||||
|
|
||||||
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
|
|
||||||
menusResult.rows.forEach((menu: any) => {
|
|
||||||
if (menu.screen_group_id) {
|
|
||||||
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
|
|
||||||
let rootMenuObjid: number | null = null;
|
|
||||||
for (const menu of menusResult.rows) {
|
|
||||||
if (Number(menu.parent_obj_id) === 0) {
|
|
||||||
rootMenuObjid = Number(menu.objid);
|
|
||||||
// 루트 메뉴는 회사 폴더와 연결
|
|
||||||
if (companyFolderId) {
|
|
||||||
menuToGroupMap.set(rootMenuObjid, companyFolderId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 각 메뉴 처리
|
|
||||||
for (const menu of menusResult.rows) {
|
|
||||||
const menuObjid = Number(menu.objid);
|
|
||||||
const menuName = menu.menu_name_kor?.trim();
|
|
||||||
|
|
||||||
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
|
|
||||||
if (Number(menu.parent_obj_id) === 0) {
|
|
||||||
result.skipped++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'skipped',
|
|
||||||
sourceName: menuName,
|
|
||||||
sourceId: menuObjid,
|
|
||||||
targetId: companyFolderId || undefined,
|
|
||||||
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
|
|
||||||
if (menu.screen_group_id) {
|
|
||||||
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
|
|
||||||
|
|
||||||
if (groupExists) {
|
|
||||||
// 그룹이 존재하면 스킵
|
|
||||||
result.skipped++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'skipped',
|
|
||||||
sourceName: menuName,
|
|
||||||
sourceId: menuObjid,
|
|
||||||
targetId: menu.screen_group_id,
|
|
||||||
reason: '이미 화면그룹과 연결됨',
|
|
||||||
});
|
|
||||||
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
// 그룹이 삭제되었으면 연결 해제하고 재생성
|
|
||||||
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
|
|
||||||
await client.query(
|
|
||||||
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
// 계속 진행하여 재생성 또는 재연결
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuNameLower = menuName?.toLowerCase() || '';
|
|
||||||
|
|
||||||
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
|
|
||||||
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
|
|
||||||
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
|
|
||||||
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
|
|
||||||
|
|
||||||
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
|
||||||
let matchedGroup = groupByPath.get(pathKey);
|
|
||||||
if (!matchedGroup) {
|
|
||||||
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
|
||||||
matchedGroup = groupByName.get(menuNameLower);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedGroup) {
|
|
||||||
// 매칭된 그룹과 연결
|
|
||||||
const groupId = Number(matchedGroup.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// menu_info에 screen_group_id 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
||||||
[groupId, menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// screen_groups에 menu_objid 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
|
||||||
[menuObjid, groupId]
|
|
||||||
);
|
|
||||||
|
|
||||||
menuToGroupMap.set(menuObjid, groupId);
|
|
||||||
result.linked++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'linked',
|
|
||||||
sourceName: menuName,
|
|
||||||
sourceId: menuObjid,
|
|
||||||
targetId: groupId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
|
|
||||||
groupByPath.delete(pathKey);
|
|
||||||
groupByName.delete(menuNameLower);
|
|
||||||
} catch (linkError: any) {
|
|
||||||
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
|
|
||||||
throw linkError;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 새 screen_group 생성
|
|
||||||
// 부모 그룹 ID 결정
|
|
||||||
let parentGroupId: number | null = null;
|
|
||||||
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
|
|
||||||
|
|
||||||
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
|
|
||||||
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
|
|
||||||
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
|
|
||||||
}
|
|
||||||
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
|
|
||||||
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
|
|
||||||
parentGroupId = companyFolderId;
|
|
||||||
}
|
|
||||||
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
|
|
||||||
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
|
|
||||||
parentGroupId = Number(menu.parent_screen_group_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 부모 그룹의 레벨 조회
|
|
||||||
if (parentGroupId) {
|
|
||||||
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
|
|
||||||
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
|
|
||||||
if (parentLevelResult.rows.length > 0) {
|
|
||||||
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
|
|
||||||
let nextDisplayOrder = 1;
|
|
||||||
const maxOrderQuery = parentGroupId
|
|
||||||
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
|
|
||||||
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
|
|
||||||
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
|
|
||||||
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
|
|
||||||
if (maxOrderResult.rows.length > 0) {
|
|
||||||
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// group_code 생성 (영문명 또는 이름 기반)
|
|
||||||
const groupCode = (menu.menu_name_eng || menuName || 'group')
|
|
||||||
.replace(/\s+/g, '_')
|
|
||||||
.toLowerCase()
|
|
||||||
.substring(0, 50);
|
|
||||||
|
|
||||||
// screen_groups에 삽입
|
|
||||||
const insertGroupQuery = `
|
|
||||||
INSERT INTO screen_groups (
|
|
||||||
group_name, group_code, parent_group_id, group_level,
|
|
||||||
display_order, company_code, writer, menu_objid, description
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
|
|
||||||
let newGroupId: number;
|
|
||||||
try {
|
|
||||||
logger.info("새 그룹 생성 시도", {
|
|
||||||
menuName,
|
|
||||||
menuObjid,
|
|
||||||
groupCode: groupCode + '_' + menuObjid,
|
|
||||||
parentGroupId,
|
|
||||||
groupLevel,
|
|
||||||
nextDisplayOrder,
|
|
||||||
companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertResult = await client.query(insertGroupQuery, [
|
|
||||||
menuName,
|
|
||||||
groupCode + '_' + menuObjid, // 고유성 보장
|
|
||||||
parentGroupId,
|
|
||||||
groupLevel,
|
|
||||||
nextDisplayOrder,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
menuObjid,
|
|
||||||
menu.menu_desc || null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
newGroupId = insertResult.rows[0].id;
|
|
||||||
} catch (insertError: any) {
|
|
||||||
logger.error("그룹 생성 중 에러", {
|
|
||||||
menuName,
|
|
||||||
menuObjid,
|
|
||||||
parentGroupId,
|
|
||||||
groupLevel,
|
|
||||||
error: insertError.message,
|
|
||||||
stack: insertError.stack,
|
|
||||||
code: insertError.code,
|
|
||||||
detail: insertError.detail,
|
|
||||||
});
|
|
||||||
throw insertError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hierarchy_path 업데이트
|
|
||||||
let hierarchyPath = `/${newGroupId}/`;
|
|
||||||
if (parentGroupId) {
|
|
||||||
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
|
|
||||||
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
|
|
||||||
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
|
|
||||||
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
|
||||||
[hierarchyPath, newGroupId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// menu_info에 screen_group_id 업데이트
|
|
||||||
await client.query(
|
|
||||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
|
||||||
[newGroupId, menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
menuToGroupMap.set(menuObjid, newGroupId);
|
|
||||||
result.created++;
|
|
||||||
result.details.push({
|
|
||||||
action: 'created',
|
|
||||||
sourceName: menuName,
|
|
||||||
sourceId: menuObjid,
|
|
||||||
targetId: newGroupId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
logger.info("메뉴 → 화면관리 동기화 완료", {
|
|
||||||
companyCode,
|
|
||||||
created: result.created,
|
|
||||||
linked: result.linked,
|
|
||||||
skipped: result.skipped
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
logger.error("메뉴 → 화면관리 동기화 실패", {
|
|
||||||
companyCode,
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
code: error.code,
|
|
||||||
detail: error.detail,
|
|
||||||
});
|
|
||||||
result.success = false;
|
|
||||||
result.errors.push(error.message);
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 동기화 상태 조회
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 동기화 상태 조회
|
|
||||||
*
|
|
||||||
* - 연결된 항목 수
|
|
||||||
* - 연결 안 된 항목 수
|
|
||||||
* - 양방향 비교
|
|
||||||
*/
|
|
||||||
export async function getSyncStatus(companyCode: string): Promise<{
|
|
||||||
screenGroups: { total: number; linked: number; unlinked: number };
|
|
||||||
menuItems: { total: number; linked: number; unlinked: number };
|
|
||||||
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
|
|
||||||
}> {
|
|
||||||
// screen_groups 상태
|
|
||||||
const sgQuery = `
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(menu_objid) as linked
|
|
||||||
FROM screen_groups
|
|
||||||
WHERE company_code = $1
|
|
||||||
`;
|
|
||||||
const sgResult = await pool.query(sgQuery, [companyCode]);
|
|
||||||
|
|
||||||
// menu_info 상태 (사용자 메뉴만, 루트 제외)
|
|
||||||
const menuQuery = `
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(screen_group_id) as linked
|
|
||||||
FROM menu_info
|
|
||||||
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
|
|
||||||
`;
|
|
||||||
const menuResult = await pool.query(menuQuery, [companyCode]);
|
|
||||||
|
|
||||||
// 이름이 같은 잠재적 매칭 후보 조회
|
|
||||||
const matchQuery = `
|
|
||||||
SELECT
|
|
||||||
m.menu_name_kor as menu_name,
|
|
||||||
sg.group_name
|
|
||||||
FROM menu_info m
|
|
||||||
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
|
|
||||||
WHERE m.company_code = $1
|
|
||||||
AND sg.company_code = $1
|
|
||||||
AND m.menu_type = 1
|
|
||||||
AND m.screen_group_id IS NULL
|
|
||||||
AND sg.menu_objid IS NULL
|
|
||||||
LIMIT 10
|
|
||||||
`;
|
|
||||||
const matchResult = await pool.query(matchQuery, [companyCode]);
|
|
||||||
|
|
||||||
const sgTotal = parseInt(sgResult.rows[0].total);
|
|
||||||
const sgLinked = parseInt(sgResult.rows[0].linked);
|
|
||||||
const menuTotal = parseInt(menuResult.rows[0].total);
|
|
||||||
const menuLinked = parseInt(menuResult.rows[0].linked);
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenGroups: {
|
|
||||||
total: sgTotal,
|
|
||||||
linked: sgLinked,
|
|
||||||
unlinked: sgTotal - sgLinked,
|
|
||||||
},
|
|
||||||
menuItems: {
|
|
||||||
total: menuTotal,
|
|
||||||
linked: menuLinked,
|
|
||||||
unlinked: menuTotal - menuLinked,
|
|
||||||
},
|
|
||||||
potentialMatches: matchResult.rows.map((row: any) => ({
|
|
||||||
menuName: row.menu_name,
|
|
||||||
groupName: row.group_name,
|
|
||||||
similarity: 'exact',
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 전체 동기화 (모든 회사)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
interface AllCompaniesSyncResult {
|
|
||||||
success: boolean;
|
|
||||||
totalCompanies: number;
|
|
||||||
successCount: number;
|
|
||||||
failedCount: number;
|
|
||||||
results: Array<{
|
|
||||||
companyCode: string;
|
|
||||||
companyName: string;
|
|
||||||
direction: 'screens-to-menus' | 'menus-to-screens';
|
|
||||||
created: number;
|
|
||||||
linked: number;
|
|
||||||
skipped: number;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 회사에 대해 양방향 동기화 수행
|
|
||||||
*
|
|
||||||
* 로직:
|
|
||||||
* 1. 모든 회사 조회
|
|
||||||
* 2. 각 회사별로 양방향 동기화 수행
|
|
||||||
* - 화면관리 → 메뉴 동기화
|
|
||||||
* - 메뉴 → 화면관리 동기화
|
|
||||||
* 3. 결과 집계
|
|
||||||
*/
|
|
||||||
export async function syncAllCompanies(
|
|
||||||
userId: string
|
|
||||||
): Promise<AllCompaniesSyncResult> {
|
|
||||||
const result: AllCompaniesSyncResult = {
|
|
||||||
success: true,
|
|
||||||
totalCompanies: 0,
|
|
||||||
successCount: 0,
|
|
||||||
failedCount: 0,
|
|
||||||
results: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info("전체 동기화 시작", { userId });
|
|
||||||
|
|
||||||
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
|
|
||||||
const companiesQuery = `
|
|
||||||
SELECT company_code, company_name
|
|
||||||
FROM company_mng
|
|
||||||
WHERE company_code != '*'
|
|
||||||
ORDER BY company_name
|
|
||||||
`;
|
|
||||||
const companiesResult = await pool.query(companiesQuery);
|
|
||||||
|
|
||||||
result.totalCompanies = companiesResult.rows.length;
|
|
||||||
|
|
||||||
// 각 회사별로 양방향 동기화
|
|
||||||
for (const company of companiesResult.rows) {
|
|
||||||
const companyCode = company.company_code;
|
|
||||||
const companyName = company.company_name;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 화면관리 → 메뉴 동기화
|
|
||||||
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
|
|
||||||
result.results.push({
|
|
||||||
companyCode,
|
|
||||||
companyName,
|
|
||||||
direction: 'screens-to-menus',
|
|
||||||
created: screensToMenusResult.created,
|
|
||||||
linked: screensToMenusResult.linked,
|
|
||||||
skipped: screensToMenusResult.skipped,
|
|
||||||
success: screensToMenusResult.success,
|
|
||||||
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 메뉴 → 화면관리 동기화
|
|
||||||
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
|
|
||||||
result.results.push({
|
|
||||||
companyCode,
|
|
||||||
companyName,
|
|
||||||
direction: 'menus-to-screens',
|
|
||||||
created: menusToScreensResult.created,
|
|
||||||
linked: menusToScreensResult.linked,
|
|
||||||
skipped: menusToScreensResult.skipped,
|
|
||||||
success: menusToScreensResult.success,
|
|
||||||
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (screensToMenusResult.success && menusToScreensResult.success) {
|
|
||||||
result.successCount++;
|
|
||||||
} else {
|
|
||||||
result.failedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
|
|
||||||
result.results.push({
|
|
||||||
companyCode,
|
|
||||||
companyName,
|
|
||||||
direction: 'screens-to-menus',
|
|
||||||
created: 0,
|
|
||||||
linked: 0,
|
|
||||||
skipped: 0,
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
result.failedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("전체 동기화 완료", {
|
|
||||||
totalCompanies: result.totalCompanies,
|
|
||||||
successCount: result.successCount,
|
|
||||||
failedCount: result.failedCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("전체 동기화 실패", { error: error.message });
|
|
||||||
result.success = false;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -607,9 +607,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rule = result.rows[0];
|
const rule = result.rows[0];
|
||||||
|
|
||||||
|
|
@ -898,15 +896,14 @@ class NumberingRuleService {
|
||||||
|
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
|
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 4;
|
||||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
return String(nextSequence).padStart(length, "0");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 3;
|
const length = autoConfig.numberLength || 4;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
@ -959,15 +956,14 @@ class NumberingRuleService {
|
||||||
|
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (자동 증가 숫자 - 다음 번호 사용)
|
// 순번 (자동 증가 숫자)
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 4;
|
||||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
return String(nextSequence).padStart(length, "0");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 3;
|
const length = autoConfig.numberLength || 4;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,23 +234,10 @@ export class ReportService {
|
||||||
`;
|
`;
|
||||||
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
||||||
|
|
||||||
// 메뉴 매핑 조회
|
|
||||||
const menuMappingQuery = `
|
|
||||||
SELECT menu_objid
|
|
||||||
FROM report_menu_mapping
|
|
||||||
WHERE report_id = $1
|
|
||||||
ORDER BY created_at
|
|
||||||
`;
|
|
||||||
const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [
|
|
||||||
reportId,
|
|
||||||
]);
|
|
||||||
const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
report,
|
report,
|
||||||
layout,
|
layout,
|
||||||
queries: queries || [],
|
queries: queries || [],
|
||||||
menuObjids,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,12 +477,6 @@ export class ReportService {
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
|
|
||||||
const componentsData =
|
|
||||||
typeof originalLayout.components === "string"
|
|
||||||
? originalLayout.components
|
|
||||||
: JSON.stringify(originalLayout.components);
|
|
||||||
|
|
||||||
await client.query(copyLayoutQuery, [
|
await client.query(copyLayoutQuery, [
|
||||||
newLayoutId,
|
newLayoutId,
|
||||||
newReportId,
|
newReportId,
|
||||||
|
|
@ -506,7 +487,7 @@ export class ReportService {
|
||||||
originalLayout.margin_bottom,
|
originalLayout.margin_bottom,
|
||||||
originalLayout.margin_left,
|
originalLayout.margin_left,
|
||||||
originalLayout.margin_right,
|
originalLayout.margin_right,
|
||||||
componentsData,
|
JSON.stringify(originalLayout.components),
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -580,7 +561,7 @@ export class ReportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조
|
* 레이아웃 저장 (쿼리 포함)
|
||||||
*/
|
*/
|
||||||
async saveLayout(
|
async saveLayout(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
|
|
@ -588,19 +569,6 @@ export class ReportService {
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return transaction(async (client) => {
|
return transaction(async (client) => {
|
||||||
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
|
|
||||||
const firstPage = data.layoutConfig.pages[0];
|
|
||||||
const canvasWidth = firstPage?.width || 210;
|
|
||||||
const canvasHeight = firstPage?.height || 297;
|
|
||||||
const pageOrientation =
|
|
||||||
canvasWidth > canvasHeight ? "landscape" : "portrait";
|
|
||||||
const margins = firstPage?.margins || {
|
|
||||||
top: 20,
|
|
||||||
bottom: 20,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. 레이아웃 저장
|
// 1. 레이아웃 저장
|
||||||
const existingQuery = `
|
const existingQuery = `
|
||||||
SELECT layout_id FROM report_layout WHERE report_id = $1
|
SELECT layout_id FROM report_layout WHERE report_id = $1
|
||||||
|
|
@ -608,7 +576,7 @@ export class ReportService {
|
||||||
const existing = await client.query(existingQuery, [reportId]);
|
const existing = await client.query(existingQuery, [reportId]);
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
|
// 업데이트
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE report_layout
|
UPDATE report_layout
|
||||||
SET
|
SET
|
||||||
|
|
@ -626,14 +594,14 @@ export class ReportService {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await client.query(updateQuery, [
|
await client.query(updateQuery, [
|
||||||
canvasWidth,
|
data.canvasWidth,
|
||||||
canvasHeight,
|
data.canvasHeight,
|
||||||
pageOrientation,
|
data.pageOrientation,
|
||||||
margins.top,
|
data.marginTop,
|
||||||
margins.bottom,
|
data.marginBottom,
|
||||||
margins.left,
|
data.marginLeft,
|
||||||
margins.right,
|
data.marginRight,
|
||||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
JSON.stringify(data.components),
|
||||||
userId,
|
userId,
|
||||||
reportId,
|
reportId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -659,14 +627,14 @@ export class ReportService {
|
||||||
await client.query(insertQuery, [
|
await client.query(insertQuery, [
|
||||||
layoutId,
|
layoutId,
|
||||||
reportId,
|
reportId,
|
||||||
canvasWidth,
|
data.canvasWidth,
|
||||||
canvasHeight,
|
data.canvasHeight,
|
||||||
pageOrientation,
|
data.pageOrientation,
|
||||||
margins.top,
|
data.marginTop,
|
||||||
margins.bottom,
|
data.marginBottom,
|
||||||
margins.left,
|
data.marginLeft,
|
||||||
margins.right,
|
data.marginRight,
|
||||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
JSON.stringify(data.components),
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -709,43 +677,6 @@ export class ReportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 메뉴 매핑 저장 (있는 경우)
|
|
||||||
if (data.menuObjids !== undefined) {
|
|
||||||
// 기존 메뉴 매핑 모두 삭제
|
|
||||||
await client.query(
|
|
||||||
`DELETE FROM report_menu_mapping WHERE report_id = $1`,
|
|
||||||
[reportId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 새 메뉴 매핑 삽입
|
|
||||||
if (data.menuObjids.length > 0) {
|
|
||||||
// 리포트의 company_code 조회
|
|
||||||
const reportResult = await client.query(
|
|
||||||
`SELECT company_code FROM report_master WHERE report_id = $1`,
|
|
||||||
[reportId]
|
|
||||||
);
|
|
||||||
const companyCode = reportResult.rows[0]?.company_code || "*";
|
|
||||||
|
|
||||||
const insertMappingSql = `
|
|
||||||
INSERT INTO report_menu_mapping (
|
|
||||||
report_id,
|
|
||||||
menu_objid,
|
|
||||||
company_code,
|
|
||||||
created_by
|
|
||||||
) VALUES ($1, $2, $3, $4)
|
|
||||||
`;
|
|
||||||
|
|
||||||
for (const menuObjid of data.menuObjids) {
|
|
||||||
await client.query(insertMappingSql, [
|
|
||||||
reportId,
|
|
||||||
menuObjid,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,24 +47,9 @@ export class RiskAlertService {
|
||||||
|
|
||||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||||
|
|
||||||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
||||||
const iconv = require('iconv-lite');
|
const iconv = require('iconv-lite');
|
||||||
const buffer = Buffer.from(warningResponse.data);
|
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
||||||
|
|
||||||
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
|
||||||
let responseText: string;
|
|
||||||
const utf8Text = buffer.toString('utf-8');
|
|
||||||
|
|
||||||
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
|
||||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
|
||||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
|
||||||
responseText = utf8Text;
|
|
||||||
console.log('📝 UTF-8 인코딩으로 디코딩');
|
|
||||||
} else {
|
|
||||||
// EUC-KR로 디코딩
|
|
||||||
responseText = iconv.decode(buffer, 'EUC-KR');
|
|
||||||
console.log('📝 EUC-KR 인코딩으로 디코딩');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||||
const lines = responseText.split('\n');
|
const lines = responseText.split('\n');
|
||||||
|
|
|
||||||
|
|
@ -326,19 +326,7 @@ export class ScreenManagementService {
|
||||||
*/
|
*/
|
||||||
async updateScreenInfo(
|
async updateScreenInfo(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
updateData: {
|
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
|
||||||
screenName: string;
|
|
||||||
tableName?: string;
|
|
||||||
description?: string;
|
|
||||||
isActive: string;
|
|
||||||
// REST API 관련 필드 추가
|
|
||||||
dataSourceType?: string;
|
|
||||||
dbSourceType?: string;
|
|
||||||
dbConnectionId?: number;
|
|
||||||
restApiConnectionId?: number;
|
|
||||||
restApiEndpoint?: string;
|
|
||||||
restApiJsonPath?: string;
|
|
||||||
},
|
|
||||||
userCompanyCode: string
|
userCompanyCode: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
|
|
@ -360,43 +348,24 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 정보 업데이트 (REST API 필드 포함)
|
// 화면 정보 업데이트 (tableName 포함)
|
||||||
await query(
|
await query(
|
||||||
`UPDATE screen_definitions
|
`UPDATE screen_definitions
|
||||||
SET screen_name = $1,
|
SET screen_name = $1,
|
||||||
table_name = $2,
|
table_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
is_active = $4,
|
is_active = $4,
|
||||||
updated_date = $5,
|
updated_date = $5
|
||||||
data_source_type = $6,
|
WHERE screen_id = $6`,
|
||||||
db_source_type = $7,
|
|
||||||
db_connection_id = $8,
|
|
||||||
rest_api_connection_id = $9,
|
|
||||||
rest_api_endpoint = $10,
|
|
||||||
rest_api_json_path = $11
|
|
||||||
WHERE screen_id = $12`,
|
|
||||||
[
|
[
|
||||||
updateData.screenName,
|
updateData.screenName,
|
||||||
updateData.tableName || null,
|
updateData.tableName || null,
|
||||||
updateData.description || null,
|
updateData.description || null,
|
||||||
updateData.isActive,
|
updateData.isActive,
|
||||||
new Date(),
|
new Date(),
|
||||||
updateData.dataSourceType || "database",
|
|
||||||
updateData.dbSourceType || "internal",
|
|
||||||
updateData.dbConnectionId || null,
|
|
||||||
updateData.restApiConnectionId || null,
|
|
||||||
updateData.restApiEndpoint || null,
|
|
||||||
updateData.restApiJsonPath || null,
|
|
||||||
screenId,
|
screenId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
|
|
||||||
dataSourceType: updateData.dataSourceType,
|
|
||||||
restApiConnectionId: updateData.restApiConnectionId,
|
|
||||||
restApiEndpoint: updateData.restApiEndpoint,
|
|
||||||
restApiJsonPath: updateData.restApiJsonPath,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -892,134 +861,6 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 활성 화면 일괄 삭제 (휴지통으로 이동)
|
|
||||||
*/
|
|
||||||
async bulkDeleteScreens(
|
|
||||||
screenIds: number[],
|
|
||||||
userCompanyCode: string,
|
|
||||||
deletedBy: string,
|
|
||||||
deleteReason?: string,
|
|
||||||
force: boolean = false
|
|
||||||
): Promise<{
|
|
||||||
deletedCount: number;
|
|
||||||
skippedCount: number;
|
|
||||||
errors: Array<{ screenId: number; error: string }>;
|
|
||||||
}> {
|
|
||||||
if (screenIds.length === 0) {
|
|
||||||
throw new Error("삭제할 화면을 선택해주세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let deletedCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
const errors: Array<{ screenId: number; error: string }> = [];
|
|
||||||
|
|
||||||
// 각 화면을 개별적으로 삭제 처리
|
|
||||||
for (const screenId of screenIds) {
|
|
||||||
try {
|
|
||||||
// 권한 확인 (Raw Query)
|
|
||||||
const existingResult = await query<{
|
|
||||||
company_code: string | null;
|
|
||||||
is_active: string;
|
|
||||||
screen_name: string;
|
|
||||||
}>(
|
|
||||||
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
|
||||||
[screenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingResult.length === 0) {
|
|
||||||
skippedCount++;
|
|
||||||
errors.push({
|
|
||||||
screenId,
|
|
||||||
error: "화면을 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingScreen = existingResult[0];
|
|
||||||
|
|
||||||
// 권한 확인
|
|
||||||
if (
|
|
||||||
userCompanyCode !== "*" &&
|
|
||||||
existingScreen.company_code !== userCompanyCode
|
|
||||||
) {
|
|
||||||
skippedCount++;
|
|
||||||
errors.push({
|
|
||||||
screenId,
|
|
||||||
error: "이 화면을 삭제할 권한이 없습니다.",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 삭제된 화면인지 확인
|
|
||||||
if (existingScreen.is_active === "D") {
|
|
||||||
skippedCount++;
|
|
||||||
errors.push({
|
|
||||||
screenId,
|
|
||||||
error: "이미 삭제된 화면입니다.",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 강제 삭제가 아닌 경우 의존성 체크
|
|
||||||
if (!force) {
|
|
||||||
const dependencyCheck = await this.checkScreenDependencies(
|
|
||||||
screenId,
|
|
||||||
userCompanyCode
|
|
||||||
);
|
|
||||||
if (dependencyCheck.hasDependencies) {
|
|
||||||
skippedCount++;
|
|
||||||
errors.push({
|
|
||||||
screenId,
|
|
||||||
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
|
|
||||||
await transaction(async (client) => {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// 소프트 삭제 (휴지통으로 이동)
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_definitions
|
|
||||||
SET is_active = 'D',
|
|
||||||
deleted_date = $1,
|
|
||||||
deleted_by = $2,
|
|
||||||
delete_reason = $3,
|
|
||||||
updated_date = $4,
|
|
||||||
updated_by = $5
|
|
||||||
WHERE screen_id = $6`,
|
|
||||||
[now, deletedBy, deleteReason || null, now, deletedBy, screenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
|
|
||||||
await client.query(
|
|
||||||
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
|
|
||||||
[screenId]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
deletedCount++;
|
|
||||||
logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`);
|
|
||||||
} catch (error) {
|
|
||||||
skippedCount++;
|
|
||||||
errors.push({
|
|
||||||
screenId,
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
logger.error(`화면 삭제 실패: ${screenId}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`
|
|
||||||
);
|
|
||||||
|
|
||||||
return { deletedCount, skippedCount, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴지통 화면 일괄 영구 삭제
|
* 휴지통 화면 일괄 영구 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1645,23 +1486,11 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
|
|
||||||
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
|
|
||||||
|
|
||||||
const components: ComponentData[] = componentLayouts.map((layout) => {
|
const components: ComponentData[] = componentLayouts.map((layout) => {
|
||||||
const properties = layout.properties as any;
|
const properties = layout.properties as any;
|
||||||
|
|
||||||
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
|
|
||||||
const tableName = properties?.tableName;
|
|
||||||
const columnName = properties?.columnName;
|
|
||||||
const latestTypeInfo = tableName && columnName
|
|
||||||
? inputTypeMap.get(`${tableName}.${columnName}`)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const component = {
|
const component = {
|
||||||
id: layout.component_id,
|
id: layout.component_id,
|
||||||
// 🔥 최신 componentType이 있으면 type 덮어쓰기
|
type: layout.component_type as any,
|
||||||
type: latestTypeInfo?.componentType || layout.component_type as any,
|
|
||||||
position: {
|
position: {
|
||||||
x: layout.position_x,
|
x: layout.position_x,
|
||||||
y: layout.position_y,
|
y: layout.position_y,
|
||||||
|
|
@ -1670,17 +1499,6 @@ export class ScreenManagementService {
|
||||||
size: { width: layout.width, height: layout.height },
|
size: { width: layout.width, height: layout.height },
|
||||||
parentId: layout.parent_id,
|
parentId: layout.parent_id,
|
||||||
...properties,
|
...properties,
|
||||||
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
|
|
||||||
...(latestTypeInfo && {
|
|
||||||
widgetType: latestTypeInfo.inputType,
|
|
||||||
inputType: latestTypeInfo.inputType,
|
|
||||||
componentType: latestTypeInfo.componentType,
|
|
||||||
componentConfig: {
|
|
||||||
...properties?.componentConfig,
|
|
||||||
type: latestTypeInfo.componentType,
|
|
||||||
inputType: latestTypeInfo.inputType,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`로드된 컴포넌트:`, {
|
console.log(`로드된 컴포넌트:`, {
|
||||||
|
|
@ -1690,9 +1508,6 @@ export class ScreenManagementService {
|
||||||
size: component.size,
|
size: component.size,
|
||||||
parentId: component.parentId,
|
parentId: component.parentId,
|
||||||
title: (component as any).title,
|
title: (component as any).title,
|
||||||
widgetType: (component as any).widgetType,
|
|
||||||
componentType: (component as any).componentType,
|
|
||||||
latestTypeInfo,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
|
|
@ -1712,112 +1527,6 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
|
||||||
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
|
||||||
*/
|
|
||||||
private getComponentIdFromInputType(inputType: string): string {
|
|
||||||
const mapping: Record<string, string> = {
|
|
||||||
// 텍스트 입력
|
|
||||||
text: "text-input",
|
|
||||||
email: "text-input",
|
|
||||||
password: "text-input",
|
|
||||||
tel: "text-input",
|
|
||||||
// 숫자 입력
|
|
||||||
number: "number-input",
|
|
||||||
decimal: "number-input",
|
|
||||||
// 날짜/시간
|
|
||||||
date: "date-input",
|
|
||||||
datetime: "date-input",
|
|
||||||
time: "date-input",
|
|
||||||
// 텍스트 영역
|
|
||||||
textarea: "textarea-basic",
|
|
||||||
// 선택
|
|
||||||
select: "select-basic",
|
|
||||||
dropdown: "select-basic",
|
|
||||||
// 체크박스/라디오
|
|
||||||
checkbox: "checkbox-basic",
|
|
||||||
radio: "radio-basic",
|
|
||||||
boolean: "toggle-switch",
|
|
||||||
// 파일
|
|
||||||
file: "file-upload",
|
|
||||||
// 이미지
|
|
||||||
image: "image-widget",
|
|
||||||
img: "image-widget",
|
|
||||||
picture: "image-widget",
|
|
||||||
photo: "image-widget",
|
|
||||||
// 버튼
|
|
||||||
button: "button-primary",
|
|
||||||
// 기타
|
|
||||||
label: "text-display",
|
|
||||||
code: "select-basic",
|
|
||||||
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
|
|
||||||
category: "select-basic",
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapping[inputType] || "text-input";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트들의 최신 inputType 정보 조회
|
|
||||||
* @param layouts - 레이아웃 목록
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
* @returns Map<"tableName.columnName", { inputType, componentType }>
|
|
||||||
*/
|
|
||||||
private async getLatestInputTypes(
|
|
||||||
layouts: any[],
|
|
||||||
companyCode: string
|
|
||||||
): Promise<Map<string, { inputType: string; componentType: string }>> {
|
|
||||||
const inputTypeMap = new Map<string, { inputType: string; componentType: string }>();
|
|
||||||
|
|
||||||
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
|
|
||||||
const tableColumnPairs = new Set<string>();
|
|
||||||
for (const layout of layouts) {
|
|
||||||
const properties = layout.properties as any;
|
|
||||||
if (properties?.tableName && properties?.columnName) {
|
|
||||||
tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tableColumnPairs.size === 0) {
|
|
||||||
return inputTypeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
|
|
||||||
const pairs = Array.from(tableColumnPairs).map(pair => {
|
|
||||||
const [tableName, columnName] = pair.split('|');
|
|
||||||
return { tableName, columnName };
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배치 쿼리로 한 번에 조회
|
|
||||||
const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ');
|
|
||||||
const params = pairs.flatMap(p => [p.tableName, p.columnName]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await query<{ table_name: string; column_name: string; input_type: string }>(
|
|
||||||
`SELECT table_name, column_name, input_type
|
|
||||||
FROM table_type_columns
|
|
||||||
WHERE (table_name, column_name) IN (${placeholders})
|
|
||||||
AND company_code = $${params.length + 1}`,
|
|
||||||
[...params, companyCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of results) {
|
|
||||||
const componentType = this.getComponentIdFromInputType(row.input_type);
|
|
||||||
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
|
|
||||||
inputType: row.input_type,
|
|
||||||
componentType: componentType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`최신 inputType 조회 완료: ${results.length}개`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputTypeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 템플릿 관리
|
// 템플릿 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -2307,40 +2016,37 @@ export class ScreenManagementService {
|
||||||
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
|
// 해당 회사의 기존 화면 코드들 조회
|
||||||
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
|
|
||||||
const existingScreens = await client.query<{ screen_code: string }>(
|
const existingScreens = await client.query<{ screen_code: string }>(
|
||||||
`SELECT screen_code FROM screen_definitions
|
`SELECT screen_code FROM screen_definitions
|
||||||
WHERE screen_code LIKE $1
|
WHERE company_code = $1 AND screen_code LIKE $2
|
||||||
ORDER BY screen_code DESC`,
|
ORDER BY screen_code DESC
|
||||||
[`${companyCode}_%`]
|
LIMIT 10`,
|
||||||
|
[companyCode, `${companyCode}%`]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
const pattern = new RegExp(
|
const pattern = new RegExp(
|
||||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`
|
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`);
|
|
||||||
console.log(`🔍 패턴: ${pattern}`);
|
|
||||||
|
|
||||||
for (const screen of existingScreens.rows) {
|
for (const screen of existingScreens.rows) {
|
||||||
const match = screen.screen_code.match(pattern);
|
const match = screen.screen_code.match(pattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
const number = parseInt(match[1], 10);
|
const number = parseInt(match[1], 10);
|
||||||
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
|
|
||||||
if (number > maxNumber) {
|
if (number > maxNumber) {
|
||||||
maxNumber = number;
|
maxNumber = number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다음 순번으로 화면 코드 생성
|
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||||
const nextNumber = maxNumber + 1;
|
const nextNumber = maxNumber + 1;
|
||||||
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
|
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||||
const newCode = `${companyCode}_${nextNumber}`;
|
|
||||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
|
const newCode = `${companyCode}_${paddedNumber}`;
|
||||||
|
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
||||||
|
|
||||||
return newCode;
|
return newCode;
|
||||||
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||||
|
|
@ -2360,32 +2066,29 @@ export class ScreenManagementService {
|
||||||
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
|
// 현재 최대 번호 조회
|
||||||
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
|
const existingScreens = await client.query<{ screen_code: string }>(
|
||||||
const existingScreens = await client.query<{ screen_code: string; num: number }>(
|
`SELECT screen_code FROM screen_definitions
|
||||||
`SELECT screen_code,
|
WHERE company_code = $1 AND screen_code LIKE $2
|
||||||
COALESCE(
|
ORDER BY screen_code DESC
|
||||||
NULLIF(
|
LIMIT 10`,
|
||||||
regexp_replace(screen_code, $2, '\\1'),
|
[companyCode, `${companyCode}%`]
|
||||||
screen_code
|
|
||||||
)::integer,
|
|
||||||
0
|
|
||||||
) as num
|
|
||||||
FROM screen_definitions
|
|
||||||
WHERE company_code = $1
|
|
||||||
AND screen_code ~ $2
|
|
||||||
AND deleted_date IS NULL
|
|
||||||
ORDER BY num DESC
|
|
||||||
LIMIT 1`,
|
|
||||||
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
|
const pattern = new RegExp(
|
||||||
maxNumber = existingScreens.rows[0].num;
|
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||||
}
|
);
|
||||||
|
|
||||||
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`);
|
for (const screen of existingScreens.rows) {
|
||||||
|
const match = screen.screen_code.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const number = parseInt(match[1], 10);
|
||||||
|
if (number > maxNumber) {
|
||||||
|
maxNumber = number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// count개의 코드를 순차적으로 생성
|
// count개의 코드를 순차적으로 생성
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
|
|
@ -2597,10 +2300,10 @@ export class ScreenManagementService {
|
||||||
// 없으면 원본과 같은 회사에 복사
|
// 없으면 원본과 같은 회사에 복사
|
||||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||||
|
|
||||||
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
|
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
||||||
const existingScreens = await client.query<any>(
|
const existingScreens = await client.query<any>(
|
||||||
`SELECT screen_id FROM screen_definitions
|
`SELECT screen_id FROM screen_definitions
|
||||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
WHERE screen_code = $1 AND company_code = $2
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[copyData.screenCode, targetCompanyCode]
|
[copyData.screenCode, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,82 +79,6 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
|
||||||
* 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다.
|
|
||||||
*/
|
|
||||||
async getAllCategoryColumns(
|
|
||||||
companyCode: string
|
|
||||||
): Promise<CategoryColumn[]> {
|
|
||||||
try {
|
|
||||||
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
tc.table_name AS "tableName",
|
|
||||||
tc.column_name AS "columnName",
|
|
||||||
tc.column_name AS "columnLabel",
|
|
||||||
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
|
||||||
FROM table_type_columns
|
|
||||||
WHERE input_type = 'category'
|
|
||||||
GROUP BY table_name, column_name
|
|
||||||
) tc
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT table_name, column_name, COUNT(*) as cnt
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE is_active = true
|
|
||||||
GROUP BY table_name, column_name
|
|
||||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
|
||||||
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
|
||||||
`;
|
|
||||||
params = [];
|
|
||||||
} else {
|
|
||||||
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
|
|
||||||
query = `
|
|
||||||
SELECT
|
|
||||||
tc.table_name AS "tableName",
|
|
||||||
tc.column_name AS "columnName",
|
|
||||||
tc.column_name AS "columnLabel",
|
|
||||||
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
|
||||||
FROM table_type_columns
|
|
||||||
WHERE input_type = 'category'
|
|
||||||
GROUP BY table_name, column_name
|
|
||||||
) tc
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT table_name, column_name, COUNT(*) as cnt
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE is_active = true AND company_code = $1
|
|
||||||
GROUP BY table_name, column_name
|
|
||||||
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
|
||||||
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
|
||||||
`;
|
|
||||||
params = [companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
|
|
||||||
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
|
||||||
companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
*
|
*
|
||||||
|
|
@ -187,11 +111,14 @@ class TableCategoryValueService {
|
||||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
const baseSelect = `
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 카테고리 값 조회
|
||||||
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
value_id AS "valueId",
|
value_id AS "valueId",
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
|
|
@ -216,39 +143,39 @@ class TableCategoryValueService {
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
|
||||||
if (menuObjid && siblingObjids.length > 0) {
|
|
||||||
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
|
||||||
params = [tableName, columnName, siblingObjids];
|
|
||||||
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
|
||||||
} else if (menuObjid) {
|
|
||||||
query = baseSelect + ` AND menu_objid = $3`;
|
|
||||||
params = [tableName, columnName, menuObjid];
|
|
||||||
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
|
||||||
} else {
|
|
||||||
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
|
||||||
query = baseSelect;
|
|
||||||
params = [tableName, columnName];
|
params = [tableName, columnName];
|
||||||
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
logger.info("최고 관리자 카테고리 값 조회");
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
// 일반 회사: 자신의 카테고리 값만 조회
|
||||||
if (menuObjid && siblingObjids.length > 0) {
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
query = `
|
||||||
params = [tableName, columnName, companyCode, siblingObjids];
|
SELECT
|
||||||
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
value_id AS "valueId",
|
||||||
} else if (menuObjid) {
|
table_name AS "tableName",
|
||||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
column_name AS "columnName",
|
||||||
params = [tableName, columnName, companyCode, menuObjid];
|
value_code AS "valueCode",
|
||||||
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
value_label AS "valueLabel",
|
||||||
} else {
|
value_order AS "valueOrder",
|
||||||
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
parent_value_id AS "parentValueId",
|
||||||
query = baseSelect + ` AND company_code = $3`;
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt",
|
||||||
|
created_by AS "createdBy",
|
||||||
|
updated_by AS "updatedBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND company_code = $3
|
||||||
|
`;
|
||||||
params = [tableName, columnName, companyCode];
|
params = [tableName, columnName, companyCode];
|
||||||
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
logger.info("회사별 카테고리 값 조회", { companyCode });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeInactive) {
|
if (!includeInactive) {
|
||||||
|
|
@ -1331,284 +1258,6 @@ class TableCategoryValueService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리 코드로 라벨 조회
|
|
||||||
*
|
|
||||||
* @param valueCodes - 카테고리 코드 배열
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
* @returns { [code]: label } 형태의 매핑 객체
|
|
||||||
*/
|
|
||||||
async getCategoryLabelsByCodes(
|
|
||||||
valueCodes: string[],
|
|
||||||
companyCode: string
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
try {
|
|
||||||
if (!valueCodes || valueCodes.length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
// 동적으로 파라미터 플레이스홀더 생성
|
|
||||||
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
// 최고 관리자: 모든 카테고리 값 조회
|
|
||||||
query = `
|
|
||||||
SELECT value_code, value_label
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE value_code IN (${placeholders})
|
|
||||||
AND is_active = true
|
|
||||||
`;
|
|
||||||
params = valueCodes;
|
|
||||||
} else {
|
|
||||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
|
||||||
query = `
|
|
||||||
SELECT value_code, value_label
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE value_code IN (${placeholders})
|
|
||||||
AND is_active = true
|
|
||||||
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
|
||||||
`;
|
|
||||||
params = [...valueCodes, companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
|
|
||||||
// { [code]: label } 형태로 변환
|
|
||||||
const labels: Record<string, string> = {};
|
|
||||||
for (const row of result.rows) {
|
|
||||||
labels[row.value_code] = row.value_label;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
|
||||||
|
|
||||||
return labels;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
|
|
||||||
*
|
|
||||||
* 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
|
|
||||||
*
|
|
||||||
* @param tableName - 테이블명
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
* @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
|
|
||||||
*/
|
|
||||||
async getCategoryLabelToCodeMapping(
|
|
||||||
tableName: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<Record<string, Record<string, string>>> {
|
|
||||||
try {
|
|
||||||
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
|
|
||||||
const categoryColumnsQuery = `
|
|
||||||
SELECT column_name
|
|
||||||
FROM table_type_columns
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND input_type = 'category'
|
|
||||||
`;
|
|
||||||
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
|
|
||||||
|
|
||||||
if (categoryColumnsResult.rows.length === 0) {
|
|
||||||
logger.info("카테고리 타입 컬럼 없음", { tableName });
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
|
|
||||||
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
|
|
||||||
|
|
||||||
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
|
|
||||||
const result: Record<string, Record<string, string>> = {};
|
|
||||||
|
|
||||||
for (const columnName of categoryColumns) {
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
|
||||||
// 최고 관리자: 모든 카테고리 값 조회
|
|
||||||
query = `
|
|
||||||
SELECT value_code, value_label
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
AND is_active = true
|
|
||||||
`;
|
|
||||||
params = [tableName, columnName];
|
|
||||||
} else {
|
|
||||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
|
||||||
query = `
|
|
||||||
SELECT value_code, value_label
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
AND is_active = true
|
|
||||||
AND (company_code = $3 OR company_code = '*')
|
|
||||||
`;
|
|
||||||
params = [tableName, columnName, companyCode];
|
|
||||||
}
|
|
||||||
|
|
||||||
const valuesResult = await pool.query(query, params);
|
|
||||||
|
|
||||||
// { [label]: code } 형태로 변환
|
|
||||||
const labelToCodeMap: Record<string, string> = {};
|
|
||||||
for (const row of valuesResult.rows) {
|
|
||||||
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
|
|
||||||
labelToCodeMap[row.value_label] = row.value_code;
|
|
||||||
// 소문자 키도 추가 (대소문자 무시 검색용)
|
|
||||||
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(labelToCodeMap).length > 0) {
|
|
||||||
result[columnName] = labelToCodeMap;
|
|
||||||
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
|
|
||||||
tableName,
|
|
||||||
columnCount: Object.keys(result).length
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터의 카테고리 라벨 값을 코드 값으로 변환
|
|
||||||
*
|
|
||||||
* 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
|
|
||||||
*
|
|
||||||
* @param tableName - 테이블명
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
* @param data - 변환할 데이터 객체
|
|
||||||
* @returns 라벨이 코드로 변환된 데이터 객체
|
|
||||||
*/
|
|
||||||
async convertCategoryLabelsToCodesForData(
|
|
||||||
tableName: string,
|
|
||||||
companyCode: string,
|
|
||||||
data: Record<string, any>
|
|
||||||
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
|
|
||||||
try {
|
|
||||||
// 라벨→코드 매핑 조회
|
|
||||||
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
|
|
||||||
|
|
||||||
if (Object.keys(labelToCodeMapping).length === 0) {
|
|
||||||
// 카테고리 컬럼 없음
|
|
||||||
return { convertedData: data, conversions: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertedData = { ...data };
|
|
||||||
const conversions: Array<{ column: string; label: string; code: string }> = [];
|
|
||||||
|
|
||||||
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
|
|
||||||
const value = data[columnName];
|
|
||||||
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
const stringValue = String(value).trim();
|
|
||||||
|
|
||||||
// 다중 값 확인 (쉼표로 구분된 경우)
|
|
||||||
if (stringValue.includes(",")) {
|
|
||||||
// 다중 카테고리 값 처리
|
|
||||||
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
|
|
||||||
const convertedCodes: string[] = [];
|
|
||||||
let allConverted = true;
|
|
||||||
|
|
||||||
for (const label of labels) {
|
|
||||||
// 정확한 라벨 매칭 시도
|
|
||||||
let matchedCode = labelCodeMap[label];
|
|
||||||
|
|
||||||
// 대소문자 무시 매칭
|
|
||||||
if (!matchedCode) {
|
|
||||||
matchedCode = labelCodeMap[label.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedCode) {
|
|
||||||
convertedCodes.push(matchedCode);
|
|
||||||
conversions.push({
|
|
||||||
column: columnName,
|
|
||||||
label: label,
|
|
||||||
code: matchedCode,
|
|
||||||
});
|
|
||||||
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
|
|
||||||
} else {
|
|
||||||
// 이미 코드값인지 확인
|
|
||||||
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
|
|
||||||
if (isAlreadyCode) {
|
|
||||||
// 이미 코드값이면 그대로 사용
|
|
||||||
convertedCodes.push(label);
|
|
||||||
} else {
|
|
||||||
// 라벨도 코드도 아니면 원래 값 유지
|
|
||||||
convertedCodes.push(label);
|
|
||||||
allConverted = false;
|
|
||||||
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 변환된 코드들을 쉼표로 합쳐서 저장
|
|
||||||
convertedData[columnName] = convertedCodes.join(",");
|
|
||||||
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
|
|
||||||
} else {
|
|
||||||
// 단일 값 처리
|
|
||||||
// 정확한 라벨 매칭 시도
|
|
||||||
let matchedCode = labelCodeMap[stringValue];
|
|
||||||
|
|
||||||
// 대소문자 무시 매칭
|
|
||||||
if (!matchedCode) {
|
|
||||||
matchedCode = labelCodeMap[stringValue.toLowerCase()];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedCode) {
|
|
||||||
// 라벨 값을 코드 값으로 변환
|
|
||||||
convertedData[columnName] = matchedCode;
|
|
||||||
conversions.push({
|
|
||||||
column: columnName,
|
|
||||||
label: stringValue,
|
|
||||||
code: matchedCode,
|
|
||||||
});
|
|
||||||
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
|
|
||||||
} else {
|
|
||||||
// 이미 코드값인지 확인 (역방향 확인)
|
|
||||||
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
|
|
||||||
if (!isAlreadyCode) {
|
|
||||||
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
|
|
||||||
}
|
|
||||||
// 변환 없이 원래 값 유지
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`카테고리 라벨→코드 변환 완료`, {
|
|
||||||
tableName,
|
|
||||||
conversionCount: conversions.length,
|
|
||||||
conversions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { convertedData, conversions };
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
|
|
||||||
// 실패 시 원본 데이터 반환
|
|
||||||
return { convertedData: data, conversions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TableCategoryValueService();
|
export default new TableCategoryValueService();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue