Compare commits
32 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e6760329bc | |
|
|
2ae43c879f | |
|
|
b11f02aceb | |
|
|
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/ ./
|
||||
|
||||
# Next.js 프로덕션 빌드 (린트 비활성화)
|
||||
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
|
||||
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/next.config.mjs ./frontend/next.config.mjs
|
||||
|
||||
# 업로드 디렉토리 생성 (백엔드용)
|
||||
RUN mkdir -p /app/backend/uploads && \
|
||||
chown -R nodejs:nodejs /app/backend/uploads
|
||||
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
|
||||
# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
|
||||
# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
|
||||
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 && \
|
||||
|
|
@ -77,29 +89,44 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
|
|||
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/backend' >> /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 '' >> /app/start.sh && \
|
||||
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
|
||||
echo 'cd /app/frontend' >> /app/start.sh && \
|
||||
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
|
||||
echo '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 && \
|
||||
echo 'PORT=3000 exec npm start' >> /app/start.sh && \
|
||||
chmod +x /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
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000 8080
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
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"]
|
||||
|
|
|
|||
70
PLAN.MD
70
PLAN.MD
|
|
@ -1,72 +1,4 @@
|
|||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
||||
|
||||
## 개요
|
||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
### 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 지원)
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
현재 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",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -37,12 +34,11 @@
|
|||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
@ -1044,7 +1040,6 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2261,93 +2256,6 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
|
||||
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
|
||||
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
|
||||
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.3",
|
||||
"@oozcitak/util": "1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
|
||||
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
|
||||
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
|
||||
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
|
||||
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
|
|
@ -2372,7 +2280,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3217,16 +3124,6 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bwip-js": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
|
||||
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
|
|
@ -3476,7 +3373,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3713,7 +3609,6 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3931,7 +3826,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4432,12 +4326,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-split": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
|
||||
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||
|
|
@ -4458,7 +4346,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -4558,15 +4445,6 @@
|
|||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bwip-js": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
|
||||
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bwip-js": "bin/bwip-js.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
|
@ -4643,15 +4521,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001745",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
||||
|
|
@ -5333,56 +5202,6 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
|
||||
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.1",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "24.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
|
|
@ -5397,11 +5216,6 @@
|
|||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
|
|
@ -5535,27 +5349,6 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ent": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"es-errors": "^1.3.0",
|
||||
"punycode": "^1.4.1",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ent/node_modules/punycode": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
|
@ -5568,16 +5361,6 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
|
||||
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"string-template": "~0.2.0",
|
||||
"xtend": "~4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
|
|
@ -5669,7 +5452,6 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -5861,14 +5643,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ev-store": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
|
||||
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
|
||||
"dependencies": {
|
||||
"individual": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
|
|
@ -6505,16 +6279,6 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/global": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "^0.11.10"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
|
|
@ -6649,16 +6413,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -6689,22 +6443,6 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
|
|
@ -6712,27 +6450,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-docx": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
|
||||
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.6",
|
||||
"@oozcitak/util": "8.3.4",
|
||||
"color-name": "^1.1.4",
|
||||
"html-entities": "^2.3.3",
|
||||
"html-to-vdom": "^0.7.0",
|
||||
"image-size": "^1.0.0",
|
||||
"image-to-base64": "^2.2.0",
|
||||
"jszip": "^3.7.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^3.1.25",
|
||||
"virtual-dom": "^2.1.1",
|
||||
"xmlbuilder2": "2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -6749,106 +6466,6 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
|
||||
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ent": "^2.0.0",
|
||||
"htmlparser2": "^3.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
|
||||
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domelementtype": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domhandler": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domutils": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "0",
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/entities": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/htmlparser2": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^1.3.1",
|
||||
"domhandler": "^2.3.0",
|
||||
"domutils": "^1.5.1",
|
||||
"entities": "^1.1.1",
|
||||
"inherits": "^2.0.1",
|
||||
"readable-stream": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
|
|
@ -6973,30 +6590,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/image-to-base64": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
|
||||
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
|
|
@ -7033,12 +6626,6 @@
|
|||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -7086,11 +6673,6 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/individual": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
|
||||
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
|
@ -7272,15 +6854,6 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-object": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
|
|
@ -7432,7 +7005,6 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8124,18 +7696,6 @@
|
|||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
|
|
@ -8252,15 +7812,6 @@
|
|||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -8402,6 +7953,7 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -8625,21 +8177,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
|
|
@ -8763,24 +8300,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/native-duplexpair": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||
|
|
@ -8810,12 +8329,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
|
||||
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
|
|
@ -9157,12 +8670,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
|
|
@ -9290,7 +8797,6 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -9673,15 +9179,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -10098,23 +9595,6 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
|
|
@ -10130,17 +9610,12 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10269,12 +9744,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -10551,11 +10020,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-template": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
|
||||
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
|
@ -10949,7 +10413,6 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11055,7 +10518,6 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -11180,16 +10642,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
|
|
@ -11223,22 +10685,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/virtual-dom": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
|
||||
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browser-split": "0.0.1",
|
||||
"error": "^4.3.0",
|
||||
"ev-store": "^7.0.0",
|
||||
"global": "^4.3.0",
|
||||
"is-object": "^1.0.1",
|
||||
"next-tick": "^0.2.2",
|
||||
"x-is-array": "0.1.0",
|
||||
"x-is-string": "0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
|
@ -11416,80 +10862,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/x-is-array": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
|
||||
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
|
||||
},
|
||||
"node_modules/x-is-string": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
|
||||
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
|
||||
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.5",
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/util": "8.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
|
||||
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
|
||||
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -26,15 +26,12 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -51,12 +48,11 @@
|
|||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import path from "path";
|
|||
import config from "./config/environment";
|
||||
import { logger } from "./utils/logger";
|
||||
import { errorHandler } from "./middleware/errorHandler";
|
||||
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||
|
||||
// 라우터 임포트
|
||||
import authRoutes from "./routes/authRoutes";
|
||||
|
|
@ -58,7 +57,6 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
|||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
|
|
@ -72,17 +70,8 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
|
|||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -177,10 +166,6 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use("/api/", limiter);
|
||||
|
||||
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||
app.use("/api/", refreshTokenIfNeeded);
|
||||
|
||||
// 헬스 체크 엔드포인트
|
||||
app.get("/health", (req, res) => {
|
||||
res.status(200).json({
|
||||
|
|
@ -198,7 +183,6 @@ app.use("/api/multilang", multilangRoutes);
|
|||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -223,7 +207,6 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
|||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
|
|
@ -253,16 +236,8 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
|||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -632,9 +632,6 @@ export class DashboardController {
|
|||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 연결 정보 (응답에 포함용)
|
||||
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
|
|
@ -655,11 +652,6 @@ export class DashboardController {
|
|||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 연결 정보 저장 (응답에 포함)
|
||||
connectionInfo = {
|
||||
saveToHistory: connection.save_to_history === "Y",
|
||||
};
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
|
|
@ -702,15 +694,6 @@ export class DashboardController {
|
|||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// 디버깅 로그: 실제 요청 정보 출력
|
||||
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
||||
url: requestConfig.url,
|
||||
method: requestConfig.method,
|
||||
headers: requestConfig.headers,
|
||||
body: requestConfig.data,
|
||||
externalConnectionId,
|
||||
});
|
||||
|
||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
|
|
@ -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);
|
||||
|
||||
if (response.status >= 400) {
|
||||
|
|
@ -742,35 +719,14 @@ export class DashboardController {
|
|||
let data = response.data;
|
||||
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 };
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||
});
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { Client } from "pg";
|
||||
import { query, queryOne, getPool } from "../database/db";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import config from "../config/environment";
|
||||
import { AdminService } from "../services/adminService";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
|
|
@ -553,24 +553,10 @@ export const setUserLocale = async (
|
|||
|
||||
const { locale } = req.body;
|
||||
|
||||
if (!locale) {
|
||||
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "로케일이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||
const validLang = await queryOne<{ lang_code: string }>(
|
||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||
[locale]
|
||||
);
|
||||
|
||||
if (!validLang) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 로케일입니다: ${locale}`,
|
||||
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -1179,33 +1165,6 @@ export async function saveMenu(
|
|||
|
||||
logger.info("메뉴 저장 성공", { savedMenu });
|
||||
|
||||
// 다국어 메뉴 카테고리 자동 생성
|
||||
try {
|
||||
const { MultiLangService } = await import("../services/multilangService");
|
||||
const multilangService = new MultiLangService();
|
||||
|
||||
// 회사명 조회
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||
|
||||
// 메뉴 경로 조회 및 카테고리 생성
|
||||
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
||||
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
||||
|
||||
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
||||
menuObjId: savedMenu.objid.toString(),
|
||||
menuPath,
|
||||
});
|
||||
} catch (categoryError) {
|
||||
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
||||
menuObjId: savedMenu.objid.toString(),
|
||||
error: categoryError,
|
||||
});
|
||||
}
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||
|
|
@ -1297,17 +1256,8 @@ export async function updateMenu(
|
|||
}
|
||||
}
|
||||
|
||||
let requestCompanyCode =
|
||||
menuData.companyCode || menuData.company_code;
|
||||
|
||||
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||
if (
|
||||
requestCompanyCode === "none" ||
|
||||
requestCompanyCode === "" ||
|
||||
!requestCompanyCode
|
||||
) {
|
||||
requestCompanyCode = currentMenu.company_code;
|
||||
}
|
||||
const requestCompanyCode =
|
||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
||||
|
||||
// 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>(
|
||||
`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)]
|
||||
);
|
||||
|
||||
|
|
@ -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)]
|
||||
);
|
||||
|
||||
// 하위 메뉴들 재귀적으로 수집
|
||||
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,
|
||||
});
|
||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
|
||||
message: "메뉴가 성공적으로 삭제되었습니다.",
|
||||
data: {
|
||||
objid: menuObjid.toString(),
|
||||
menuNameKor: currentMenu.menu_name_kor,
|
||||
deletedCount: allMenuIdsToDelete.length,
|
||||
deletedChildCount: childMenuIds.length,
|
||||
objid: deletedMenu.objid.toString(),
|
||||
menuNameKor: deletedMenu.menu_name_kor,
|
||||
menuNameEng: deletedMenu.menu_name_eng,
|
||||
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를 사용한 메뉴 일괄 삭제
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
const deletedMenus: any[] = [];
|
||||
const failedMenuIds: string[] = [];
|
||||
|
||||
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
|
||||
const reversedIds = [...allIdsArray].reverse();
|
||||
|
||||
// 각 메뉴 ID에 대해 삭제 시도
|
||||
for (const menuObjid of reversedIds) {
|
||||
for (const menuId of menuIds) {
|
||||
try {
|
||||
const result = await query<any>(
|
||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||
[menuObjid]
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
|
|
@ -1728,20 +1554,20 @@ export async function deleteMenusBatch(
|
|||
});
|
||||
} else {
|
||||
failedCount++;
|
||||
failedMenuIds.push(String(menuObjid));
|
||||
failedMenuIds.push(menuId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
|
||||
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
|
||||
failedCount++;
|
||||
failedMenuIds.push(String(menuObjid));
|
||||
failedMenuIds.push(menuId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("메뉴 일괄 삭제 완료", {
|
||||
requested: menuIds.length,
|
||||
totalWithChildren: allIdsArray.length,
|
||||
total: menuIds.length,
|
||||
deletedCount,
|
||||
failedCount,
|
||||
deletedMenus,
|
||||
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("회사 등록 성공", {
|
||||
companyCode: createdCompany.company_code,
|
||||
companyName: createdCompany.company_name,
|
||||
|
|
@ -3200,23 +3008,6 @@ export const updateProfile = async (
|
|||
}
|
||||
|
||||
if (locale !== undefined) {
|
||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||
const validLang = await queryOne<{ lang_code: string }>(
|
||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||
[locale]
|
||||
);
|
||||
|
||||
if (!validLang) {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
error: {
|
||||
code: "INVALID_LOCALE",
|
||||
details: `유효하지 않은 로케일입니다: ${locale}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateFields.push(`locale = $${paramIndex}`);
|
||||
updateValues.push(locale);
|
||||
paramIndex++;
|
||||
|
|
@ -3404,7 +3195,6 @@ export const resetUserPassword = async (
|
|||
|
||||
/**
|
||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||
* column_labels 테이블에서 라벨 정보도 함께 가져옴
|
||||
*/
|
||||
export async function getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -3424,25 +3214,20 @@ export async function getTableSchema(
|
|||
|
||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||
|
||||
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||
// information_schema에서 컬럼 정보 가져오기
|
||||
const schemaQuery = `
|
||||
SELECT
|
||||
ic.column_name,
|
||||
ic.data_type,
|
||||
ic.is_nullable,
|
||||
ic.column_default,
|
||||
ic.character_maximum_length,
|
||||
ic.numeric_precision,
|
||||
ic.numeric_scale,
|
||||
cl.column_label,
|
||||
cl.display_order
|
||||
FROM information_schema.columns ic
|
||||
LEFT JOIN column_labels cl
|
||||
ON cl.table_name = ic.table_name
|
||||
AND cl.column_name = ic.column_name
|
||||
WHERE ic.table_schema = 'public'
|
||||
AND ic.table_name = $1
|
||||
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
const columns = await query<any>(schemaQuery, [tableName]);
|
||||
|
|
@ -3455,10 +3240,9 @@ export async function getTableSchema(
|
|||
return;
|
||||
}
|
||||
|
||||
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||
// 컬럼 정보를 간단한 형태로 변환
|
||||
const columnList = columns.map((col: any) => ({
|
||||
name: col.column_name,
|
||||
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||
type: col.data_type,
|
||||
nullable: col.is_nullable === "YES",
|
||||
default: col.column_default,
|
||||
|
|
@ -3553,23 +3337,13 @@ export async function copyMenu(
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
||||
const additionalCopyOptions = req.body.additionalCopyOptions
|
||||
? {
|
||||
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
||||
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
||||
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 메뉴 복사 실행
|
||||
const menuCopyService = new MenuCopyService();
|
||||
const result = await menuCopyService.copyMenu(
|
||||
parseInt(menuObjid, 10),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
screenNameConfig
|
||||
);
|
||||
|
||||
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
|
||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||
|
|
@ -330,14 +226,13 @@ export class AuthController {
|
|||
}
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
userName: dbUserInfo.userName || "",
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||
userType: dbUserInfo.userType || "USER",
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
email: dbUserInfo.email || "",
|
||||
photo: dbUserInfo.photo,
|
||||
|
|
@ -492,47 +387,50 @@ export class AuthController {
|
|||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 공차중계 회원가입 API
|
||||
* 회원가입 API (공차중계용)
|
||||
*/
|
||||
static async signup(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||
|
||||
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||
logger.info(`=== 회원가입 API 호출 ===`);
|
||||
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}, vehicleType: ${vehicleType}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 입력값이 누락되었습니다.",
|
||||
message: "모든 필수 항목을 입력해주세요.",
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||
details: "필수 입력값이 누락되었습니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회원가입 처리
|
||||
const signupResult = await AuthService.signupDriver({
|
||||
const signupResult = await AuthService.signupUser({
|
||||
userId,
|
||||
password,
|
||||
userName,
|
||||
phoneNumber,
|
||||
licenseNumber,
|
||||
vehicleNumber,
|
||||
vehicleType,
|
||||
vehicleType, // 차량 타입 추가
|
||||
});
|
||||
|
||||
if (signupResult.success) {
|
||||
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||
logger.info(`회원가입 성공: ${userId}`);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
data: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||
|
|
@ -542,14 +440,14 @@ export class AuthController {
|
|||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("공차중계 회원가입 API 오류:", error);
|
||||
} catch (error: any) {
|
||||
logger.error("회원가입 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SIGNUP_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import {
|
||||
BatchConfigFilter,
|
||||
CreateBatchConfigRequest,
|
||||
|
|
@ -64,7 +63,7 @@ export class BatchController {
|
|||
res: Response
|
||||
) {
|
||||
try {
|
||||
const result = await BatchExternalDbService.getAvailableConnections();
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
|
|
@ -100,8 +99,8 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getTables(
|
||||
type as "internal" | "external",
|
||||
const result = await BatchService.getTablesFromConnection(
|
||||
type,
|
||||
connectionId
|
||||
);
|
||||
|
||||
|
|
@ -143,10 +142,10 @@ export class BatchController {
|
|||
}
|
||||
|
||||
const connectionId = type === "external" ? Number(id) : undefined;
|
||||
const result = await BatchService.getColumns(
|
||||
tableName,
|
||||
type as "internal" | "external",
|
||||
connectionId
|
||||
const result = await BatchService.getTableColumns(
|
||||
type,
|
||||
connectionId,
|
||||
tableName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
BatchManagementService,
|
||||
|
|
@ -13,7 +13,6 @@ import { BatchService } from "../services/batchService";
|
|||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchManagementController {
|
||||
/**
|
||||
|
|
@ -332,11 +331,8 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// executionLog가 정의되어 있는지 확인
|
||||
if (typeof executionLog !== "undefined" && executionLog) {
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
if (typeof executionLog !== "undefined") {
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
@ -423,8 +419,6 @@ export class BatchManagementController {
|
|||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||
} = req.body;
|
||||
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
|
|
@ -435,47 +429,15 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
|
||||
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||
let finalApiKey = apiKey || "";
|
||||
if (authServiceName) {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 토큰 조회 가능
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 토큰만 조회
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName, companyCode];
|
||||
}
|
||||
|
||||
const tokenResult = await query<{ access_token: string }>(
|
||||
tokenQuery,
|
||||
tokenParams
|
||||
);
|
||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||
finalApiKey = tokenResult[0].access_token;
|
||||
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
||||
});
|
||||
}
|
||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||
if ((!method || method === "GET") && !apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
||||
|
||||
console.log("REST API 미리보기 요청:", {
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
|
|
@ -484,8 +446,6 @@ export class BatchManagementController {
|
|||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
authServiceName: authServiceName || "직접 입력",
|
||||
dataArrayPath: dataArrayPath || "전체 응답",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
|
|
@ -493,7 +453,7 @@ export class BatchManagementController {
|
|||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: finalApiKey,
|
||||
apiKey: apiKey || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
|
@ -548,50 +508,8 @@ export class BatchManagementController {
|
|||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||
});
|
||||
|
||||
// 데이터 배열 추출 헬퍼 함수
|
||||
const getValueByPath = (obj: any, path: string): any => {
|
||||
if (!path) return obj;
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// dataArrayPath가 있으면 해당 경로에서 배열 추출
|
||||
let extractedData: any[] = [];
|
||||
if (dataArrayPath) {
|
||||
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
|
||||
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
|
||||
const arrayData = getValueByPath(rawData, dataArrayPath);
|
||||
|
||||
if (Array.isArray(arrayData)) {
|
||||
extractedData = arrayData;
|
||||
console.log(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
|
||||
typeof arrayData
|
||||
);
|
||||
// 배열이 아니면 단일 객체로 처리
|
||||
if (arrayData) {
|
||||
extractedData = [arrayData];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// dataArrayPath가 없으면 기존 로직 사용
|
||||
extractedData = result.rows;
|
||||
}
|
||||
|
||||
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(
|
||||
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
|
||||
data
|
||||
);
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
|
|
@ -603,9 +521,9 @@ export class BatchManagementController {
|
|||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: extractedData.length,
|
||||
totalCount: result.rowCount || data.length,
|
||||
},
|
||||
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
|
|
@ -633,17 +551,8 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
} = req.body;
|
||||
const { batchName, batchType, cronSchedule, description, apiMappings } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
|
|
@ -664,10 +573,6 @@ export class BatchManagementController {
|
|||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
|
|
@ -681,10 +586,6 @@ export class BatchManagementController {
|
|||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
authServiceName: authServiceName || undefined,
|
||||
dataArrayPath: dataArrayPath || undefined,
|
||||
saveMode: saveMode || "INSERT",
|
||||
conflictKey: conflictKey || undefined,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
|
|
@ -721,51 +622,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,
|
||||
isActive: code.is_active,
|
||||
useYn: code.is_active,
|
||||
companyCode: code.company_code,
|
||||
parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
depth: code.depth, // 계층구조: 깊이
|
||||
companyCode: code.company_code, // 추가
|
||||
|
||||
// 기존 필드명도 유지 (하위 호환성)
|
||||
code_category: code.code_category,
|
||||
|
|
@ -105,9 +103,7 @@ export class CommonCodeController {
|
|||
code_name_eng: code.code_name_eng,
|
||||
sort_order: code.sort_order,
|
||||
is_active: code.is_active,
|
||||
company_code: code.company_code,
|
||||
parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값
|
||||
// depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일)
|
||||
company_code: code.company_code, // 추가
|
||||
created_date: code.created_date,
|
||||
created_by: code.created_by,
|
||||
updated_date: code.updated_date,
|
||||
|
|
@ -290,17 +286,19 @@ export class CommonCodeController {
|
|||
});
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드)
|
||||
// 공통코드관리 메뉴 OBJID: 1757401858940
|
||||
const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940;
|
||||
const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID;
|
||||
if (!menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "메뉴 OBJID는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const code = await this.commonCodeService.createCode(
|
||||
categoryCode,
|
||||
codeData,
|
||||
userId,
|
||||
companyCode,
|
||||
effectiveMenuObjid
|
||||
Number(menuObjid)
|
||||
);
|
||||
|
||||
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 { pool, queryOne } from "../database/db";
|
||||
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) {
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
||||
return {
|
||||
executeQuery: async (sql: string, params?: any[]) => {
|
||||
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||
return { rows: result };
|
||||
},
|
||||
if (!connection) {
|
||||
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
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 (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||
|
||||
|
|
@ -30,9 +48,7 @@ export const getHierarchyData = async (
|
|||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
const result: any = {
|
||||
|
|
@ -78,10 +94,7 @@ export const getHierarchyData = async (
|
|||
logger.info("동적 계층 구조 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
warehouseCount: result.warehouse?.length || 0,
|
||||
levelCounts: result.levels.map((l: any) => ({
|
||||
level: l.level,
|
||||
count: l.data.length,
|
||||
})),
|
||||
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
@ -99,35 +112,22 @@ export const getHierarchyData = async (
|
|||
};
|
||||
|
||||
// 특정 레벨의 하위 데이터 조회
|
||||
export const getChildrenData = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
|
||||
req.body;
|
||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!hierarchyConfig ||
|
||||
!parentLevel ||
|
||||
!parentKey
|
||||
) {
|
||||
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
// 다음 레벨 찾기
|
||||
const nextLevel = config.levels?.find(
|
||||
(l: any) => l.level === parentLevel + 1
|
||||
);
|
||||
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
|
||||
|
||||
if (!nextLevel) {
|
||||
return res.json({
|
||||
|
|
@ -168,10 +168,7 @@ export const getChildrenData = async (
|
|||
};
|
||||
|
||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getWarehouses = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, tableName } = req.query;
|
||||
|
||||
|
|
@ -189,9 +186,7 @@ export const getWarehouses = async (
|
|||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||
|
|
@ -220,10 +215,7 @@ export const getWarehouses = async (
|
|||
};
|
||||
|
||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getAreas = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||
|
||||
|
|
@ -234,9 +226,7 @@ export const getAreas = async (
|
|||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
|
|
@ -268,10 +258,7 @@ export const getAreas = async (
|
|||
};
|
||||
|
||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getLocations = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||
|
||||
|
|
@ -282,9 +269,7 @@ export const getLocations = async (
|
|||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
|
|
@ -316,10 +301,7 @@ export const getLocations = async (
|
|||
};
|
||||
|
||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||
export const getMaterials = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
|
|
@ -327,27 +309,20 @@ export const getMaterials = async (
|
|||
tableName,
|
||||
keyColumn,
|
||||
locationKeyColumn,
|
||||
layerColumn,
|
||||
layerColumn
|
||||
} = req.query;
|
||||
|
||||
if (
|
||||
!externalDbConnectionId ||
|
||||
!locaKey ||
|
||||
!tableName ||
|
||||
!locationKeyColumn
|
||||
) {
|
||||
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
// 동적 쿼리 생성
|
||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
|
||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||
|
|
@ -381,10 +356,7 @@ export const getMaterials = async (
|
|||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||
export const getMaterialCounts = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||
|
||||
|
|
@ -395,9 +367,7 @@ export const getMaterialCounts = async (
|
|||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(
|
||||
Number(externalDbConnectionId)
|
||||
);
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
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: "회원 탈퇴 처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
|
|||
};
|
||||
|
||||
const result = await dynamicFormService.updateFormDataPartial(
|
||||
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||
parseInt(id),
|
||||
tableName,
|
||||
originalData,
|
||||
newDataWithMeta
|
||||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, screenId } = req.body;
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -240,16 +240,7 @@ export const deleteFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||
|
||||
await dynamicFormService.deleteFormData(
|
||||
id,
|
||||
tableName,
|
||||
companyCode,
|
||||
userId,
|
||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||
);
|
||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -436,8 +427,7 @@ export const updateFieldValue = async (
|
|||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||
req.body;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
|
|
@ -450,27 +440,16 @@ export const updateFieldValue = async (
|
|||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!tableName ||
|
||||
!keyField ||
|
||||
keyValue === undefined ||
|
||||
!updateField ||
|
||||
updateValue === undefined
|
||||
) {
|
||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!validNamePattern.test(tableName) ||
|
||||
!validNamePattern.test(keyField) ||
|
||||
!validNamePattern.test(updateField)
|
||||
) {
|
||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
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 문자열)
|
||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||
...otherParams
|
||||
} = req.query;
|
||||
|
|
@ -66,23 +65,11 @@ export class EntityJoinController {
|
|||
const userField = parsedAutoFilter.userField || "companyCode";
|
||||
const userValue = ((req as any).user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
searchConditions[filterColumn] = finalCompanyCode;
|
||||
if (userValue) {
|
||||
searchConditions[filterColumn] = userValue;
|
||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||
filterColumn,
|
||||
finalCompanyCode,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
tableName,
|
||||
{
|
||||
|
|
@ -167,7 +141,6 @@ export class EntityJoinController {
|
|||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -436,16 +409,18 @@ export class EntityJoinController {
|
|||
config.referenceTable
|
||||
);
|
||||
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
||||
const currentDisplayColumn =
|
||||
config.displayColumn || config.displayColumns[0];
|
||||
const availableColumns = columns.filter(
|
||||
(col) => col.columnName !== currentDisplayColumn
|
||||
);
|
||||
|
||||
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||
return {
|
||||
joinConfig: config,
|
||||
tableName: config.referenceTable,
|
||||
currentDisplayColumn: currentDisplayColumn,
|
||||
availableColumns: columns.map((col) => ({
|
||||
availableColumns: availableColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
dataType: col.dataType,
|
||||
|
|
|
|||
|
|
@ -32,32 +32,10 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const requestedFields = searchFields
|
||||
const fields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// 🆕 테이블의 실제 컬럼 목록 조회
|
||||
const pool = getPool();
|
||||
const columnsResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 🆕 존재하는 컬럼만 필터링
|
||||
const fields = requestedFields.filter((field) => {
|
||||
if (existingColumns.has(field)) {
|
||||
return true;
|
||||
} else {
|
||||
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const existingColumnsArray = Array.from(existingColumns);
|
||||
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
|
@ -65,131 +43,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||
if (existingColumns.has("company_code")) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText) {
|
||||
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||
let searchableFields = fields;
|
||||
if (searchableFields.length === 0) {
|
||||
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||
const defaultSearchColumns = [
|
||||
'name', 'code', 'description', 'title', 'label',
|
||||
'item_name', 'item_code', 'item_number',
|
||||
'equipment_name', 'equipment_code',
|
||||
'inspection_item', 'consumable_name', // 소모품명 추가
|
||||
'supplier_name', 'customer_name', 'product_name',
|
||||
];
|
||||
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
|
||||
if (searchText && fields.length > 0) {
|
||||
const searchConditions = fields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
|
||||
}
|
||||
|
||||
if (searchableFields.length > 0) {
|
||||
const searchConditions = searchableFields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
searchableFields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
// 검색어 파라미터 추가
|
||||
fields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
|
||||
// 추가 필터 조건 (존재하는 컬럼만)
|
||||
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
||||
// 특수 키 형식: column__operator (예: division__in, name__like)
|
||||
// 추가 필터 조건
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
// 특수 키 형식 파싱: column__operator
|
||||
let columnName = key;
|
||||
let operator = "=";
|
||||
|
||||
if (key.includes("__")) {
|
||||
const parts = key.split("__");
|
||||
columnName = parts[0];
|
||||
operator = parts[1] || "=";
|
||||
}
|
||||
|
||||
if (!existingColumns.has(columnName)) {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자별 WHERE 조건 생성
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${columnName}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
whereConditions.push(`"${columnName}" > $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<":
|
||||
whereConditions.push(`"${columnName}" < $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">=":
|
||||
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<=":
|
||||
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in":
|
||||
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
|
||||
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inValues.length > 0) {
|
||||
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" IN (${placeholders})`);
|
||||
params.push(...inValues);
|
||||
paramIndex += inValues.length;
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
// NOT IN 연산자
|
||||
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInValues.length > 0) {
|
||||
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
|
||||
params.push(...notInValues);
|
||||
paramIndex += notInValues.length;
|
||||
}
|
||||
break;
|
||||
case "like":
|
||||
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
default:
|
||||
// 알 수 없는 연산자는 등호로 처리
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 페이징
|
||||
|
|
@ -199,7 +78,8 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
// 쿼리 실행
|
||||
const pool = getPool();
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
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({
|
||||
success: true,
|
||||
message: `${files.length}개 파일 업로드 완료`,
|
||||
|
|
@ -463,56 +405,6 @@ export const deleteFile = async (
|
|||
["DELETED", parseInt(objid)]
|
||||
);
|
||||
|
||||
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||
const targetObjid = fileRecord.target_objid;
|
||||
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||
const parts = targetObjid.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const [tableName, recordId, columnName] = parts;
|
||||
|
||||
try {
|
||||
// 해당 레코드의 남은 첨부파일 조회
|
||||
const remainingFiles = await query<any>(
|
||||
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||
ORDER BY regdate DESC`,
|
||||
[targetObjid]
|
||||
);
|
||||
|
||||
// attachments JSONB 형태로 변환
|
||||
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||
objid: f.objid.toString(),
|
||||
realFileName: f.real_file_name,
|
||||
fileSize: Number(f.file_size),
|
||||
fileExt: f.file_ext,
|
||||
filePath: f.file_path,
|
||||
regdate: f.regdate?.toISOString(),
|
||||
}));
|
||||
|
||||
// 해당 테이블의 attachments 컬럼 업데이트
|
||||
// 🔒 멀티테넌시: company_code 필터 추가
|
||||
await query(
|
||||
`UPDATE ${tableName}
|
||||
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||
);
|
||||
|
||||
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
remainingFiles: attachmentsJson.length,
|
||||
});
|
||||
} catch (updateError) {
|
||||
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "파일이 삭제되었습니다.",
|
||||
|
|
|
|||
|
|
@ -32,17 +32,8 @@ export class FlowController {
|
|||
*/
|
||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
// REST API 관련 필드
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
||||
req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
|
|
@ -52,9 +43,6 @@ export class FlowController {
|
|||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
|
|
@ -66,12 +54,8 @@ export class FlowController {
|
|||
return;
|
||||
}
|
||||
|
||||
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
|
||||
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
|
||||
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
|
||||
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
|
||||
// 테이블 이름이 제공된 경우에만 존재 확인
|
||||
if (tableName) {
|
||||
const tableExists =
|
||||
await this.flowDefinitionService.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
|
|
@ -84,17 +68,7 @@ export class FlowController {
|
|||
}
|
||||
|
||||
const flowDef = await this.flowDefinitionService.create(
|
||||
{
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||
},
|
||||
{ name, description, tableName, dbSourceType, dbConnectionId },
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
|
@ -837,53 +811,4 @@ export class FlowController {
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 스텝 데이터 업데이트 (인라인 편집)
|
||||
*/
|
||||
updateStepData = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId, recordId } = req.params;
|
||||
const updateData = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
if (!flowId || !stepId || !recordId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, stepId, and recordId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updateData || Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "Update data is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.flowExecutionService.updateStepData(
|
||||
parseInt(flowId),
|
||||
parseInt(stepId),
|
||||
recordId,
|
||||
updateData,
|
||||
userId,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data updated successfully",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error updating step data:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "Failed to update step data",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import {
|
|||
SaveLangTextsRequest,
|
||||
GetUserTextParams,
|
||||
BatchTranslationRequest,
|
||||
GenerateKeyRequest,
|
||||
CreateOverrideKeyRequest,
|
||||
ApiResponse,
|
||||
LangCategory,
|
||||
} from "../types/multilang";
|
||||
|
||||
/**
|
||||
|
|
@ -190,7 +187,7 @@ export const getLangKeys = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const { companyCode, menuCode, keyType, searchText } = req.query;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
|
|
@ -202,7 +199,6 @@ export const getLangKeys = async (
|
|||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -634,391 +630,6 @@ export const deleteLanguage = async (
|
|||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 카테고리 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories
|
||||
* 카테고리 목록 조회 API (트리 구조)
|
||||
*/
|
||||
export const getCategories = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("카테고리 목록 조회 요청", { user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const categories = await multiLangService.getCategories();
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 목록 조회 성공",
|
||||
data: categories,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId
|
||||
* 카테고리 상세 조회 API
|
||||
*/
|
||||
export const getCategoryById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const category = await multiLangService.getCategoryById(parseInt(categoryId));
|
||||
|
||||
if (!category) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "카테고리를 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_NOT_FOUND",
|
||||
details: `Category ID ${categoryId} not found`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse<LangCategory> = {
|
||||
success: true,
|
||||
message: "카테고리 상세 조회 성공",
|
||||
data: category,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_DETAIL_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId/path
|
||||
* 카테고리 경로 조회 API (부모 포함)
|
||||
*/
|
||||
export const getCategoryPath = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 경로 조회 성공",
|
||||
data: path,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 경로 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_PATH_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 자동 생성 및 오버라이드 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/generate
|
||||
* 키 자동 생성 API
|
||||
*/
|
||||
export const generateKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const generateData: GenerateKeyRequest = req.body;
|
||||
logger.info("키 자동 생성 요청", { generateData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode, categoryId, and keyMeaning are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Only super admin can create common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 키만 생성 가능
|
||||
if (generateData.companyCode !== "*" &&
|
||||
req.user?.companyCode !== "*" &&
|
||||
generateData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.generateKey({
|
||||
...generateData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 자동 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 자동 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_GENERATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/preview
|
||||
* 키 미리보기 API
|
||||
*/
|
||||
export const previewKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId, keyMeaning, companyCode } = req.body;
|
||||
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
|
||||
|
||||
if (!categoryId || !keyMeaning || !companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "categoryId, keyMeaning, and companyCode are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const preview = await multiLangService.previewGeneratedKey(
|
||||
parseInt(categoryId),
|
||||
keyMeaning,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<{
|
||||
langKey: string;
|
||||
exists: boolean;
|
||||
isOverride: boolean;
|
||||
baseKeyId?: number;
|
||||
}> = {
|
||||
success: true,
|
||||
message: "키 미리보기 성공",
|
||||
data: preview,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 미리보기 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_PREVIEW_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/override
|
||||
* 오버라이드 키 생성 API
|
||||
*/
|
||||
export const createOverrideKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const overrideData: CreateOverrideKeyRequest = req.body;
|
||||
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!overrideData.companyCode || !overrideData.baseKeyId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드와 원본 키 ID는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode and baseKeyId are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
|
||||
if (overrideData.companyCode === "*") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
|
||||
error: {
|
||||
code: "INVALID_OVERRIDE",
|
||||
details: "Cannot create override for common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
|
||||
if (req.user?.companyCode !== "*" &&
|
||||
overrideData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createOverrideKey({
|
||||
...overrideData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "오버라이드 키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEY_CREATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/keys/overrides/:companyCode
|
||||
* 회사별 오버라이드 키 목록 조회 API
|
||||
*/
|
||||
export const getOverrideKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
|
||||
|
||||
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
|
||||
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot view override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keys = await multiLangService.getOverrideKeys(companyCode);
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "오버라이드 키 목록 조회 성공",
|
||||
data: keys,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEYS_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/batch
|
||||
* 다국어 텍스트 배치 조회 API
|
||||
|
|
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/screen-labels
|
||||
* 화면 라벨 다국어 키 자동 생성 API
|
||||
*/
|
||||
export const generateScreenLabelKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, menuObjId, labels } = req.body;
|
||||
|
||||
logger.info("화면 라벨 다국어 키 생성 요청", {
|
||||
screenId,
|
||||
menuObjId,
|
||||
labelCount: labels?.length,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!screenId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId는 필수입니다.",
|
||||
error: { code: "MISSING_SCREEN_ID" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "labels 배열이 필요합니다.",
|
||||
error: { code: "MISSING_LABELS" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
|
||||
const { queryOne } = await import("../database/db");
|
||||
const screenInfo = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
|
||||
|
||||
// 회사명 조회
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||
|
||||
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const results = await multiLangService.generateScreenLabelKeys({
|
||||
screenId: Number(screenId),
|
||||
companyCode,
|
||||
companyName,
|
||||
menuObjId,
|
||||
labels,
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof results> = {
|
||||
success: true,
|
||||
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
||||
data: results,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -217,14 +217,11 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
|
|||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode });
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
|
||||
logger.error("코드 할당 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -5,7 +5,6 @@
|
|||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ const pool = getPool();
|
|||
* 화면 임베딩 목록 조회
|
||||
* GET /api/screen-embedding?parentScreenId=1
|
||||
*/
|
||||
export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getScreenEmbeddings(req: Request, res: Response) {
|
||||
try {
|
||||
const { parentScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -68,7 +67,7 @@ export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Respon
|
|||
* 화면 임베딩 상세 조회
|
||||
* GET /api/screen-embedding/:id
|
||||
*/
|
||||
export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getScreenEmbeddingById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -114,7 +113,7 @@ export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Res
|
|||
* 화면 임베딩 생성
|
||||
* POST /api/screen-embedding
|
||||
*/
|
||||
export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
export async function createScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
parentScreenId,
|
||||
|
|
@ -185,7 +184,7 @@ export async function createScreenEmbedding(req: AuthenticatedRequest, res: Resp
|
|||
* 화면 임베딩 수정
|
||||
* PUT /api/screen-embedding/:id
|
||||
*/
|
||||
export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
export async function updateScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { position, mode, config } = req.body;
|
||||
|
|
@ -258,7 +257,7 @@ export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Resp
|
|||
* 화면 임베딩 삭제
|
||||
* DELETE /api/screen-embedding/:id
|
||||
*/
|
||||
export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||
export async function deleteScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -302,7 +301,7 @@ export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Resp
|
|||
* 데이터 전달 설정 조회
|
||||
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
|
||||
*/
|
||||
export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { sourceScreenId, targetScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -364,7 +363,7 @@ export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Resp
|
|||
* 데이터 전달 설정 생성
|
||||
* POST /api/screen-data-transfer
|
||||
*/
|
||||
export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
export async function createScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
sourceScreenId,
|
||||
|
|
@ -437,7 +436,7 @@ export async function createScreenDataTransfer(req: AuthenticatedRequest, res: R
|
|||
* 데이터 전달 설정 수정
|
||||
* PUT /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
export async function updateScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { dataReceivers, buttonConfig } = req.body;
|
||||
|
|
@ -505,7 +504,7 @@ export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: R
|
|||
* 데이터 전달 설정 삭제
|
||||
* DELETE /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||
export async function deleteScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -549,7 +548,7 @@ export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: R
|
|||
* 분할 패널 설정 조회
|
||||
* GET /api/screen-split-panel/:screenId
|
||||
*/
|
||||
export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getScreenSplitPanel(req: Request, res: Response) {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
|
@ -656,7 +655,7 @@ export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Respon
|
|||
* 분할 패널 설정 생성
|
||||
* POST /api/screen-split-panel
|
||||
*/
|
||||
export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
export async function createScreenSplitPanel(req: Request, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
|
|
@ -793,7 +792,7 @@ export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Res
|
|||
* 분할 패널 설정 수정
|
||||
* PUT /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
export async function updateScreenSplitPanel(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { layoutConfig } = req.body;
|
||||
|
|
@ -846,7 +845,7 @@ export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Res
|
|||
* 분할 패널 설정 삭제
|
||||
* DELETE /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||
export async function deleteScreenSplitPanel(req: Request, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -148,42 +148,11 @@ export const updateScreenInfo = async (
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const {
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
// REST API 관련 필드 추가
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
} = req.body;
|
||||
|
||||
console.log("화면 정보 수정 요청:", {
|
||||
screenId: id,
|
||||
dataSourceType,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
});
|
||||
const { screenName, tableName, description, isActive } = req.body;
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{
|
||||
screenName,
|
||||
tableName,
|
||||
description,
|
||||
isActive,
|
||||
dataSourceType,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
},
|
||||
{ screenName, tableName, description, isActive },
|
||||
companyCode
|
||||
);
|
||||
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 (
|
||||
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레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
// 이력 조회 쿼리
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY log_id DESC
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
|||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||
// 이력 조회 쿼리
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
|||
full_row_after
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
ORDER BY log_id DESC
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -767,33 +767,20 @@ export async function getTableData(
|
|||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
|
||||
// 🆕 현재 사용자 필터 적용
|
||||
let enhancedSearch = { ...search };
|
||||
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
|
||||
if (shouldApplyAutoFilter && req.user) {
|
||||
const filterColumn = autoFilter?.filterColumn || "company_code";
|
||||
const userField = autoFilter?.userField || "companyCode";
|
||||
if (autoFilter?.enabled && req.user) {
|
||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
||||
const userField = autoFilter.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (autoFilter?.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = autoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: autoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
enhancedSearch[filterColumn] = finalCompanyCode;
|
||||
if (userValue) {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userField,
|
||||
userValue: finalCompanyCode,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -883,27 +870,6 @@ export async function addTableData(
|
|||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (companyCode && !data.company_code) {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||
if (hasCompanyCodeColumn) {
|
||||
data.company_code = companyCode;
|
||||
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
|
||||
const userId = req.user?.userId;
|
||||
if (userId && !data.writer) {
|
||||
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
|
||||
if (hasWriterColumn) {
|
||||
data.writer = userId;
|
||||
logger.info(`writer 자동 추가 - ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
|
|
@ -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();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||
|
||||
// 토큰 만료 에러인지 확인
|
||||
const isTokenExpired = errorMessage.includes("만료");
|
||||
logger.error(
|
||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
||||
);
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||
code: "INVALID_TOKEN",
|
||||
details:
|
||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,16 +28,6 @@ export const errorHandler = (
|
|||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||
if ((err as any).code) {
|
||||
const pgError = err as any;
|
||||
// 원본 에러 메시지 로깅 (디버깅용)
|
||||
console.error("🔴 PostgreSQL Error:", {
|
||||
code: pgError.code,
|
||||
message: pgError.message,
|
||||
detail: pgError.detail,
|
||||
hint: pgError.hint,
|
||||
table: pgError.table,
|
||||
column: pgError.column,
|
||||
constraint: pgError.constraint,
|
||||
});
|
||||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
if (pgError.code === "23505") {
|
||||
// unique_violation
|
||||
|
|
@ -52,7 +42,7 @@ export const errorHandler = (
|
|||
// 기타 무결성 제약 조건 위반
|
||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||
} else {
|
||||
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
||||
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ import {
|
|||
getDepartmentList, // 부서 목록 조회
|
||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||
saveUser, // 사용자 등록/수정
|
||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
|
|
@ -52,10 +50,8 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
|||
router.get("/users", getUserList);
|
||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||
router.put("/profile", updateProfile); // 프로필 수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
|
|
|
|||
|
|
@ -43,14 +43,8 @@ router.post("/refresh", AuthController.refreshToken);
|
|||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* 공차중계 회원가입 API
|
||||
* 회원가입 API
|
||||
*/
|
||||
router.post("/signup", AuthController.signup);
|
||||
|
||||
/**
|
||||
* POST /api/auth/switch-company
|
||||
* WACE 관리자 전용: 다른 회사로 전환
|
||||
*/
|
||||
router.post("/switch-company", AuthController.switchCompany);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -79,10 +79,4 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
|
|||
*/
|
||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/auth-services
|
||||
* 인증 토큰 서비스명 목록 조회
|
||||
*/
|
||||
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
|
||||
|
||||
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,
|
||||
getTablesWithColumn,
|
||||
previewCodeMerge,
|
||||
mergeCodeByValue,
|
||||
previewMergeCodeByValue,
|
||||
} from "../controllers/codeMergeController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -15,7 +13,7 @@ router.use(authenticateToken);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/merge-all-tables
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||
* Body: { columnName, oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||
|
|
@ -28,24 +26,10 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/preview
|
||||
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
* Body: { columnName, oldValue }
|
||||
*/
|
||||
router.post("/preview", previewCodeMerge);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/merge-by-value
|
||||
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
||||
* Body: { oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-by-value", mergeCodeByValue);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/preview-by-value
|
||||
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
||||
* Body: { oldValue }
|
||||
*/
|
||||
router.post("/preview-by-value", previewMergeCodeByValue);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,21 +46,6 @@ router.put("/categories/:categoryCode/codes/reorder", (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) =>
|
||||
commonCodeController.updateCode(req, res)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,262 +1,10 @@
|
|||
import express from "express";
|
||||
import { dataService } from "../services/dataService";
|
||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ================================
|
||||
// 마스터-디테일 엑셀 API
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보 조회
|
||||
* GET /api/data/master-detail/relation/:screenId
|
||||
*/
|
||||
router.get(
|
||||
"/master-detail/relation/:screenId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
|
||||
if (!screenId || isNaN(parseInt(screenId))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효한 screenId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||
masterTable: relation.masterTable,
|
||||
detailTable: relation.detailTable,
|
||||
joinKey: relation.masterKeyColumn,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: relation,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||
* POST /api/data/master-detail/download
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/download",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, filters } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!screenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. JOIN 데이터 조회
|
||||
const data = await masterDetailExcelService.getJoinedData(
|
||||
relation,
|
||||
companyCode,
|
||||
filters
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 다운로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 업로드
|
||||
* POST /api/data/master-detail/upload
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/upload",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, data } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!screenId || !data || !Array.isArray(data)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 data 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 데이터 업로드
|
||||
const result = await masterDetailExcelService.uploadJoinedData(
|
||||
relation,
|
||||
data,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 업로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||
* - 마스터 정보는 UI에서 선택
|
||||
* - 디테일 정보만 엑셀에서 업로드
|
||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||
*
|
||||
* POST /api/data/master-detail/upload-simple
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/upload-simple",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 detailData 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||
|
||||
// 업로드 실행
|
||||
const result = await masterDetailExcelService.uploadSimple(
|
||||
parseInt(screenId),
|
||||
detailData,
|
||||
masterFieldValues || {},
|
||||
numberingRuleId,
|
||||
companyCode,
|
||||
userId,
|
||||
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
detailInserted: result.detailInserted,
|
||||
generatedKey: result.generatedKey,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ================================
|
||||
// 기존 데이터 API
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||
|
|
@ -950,7 +698,6 @@ router.post(
|
|||
try {
|
||||
const { tableName } = req.params;
|
||||
const filterConditions = req.body;
|
||||
const userCompany = req.user?.companyCode;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -959,12 +706,11 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
const result = await dataService.deleteGroupRecords(
|
||||
tableName,
|
||||
filterConditions,
|
||||
userCompany // 회사 코드 전달
|
||||
filterConditions
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -215,132 +215,49 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
/**
|
||||
* 플로우 소스 테이블 조회
|
||||
* GET /api/dataflow/node-flows/:flowId/source-table
|
||||
* 플로우의 첫 번째 소스 노드(tableSource, externalDBSource)에서 테이블명 추출
|
||||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.get("/:flowId/source-table", async (req: Request, res: Response) => {
|
||||
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
const flow = await queryOne<{ flow_data: any }>(
|
||||
`SELECT flow_data FROM node_flows WHERE flow_id = $1`,
|
||||
[flowId]
|
||||
);
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
userId: req.user?.userId,
|
||||
companyCode: req.user?.companyCode,
|
||||
});
|
||||
|
||||
if (!flow) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "플로우를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
// 사용자 정보를 contextData에 추가
|
||||
const enrichedContextData = {
|
||||
...contextData,
|
||||
userId: req.user?.userId,
|
||||
userName: req.user?.userName,
|
||||
companyCode: req.user?.companyCode,
|
||||
};
|
||||
|
||||
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}`
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
enrichedContextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sourceTable: sourceNode.data.tableName,
|
||||
sourceNodeType: sourceNode.type,
|
||||
sourceNodeId: sourceNode.id,
|
||||
displayName: sourceNode.data.displayName,
|
||||
},
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 소스 테이블 조회 실패:", error);
|
||||
logger.error("플로우 실행 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우 소스 테이블을 조회하지 못했습니다.",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "플로우 실행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.post(
|
||||
"/:flowId/execute",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
userId: req.user?.userId,
|
||||
companyCode: req.user?.companyCode,
|
||||
});
|
||||
|
||||
// 🔍 디버깅: req.user 전체 확인
|
||||
logger.info(`🔍 req.user 전체 정보:`, {
|
||||
user: req.user,
|
||||
hasUser: !!req.user,
|
||||
});
|
||||
|
||||
// 사용자 정보를 contextData에 추가
|
||||
const enrichedContextData = {
|
||||
...contextData,
|
||||
userId: req.user?.userId,
|
||||
userName: req.user?.userName,
|
||||
companyCode: req.user?.companyCode,
|
||||
};
|
||||
|
||||
// 🔍 디버깅: enrichedContextData 확인
|
||||
logger.info(`🔍 enrichedContextData:`, {
|
||||
userId: enrichedContextData.userId,
|
||||
companyCode: enrichedContextData.companyCode,
|
||||
});
|
||||
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
enrichedContextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "플로우 실행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
getTableColumns,
|
||||
getTablePrimaryKeys,
|
||||
saveLocationHistory,
|
||||
getLocationHistory,
|
||||
} from "../controllers/dynamicFormController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -24,9 +22,9 @@ router.use(authenticateToken);
|
|||
// 폼 데이터 CRUD
|
||||
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
router.delete("/:id", deleteFormData);
|
||||
router.get("/:id", getFormData);
|
||||
|
||||
|
|
@ -42,8 +40,4 @@ router.get("/table/:tableName/columns", getTableColumns);
|
|||
// 테이블 기본키 조회
|
||||
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
||||
|
||||
// 위치 이력 (연속 위치 추적)
|
||||
router.post("/location-history", saveLocationHistory);
|
||||
router.get("/location-history/:tripId", getLocationHistory);
|
||||
|
||||
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 = {
|
||||
...req.body,
|
||||
created_by: req.user?.userId || "system",
|
||||
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
|
||||
company_code: req.body.company_code || req.user?.companyCode || "*",
|
||||
};
|
||||
|
||||
const result =
|
||||
|
|
|
|||
|
|
@ -43,9 +43,6 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
|||
router.post("/move", flowController.moveData);
|
||||
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", flowController.getFlowAuditLogs);
|
||||
|
|
|
|||
|
|
@ -21,20 +21,6 @@ import {
|
|||
getUserText,
|
||||
getLangText,
|
||||
getBatchTranslations,
|
||||
|
||||
// 카테고리 관리 API
|
||||
getCategories,
|
||||
getCategoryById,
|
||||
getCategoryPath,
|
||||
|
||||
// 자동 생성 및 오버라이드 API
|
||||
generateKey,
|
||||
previewKey,
|
||||
createOverrideKey,
|
||||
getOverrideKeys,
|
||||
|
||||
// 화면 라벨 다국어 API
|
||||
generateScreenLabelKeys,
|
||||
} from "../controllers/multilangController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
|
|||
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
||||
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
||||
|
||||
// 카테고리 관리 API
|
||||
router.get("/categories", getCategories); // 카테고리 트리 조회
|
||||
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
|
||||
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
|
||||
|
||||
// 자동 생성 및 오버라이드 API
|
||||
router.post("/keys/generate", generateKey); // 키 자동 생성
|
||||
router.post("/keys/preview", previewKey); // 키 미리보기
|
||||
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
||||
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
||||
|
||||
// 화면 라벨 다국어 자동 생성 API
|
||||
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
||||
// WORD(DOCX) 내보내기
|
||||
router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
|
|
|
|||
|
|
@ -22,15 +22,6 @@ const router = Router();
|
|||
// 모든 role 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
/**
|
||||
* 권한 그룹 CRUD
|
||||
*/
|
||||
|
|
@ -76,4 +67,13 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
|||
// 메뉴 권한 설정
|
||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
updateScreenInfo,
|
||||
deleteScreen,
|
||||
bulkDeleteScreens,
|
||||
checkScreenDependencies,
|
||||
restoreScreen,
|
||||
permanentDeleteScreen,
|
||||
|
|
@ -45,7 +44,6 @@ router.put("/screens/:id", updateScreen);
|
|||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Router } from "express";
|
||||
import {
|
||||
getCategoryColumns,
|
||||
getAllCategoryColumns,
|
||||
getCategoryValues,
|
||||
addCategoryValue,
|
||||
updateCategoryValue,
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
getCategoryLabelsByCodes,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -23,10 +21,6 @@ const router = Router();
|
|||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
||||
router.get("/all-columns", getAllCategoryColumns);
|
||||
|
||||
// 테이블의 카테고리 컬럼 목록 조회
|
||||
router.get("/:tableName/columns", getCategoryColumns);
|
||||
|
||||
|
|
@ -48,9 +42,6 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
|||
// 카테고리 값 순서 변경
|
||||
router.post("/values/reorder", reorderCategoryValues);
|
||||
|
||||
// 카테고리 코드로 라벨 조회
|
||||
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ import {
|
|||
getLogData,
|
||||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -39,15 +37,6 @@ router.use(authenticateToken);
|
|||
*/
|
||||
router.get("/tables", getTableList);
|
||||
|
||||
/**
|
||||
* 두 테이블 간 엔티티 관계 조회
|
||||
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||
*
|
||||
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||
*/
|
||||
router.get("/tables/entity-relations", getTableEntityRelations);
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회
|
||||
* GET /api/table-management/tables/:tableName/columns
|
||||
|
|
@ -209,17 +198,4 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
|||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 조건 생성
|
||||
const menuTypeCondition =
|
||||
menuType !== undefined
|
||||
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
|
||||
: "1 = 1";
|
||||
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
|
||||
|
||||
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
||||
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
||||
const includeInactive = paramMap.includeInactive === true;
|
||||
const isManagementScreen = includeInactive || menuType === undefined;
|
||||
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
||||
const statusCondition = isManagementScreen
|
||||
? "1 = 1"
|
||||
: "MENU.STATUS = 'active'";
|
||||
const subStatusCondition = isManagementScreen
|
||||
? "1 = 1"
|
||||
: "MENU_SUB.STATUS = 'active'";
|
||||
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
|
||||
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
|
||||
|
||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||
let authFilter = "";
|
||||
|
|
@ -41,11 +35,7 @@ export class AdminService {
|
|||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (
|
||||
menuType !== undefined &&
|
||||
userType !== "SUPER_ADMIN" &&
|
||||
!isManagementScreen
|
||||
) {
|
||||
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
|
||||
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
||||
const userRoleGroups = await query<any>(
|
||||
`
|
||||
|
|
@ -65,53 +55,46 @@ export class AdminService {
|
|||
}
|
||||
);
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
|
||||
/* [원본 코드 - 권한 그룹 체크]
|
||||
if (userType === "COMPANY_ADMIN") {
|
||||
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
|
||||
if (userRoleGroups.length > 0) {
|
||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
|
||||
authFilter = `
|
||||
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'
|
||||
)
|
||||
`;
|
||||
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
||||
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
||||
queryParams.push(userCompanyCode);
|
||||
const companyParamIndex = paramIndex;
|
||||
paramIndex++;
|
||||
|
||||
// 하위 메뉴도 권한 체크
|
||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
||||
unionFilter = `
|
||||
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
AND (
|
||||
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
||||
OR (
|
||||
MENU_SUB.COMPANY_CODE = '*'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM rel_menu_auth rma
|
||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||
AND rma.auth_objid = ANY($${paramIndex})
|
||||
AND rma.read_yn = 'Y'
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
queryParams.push(roleObjids);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
|
||||
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
||||
);
|
||||
} else {
|
||||
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
|
||||
logger.warn(
|
||||
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
||||
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
||||
queryParams.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(
|
||||
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 권한 그룹 필수
|
||||
|
|
@ -148,12 +131,7 @@ export class AdminService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
} else if (
|
||||
menuType !== undefined &&
|
||||
userType === "SUPER_ADMIN" &&
|
||||
!isManagementScreen
|
||||
) {
|
||||
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
|
||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||
|
|
@ -420,18 +398,9 @@ export class AdminService {
|
|||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
|
||||
if (userType === "SUPER_ADMIN") {
|
||||
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
} else {
|
||||
|
|
@ -488,7 +457,6 @@ export class AdminService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 2. 회사별 필터링 조건 생성
|
||||
let companyFilter = "";
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ export class AuthService {
|
|||
[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에서 조회한 원본 사용자 정보 상세 로그
|
||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||
|
|
@ -344,11 +345,11 @@ export class AuthService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 공차중계 회원가입 처리
|
||||
* 회원가입 처리
|
||||
* - user_info 테이블에 사용자 정보 저장
|
||||
* - vehicles 테이블에 차량 정보 저장
|
||||
* - vehicles 테이블에 차량 정보 저장 (공차중계용)
|
||||
*/
|
||||
static async signupDriver(data: {
|
||||
static async signupUser(data: {
|
||||
userId: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
|
|
@ -412,10 +413,9 @@ export class AuthService {
|
|||
vehicle_number,
|
||||
company_code,
|
||||
user_type,
|
||||
signup_type,
|
||||
status,
|
||||
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,
|
||||
hashedPassword,
|
||||
|
|
@ -425,12 +425,12 @@ export class AuthService {
|
|||
vehicleNumber,
|
||||
"COMPANY_13", // 기본 회사 코드
|
||||
null, // user_type: null
|
||||
"DRIVER", // signup_type: 공차중계 회원가입 사용자
|
||||
"active", // status: active
|
||||
]
|
||||
);
|
||||
|
||||
// 5. 차량 정보 저장 (vehicles)
|
||||
// 5. 차량 정보 저장 (vehicles) - 공차중계용
|
||||
// status = 'off': 앱 미사용/로그아웃 상태
|
||||
await query(
|
||||
`INSERT INTO vehicles (
|
||||
vehicle_number,
|
||||
|
|
@ -448,20 +448,20 @@ export class AuthService {
|
|||
vehicleType || null,
|
||||
userName,
|
||||
phoneNumber,
|
||||
"off", // 초기 상태: off (대기)
|
||||
"off", // 초기 상태: off (앱 미사용)
|
||||
"COMPANY_13", // 기본 회사 코드
|
||||
userId, // 사용자 ID 연결
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||
logger.info(`회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "회원가입이 완료되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("공차중계 회원가입 오류:", error);
|
||||
logger.error("회원가입 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
||||
|
|
|
|||
|
|
@ -203,7 +203,8 @@ export class BatchExternalDbService {
|
|||
// 비밀번호 복호화
|
||||
if (connection.password) {
|
||||
try {
|
||||
connection.password = PasswordEncryption.decrypt(connection.password);
|
||||
const passwordEncryption = new PasswordEncryption();
|
||||
connection.password = passwordEncryption.decrypt(connection.password);
|
||||
} catch (error) {
|
||||
console.error("비밀번호 복호화 실패:", error);
|
||||
// 복호화 실패 시 원본 사용 (또는 에러 처리)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import cron, { ScheduledTask } from "node-cron";
|
||||
import cron from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
|
||||
/**
|
||||
* 모든 활성 배치의 스케줄링 초기화
|
||||
|
|
@ -65,18 +64,12 @@ export class BatchSchedulerService {
|
|||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||
);
|
||||
|
||||
const task = cron.schedule(
|
||||
config.cron_schedule,
|
||||
async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
},
|
||||
{
|
||||
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
|
||||
}
|
||||
);
|
||||
const task = cron.schedule(config.cron_schedule, async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} catch (error) {
|
||||
|
|
@ -190,7 +183,7 @@ export class BatchSchedulerService {
|
|||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
execution_status: "FAILURE",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
|
|
@ -221,16 +214,9 @@ export class BatchSchedulerService {
|
|||
}
|
||||
|
||||
// 테이블별로 매핑을 그룹화
|
||||
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
|
||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||
const fixedMappingsGlobal: typeof 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}`;
|
||||
if (!tableGroups.has(key)) {
|
||||
tableGroups.set(key, []);
|
||||
|
|
@ -238,14 +224,6 @@ export class BatchSchedulerService {
|
|||
tableGroups.get(key)!.push(mapping);
|
||||
}
|
||||
|
||||
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
|
||||
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
|
||||
logger.warn(
|
||||
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
|
||||
);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
// 각 테이블 그룹별로 처리
|
||||
for (const [tableKey, mappings] of tableGroups) {
|
||||
try {
|
||||
|
|
@ -266,46 +244,10 @@ export class BatchSchedulerService {
|
|||
"./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 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
apiKey,
|
||||
firstMapping.from_api_key!,
|
||||
firstMapping.from_table_name,
|
||||
(firstMapping.from_api_method as
|
||||
| "GET"
|
||||
|
|
@ -324,36 +266,7 @@ export class BatchSchedulerService {
|
|||
);
|
||||
|
||||
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 {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
|
|
@ -385,11 +298,6 @@ export class BatchSchedulerService {
|
|||
const mappedData = fromData.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
|
||||
if (mapping.mapping_type === "fixed") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// DB → REST API 배치인지 확인
|
||||
if (
|
||||
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 자동 주입
|
||||
// - 배치 설정에 company_code가 있고
|
||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||
|
|
@ -483,14 +384,12 @@ export class BatchSchedulerService {
|
|||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
|
||||
// DB에 데이터 삽입
|
||||
insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as "internal" | "external",
|
||||
firstMapping.to_connection_id || undefined,
|
||||
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
|
||||
config.conflict_key || undefined
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -505,11 +404,4 @@ export class BatchSchedulerService {
|
|||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링 (scheduleBatch의 별칭)
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
return this.scheduleBatch(config);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
UpdateBatchConfigRequest,
|
||||
} from "../types/batchTypes";
|
||||
import { BatchExternalDbService } from "./batchExternalDbService";
|
||||
import { DbConnectionManager } from "./dbConnectionManager";
|
||||
|
||||
export class BatchService {
|
||||
/**
|
||||
|
|
@ -176,8 +177,8 @@ export class BatchService {
|
|||
// 배치 설정 생성
|
||||
const batchConfigResult = await client.query(
|
||||
`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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batchName,
|
||||
|
|
@ -185,10 +186,6 @@ export class BatchService {
|
|||
data.cronSchedule,
|
||||
data.isActive || "Y",
|
||||
data.companyCode,
|
||||
data.saveMode || "INSERT",
|
||||
data.conflictKey || null,
|
||||
data.authServiceName || null,
|
||||
data.dataArrayPath || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -205,38 +202,37 @@ export class BatchService {
|
|||
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,
|
||||
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)
|
||||
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())
|
||||
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, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
batchConfig.id,
|
||||
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
|
||||
mapping.from_connection_type,
|
||||
mapping.from_connection_id,
|
||||
mapping.from_table_name,
|
||||
mapping.from_column_name,
|
||||
mapping.from_column_type,
|
||||
mapping.from_api_url,
|
||||
mapping.from_api_key,
|
||||
mapping.from_api_method,
|
||||
mapping.from_api_param_type,
|
||||
mapping.from_api_param_name,
|
||||
mapping.from_api_param_value,
|
||||
mapping.from_api_param_source,
|
||||
mapping.from_api_body, // FROM REST API Body
|
||||
mapping.to_connection_type,
|
||||
mapping.to_connection_id,
|
||||
mapping.to_table_name,
|
||||
mapping.to_column_name,
|
||||
mapping.to_column_type,
|
||||
mapping.to_api_url,
|
||||
mapping.to_api_key,
|
||||
mapping.to_api_method,
|
||||
mapping.to_api_body,
|
||||
mapping.mapping_order || index + 1,
|
||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||
userId,
|
||||
]
|
||||
batchConfig.id,
|
||||
data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용
|
||||
mapping.from_connection_type,
|
||||
mapping.from_connection_id,
|
||||
mapping.from_table_name,
|
||||
mapping.from_column_name,
|
||||
mapping.from_column_type,
|
||||
mapping.from_api_url,
|
||||
mapping.from_api_key,
|
||||
mapping.from_api_method,
|
||||
mapping.from_api_param_type,
|
||||
mapping.from_api_param_name,
|
||||
mapping.from_api_param_value,
|
||||
mapping.from_api_param_source,
|
||||
mapping.from_api_body, // FROM REST API Body
|
||||
mapping.to_connection_type,
|
||||
mapping.to_connection_id,
|
||||
mapping.to_table_name,
|
||||
mapping.to_column_name,
|
||||
mapping.to_column_type,
|
||||
mapping.to_api_url,
|
||||
mapping.to_api_key,
|
||||
mapping.to_api_method,
|
||||
mapping.to_api_body,
|
||||
mapping.mapping_order || index + 1,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
mappings.push(mappingResult.rows[0]);
|
||||
}
|
||||
|
|
@ -316,22 +312,6 @@ export class BatchService {
|
|||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
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(
|
||||
|
|
@ -360,8 +340,8 @@ export class BatchService {
|
|||
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,
|
||||
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)
|
||||
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())
|
||||
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, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
|
|
@ -389,7 +369,6 @@ export class BatchService {
|
|||
mapping.to_api_method,
|
||||
mapping.to_api_body,
|
||||
mapping.mapping_order || index + 1,
|
||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -496,13 +475,7 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 테이블 조회
|
||||
const tables = await query<TableInfo>(
|
||||
`SELECT table_name, table_type, table_schema
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`
|
||||
);
|
||||
const tables = await DbConnectionManager.getInternalTables();
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
|
|
@ -536,13 +509,7 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 컬럼 조회
|
||||
const columns = await query<ColumnInfo>(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
const columns = await DbConnectionManager.getInternalColumns(tableName);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
|
|
@ -576,7 +543,7 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 데이터 조회
|
||||
const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
|
||||
const data = await DbConnectionManager.getInternalData(tableName, 10);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
|
|
@ -749,27 +716,19 @@ export class BatchService {
|
|||
|
||||
/**
|
||||
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||
* @param tableName 테이블명
|
||||
* @param data 삽입할 데이터 배열
|
||||
* @param connectionType 연결 타입 (internal/external)
|
||||
* @param connectionId 외부 연결 ID
|
||||
* @param saveMode 저장 모드 (INSERT/UPSERT)
|
||||
* @param conflictKey UPSERT 시 충돌 기준 컬럼명
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
tableName: string,
|
||||
data: any[],
|
||||
connectionType: "internal" | "external" = "internal",
|
||||
connectionId?: number,
|
||||
saveMode: "INSERT" | "UPSERT" = "INSERT",
|
||||
conflictKey?: string
|
||||
connectionId?: number
|
||||
): Promise<{
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}> {
|
||||
try {
|
||||
console.log(
|
||||
`[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
|
||||
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드`
|
||||
);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -781,54 +740,24 @@ export class BatchService {
|
|||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 삽입
|
||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||
for (const record of data) {
|
||||
try {
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const placeholders = values
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
.join(", ");
|
||||
|
||||
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(", ");
|
||||
|
||||
// updated_date 컬럼이 있으면 현재 시간으로 업데이트
|
||||
const hasUpdatedDate = columns.includes("updated_date");
|
||||
const finalUpdateSet = hasUpdatedDate
|
||||
? `${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})`;
|
||||
}
|
||||
const queryStr = `INSERT INTO ${tableName} (${columns.join(
|
||||
", "
|
||||
)}) VALUES (${placeholders})`;
|
||||
|
||||
await query(queryStr, values);
|
||||
successCount++;
|
||||
} catch (insertError) {
|
||||
console.error(
|
||||
`내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
|
||||
`내부 DB 데이터 삽입 실패 (${tableName}):`,
|
||||
insertError
|
||||
);
|
||||
failedCount++;
|
||||
|
|
@ -837,13 +766,7 @@ export class BatchService {
|
|||
|
||||
return { successCount, failedCount };
|
||||
} else if (connectionType === "external" && connectionId) {
|
||||
// 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
|
||||
if (saveMode === "UPSERT") {
|
||||
console.warn(
|
||||
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 외부 DB에 데이터 삽입
|
||||
const result = await BatchExternalDbService.insertDataToTable(
|
||||
connectionId,
|
||||
tableName,
|
||||
|
|
@ -863,7 +786,7 @@ export class BatchService {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
|
||||
console.error(`데이터 삽입 오류 (${tableName}):`, error);
|
||||
return { successCount: 0, failedCount: data ? data.length : 0 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ export interface CodeInfo {
|
|||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid?: number | null; // 메뉴 기반 코드 관리용
|
||||
parent_code_value?: string | null; // 계층구조: 부모 코드값
|
||||
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
||||
created_date?: Date | null;
|
||||
created_by?: string | null;
|
||||
updated_date?: Date | null;
|
||||
|
|
@ -63,8 +61,6 @@ export interface CreateCodeData {
|
|||
description?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: string;
|
||||
parentCodeValue?: string; // 계층구조: 부모 코드값
|
||||
depth?: number; // 계층구조: 깊이 (1, 2, 3단계)
|
||||
}
|
||||
|
||||
export class CommonCodeService {
|
||||
|
|
@ -90,12 +86,11 @@ export class CommonCodeService {
|
|||
}
|
||||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||
// company_code = '*'인 공통 데이터도 함께 조회
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
values.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
// 최고 관리자는 모든 데이터 조회 가능
|
||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||
|
|
@ -121,7 +116,7 @@ export class CommonCodeService {
|
|||
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
// code_category 테이블에서만 조회 (comm_code 제거)
|
||||
// 카테고리 조회
|
||||
const categories = await query<CodeCategory>(
|
||||
`SELECT * FROM code_category
|
||||
${whereClause}
|
||||
|
|
@ -139,7 +134,7 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
logger.info(
|
||||
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -229,7 +224,7 @@ export class CommonCodeService {
|
|||
paramIndex,
|
||||
});
|
||||
|
||||
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
||||
// 코드 조회
|
||||
const codes = await query<CodeInfo>(
|
||||
`SELECT * FROM code_info
|
||||
${whereClause}
|
||||
|
|
@ -247,9 +242,20 @@ export class CommonCodeService {
|
|||
const total = parseInt(countResult?.count || "0");
|
||||
|
||||
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 };
|
||||
} catch (error) {
|
||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||
|
|
@ -409,22 +415,11 @@ export class CommonCodeService {
|
|||
menuObjid: number
|
||||
) {
|
||||
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>(
|
||||
`INSERT INTO code_info
|
||||
(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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
||||
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, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
categoryCode,
|
||||
|
|
@ -435,15 +430,13 @@ export class CommonCodeService {
|
|||
data.sortOrder || 0,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
data.parentCodeValue || null,
|
||||
depth,
|
||||
createdBy,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})`
|
||||
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})`
|
||||
);
|
||||
return code;
|
||||
} catch (error) {
|
||||
|
|
@ -508,24 +501,6 @@ export class CommonCodeService {
|
|||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
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 절 구성
|
||||
let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`;
|
||||
|
|
@ -882,170 +857,4 @@ export class CommonCodeService {
|
|||
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":
|
||||
// base_price = true인 행 찾기
|
||||
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
|
||||
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
||||
break;
|
||||
|
||||
case "current_date":
|
||||
|
|
@ -128,21 +128,18 @@ class DataService {
|
|||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||
|
||||
selectedRow =
|
||||
rows.find((row) => {
|
||||
const startDate = row.start_date
|
||||
? new Date(row.start_date)
|
||||
: null;
|
||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||
selectedRow = rows.find(row => {
|
||||
const startDate = row.start_date ? new Date(row.start_date) : null;
|
||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||
|
||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const afterStart = !startDate || today >= startDate;
|
||||
const beforeEnd = !endDate || today <= endDate;
|
||||
const afterStart = !startDate || today >= startDate;
|
||||
const beforeEnd = !endDate || today <= endDate;
|
||||
|
||||
return afterStart && beforeEnd;
|
||||
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||
return afterStart && beforeEnd;
|
||||
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||
break;
|
||||
|
||||
default:
|
||||
|
|
@ -233,17 +230,12 @@ class DataService {
|
|||
|
||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(
|
||||
tableName,
|
||||
"company_code"
|
||||
);
|
||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
queryParams.push(userCompany);
|
||||
paramIndex++;
|
||||
console.log(
|
||||
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
|
||||
);
|
||||
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,10 +246,7 @@ class DataService {
|
|||
key !== "limit" &&
|
||||
key !== "offset" &&
|
||||
key !== "orderBy" &&
|
||||
key !== "userLang" &&
|
||||
key !== "page" &&
|
||||
key !== "pageSize" &&
|
||||
key !== "size"
|
||||
key !== "userLang"
|
||||
) {
|
||||
// 컬럼명 검증 (SQL 인젝션 방지)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
|
|
@ -519,8 +508,7 @@ class DataService {
|
|||
const entityJoinService = new EntityJoinService();
|
||||
|
||||
// Entity Join 구성 감지
|
||||
const joinConfigs =
|
||||
await entityJoinService.detectEntityJoins(tableName);
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
if (joinConfigs.length > 0) {
|
||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||
|
|
@ -530,7 +518,7 @@ class DataService {
|
|||
tableName,
|
||||
joinConfigs,
|
||||
["*"],
|
||||
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||
);
|
||||
|
||||
const result = await pool.query(joinQuery, [id]);
|
||||
|
|
@ -545,14 +533,14 @@ class DataService {
|
|||
|
||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map((row) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
|
|
@ -563,10 +551,7 @@ class DataService {
|
|||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(
|
||||
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
|
||||
normalizedRows[0]
|
||||
);
|
||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
||||
|
||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||
if (groupByColumns.length > 0) {
|
||||
|
|
@ -589,10 +574,7 @@ class DataService {
|
|||
if (groupConditions.length > 0) {
|
||||
const groupWhereClause = groupConditions.join(" AND ");
|
||||
|
||||
console.log(
|
||||
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
|
||||
groupValues
|
||||
);
|
||||
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
||||
|
||||
// 그룹핑 기준으로 모든 레코드 조회
|
||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||
|
|
@ -605,9 +587,7 @@ class DataService {
|
|||
const groupResult = await pool.query(groupQuery, groupValues);
|
||||
|
||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||
console.log(
|
||||
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`
|
||||
);
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -662,8 +642,7 @@ class DataService {
|
|||
dataFilter?: any, // 🆕 데이터 필터
|
||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||
deduplication?: {
|
||||
// 🆕 중복 제거 설정
|
||||
deduplication?: { // 🆕 중복 제거 설정
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
|
|
@ -687,8 +666,7 @@ class DataService {
|
|||
if (enableEntityJoin) {
|
||||
try {
|
||||
const { entityJoinService } = await import("./entityJoinService");
|
||||
const joinConfigs =
|
||||
await entityJoinService.detectEntityJoins(rightTable);
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
||||
|
||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||
if (displayColumns && Array.isArray(displayColumns)) {
|
||||
|
|
@ -696,8 +674,8 @@ class DataService {
|
|||
const tableColumns: Record<string, Set<string>> = {};
|
||||
|
||||
for (const col of displayColumns) {
|
||||
if (col.name && col.name.includes(".")) {
|
||||
const [refTable, refColumn] = col.name.split(".");
|
||||
if (col.name && col.name.includes('.')) {
|
||||
const [refTable, refColumn] = col.name.split('.');
|
||||
if (!tableColumns[refTable]) {
|
||||
tableColumns[refTable] = new Set();
|
||||
}
|
||||
|
|
@ -708,18 +686,14 @@ class DataService {
|
|||
// 각 테이블별로 처리
|
||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||
// 이미 조인 설정에 있는지 확인
|
||||
const existingJoins = joinConfigs.filter(
|
||||
(jc) => jc.referenceTable === refTable
|
||||
);
|
||||
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
||||
|
||||
if (existingJoins.length > 0) {
|
||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||
for (const refColumn of refColumns) {
|
||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||
const existingJoin = existingJoins.find(
|
||||
(jc) =>
|
||||
jc.displayColumns.length === 1 &&
|
||||
jc.displayColumns[0] === refColumn
|
||||
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
||||
);
|
||||
|
||||
if (!existingJoin) {
|
||||
|
|
@ -734,9 +708,7 @@ class DataService {
|
|||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||
};
|
||||
joinConfigs.push(newJoin);
|
||||
console.log(
|
||||
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
|
||||
);
|
||||
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -746,9 +718,7 @@ class DataService {
|
|||
}
|
||||
|
||||
if (joinConfigs.length > 0) {
|
||||
console.log(
|
||||
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
|
||||
);
|
||||
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
|
|
@ -765,10 +735,7 @@ class DataService {
|
|||
|
||||
// 회사별 필터링
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(
|
||||
rightTable,
|
||||
"company_code"
|
||||
);
|
||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||
values.push(userCompany);
|
||||
|
|
@ -777,49 +744,33 @@ class DataService {
|
|||
}
|
||||
|
||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||
if (
|
||||
dataFilter &&
|
||||
dataFilter.enabled &&
|
||||
dataFilter.filters &&
|
||||
dataFilter.filters.length > 0
|
||||
) {
|
||||
const { buildDataFilterWhereClause } = await import(
|
||||
"../utils/dataFilterUtil"
|
||||
);
|
||||
const filterResult = buildDataFilterWhereClause(
|
||||
dataFilter,
|
||||
"main",
|
||||
paramIndex
|
||||
);
|
||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
||||
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
||||
if (filterResult.whereClause) {
|
||||
whereConditions.push(filterResult.whereClause);
|
||||
values.push(...filterResult.params);
|
||||
paramIndex += filterResult.params.length;
|
||||
console.log(
|
||||
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
|
||||
filterResult.whereClause
|
||||
);
|
||||
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||
|
||||
// Entity 조인 쿼리 빌드
|
||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||
const selectColumns = ["*"];
|
||||
|
||||
const { query: finalQuery, aliasMap } =
|
||||
entityJoinService.buildJoinQuery(
|
||||
rightTable,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
"",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
||||
rightTable,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
"",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
||||
console.log(`🔍 파라미터:`, values);
|
||||
|
|
@ -828,13 +779,13 @@ class DataService {
|
|||
|
||||
// 🔧 날짜 타입 타임존 문제 해결
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map((row) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
|
|
@ -845,20 +796,14 @@ class DataService {
|
|||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(
|
||||
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
|
||||
);
|
||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
||||
|
||||
// 🆕 중복 제거 처리
|
||||
let finalData = normalizedRows;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(
|
||||
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||
);
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||
console.log(
|
||||
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`
|
||||
);
|
||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -893,40 +838,23 @@ class DataService {
|
|||
|
||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(
|
||||
rightTable,
|
||||
"company_code"
|
||||
);
|
||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||
values.push(userCompany);
|
||||
paramIndex++;
|
||||
console.log(
|
||||
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
|
||||
);
|
||||
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||
if (
|
||||
dataFilter &&
|
||||
dataFilter.enabled &&
|
||||
dataFilter.filters &&
|
||||
dataFilter.filters.length > 0
|
||||
) {
|
||||
const filterResult = buildDataFilterWhereClause(
|
||||
dataFilter,
|
||||
"r",
|
||||
paramIndex
|
||||
);
|
||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
||||
if (filterResult.whereClause) {
|
||||
whereConditions.push(filterResult.whereClause);
|
||||
values.push(...filterResult.params);
|
||||
paramIndex += filterResult.params.length;
|
||||
console.log(
|
||||
`🔍 데이터 필터 적용 (${rightTable}):`,
|
||||
filterResult.whereClause
|
||||
);
|
||||
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -943,13 +871,9 @@ class DataService {
|
|||
// 🆕 중복 제거 처리
|
||||
let finalData = result;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(
|
||||
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||
);
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(result, deduplication);
|
||||
console.log(
|
||||
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`
|
||||
);
|
||||
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -983,31 +907,8 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||
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 columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||
|
||||
|
|
@ -1050,32 +951,9 @@ class DataService {
|
|||
|
||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||
const relationInfo = data._relationInfo;
|
||||
let cleanData = { ...data };
|
||||
const cleanData = { ...data };
|
||||
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 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
|
|
@ -1115,14 +993,8 @@ class DataService {
|
|||
}
|
||||
|
||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||
if (
|
||||
relationInfo &&
|
||||
relationInfo.rightTable &&
|
||||
relationInfo.leftColumn &&
|
||||
relationInfo.rightColumn
|
||||
) {
|
||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
|
||||
relationInfo;
|
||||
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
||||
const newLeftValue = cleanData[leftColumn];
|
||||
|
||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||
|
|
@ -1140,13 +1012,8 @@ class DataService {
|
|||
SET "${rightColumn}" = $1
|
||||
WHERE "${rightColumn}" = $2
|
||||
`;
|
||||
const updateResult = await query(updateRelatedQuery, [
|
||||
newLeftValue,
|
||||
oldLeftValue,
|
||||
]);
|
||||
console.log(
|
||||
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
|
||||
);
|
||||
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
||||
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
||||
} catch (relError) {
|
||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||
|
|
@ -1192,23 +1059,14 @@ class DataService {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
||||
pkColumns: pkResult.map((r) => r.attname),
|
||||
pkCount: pkResult.length,
|
||||
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
||||
inputIdType: typeof id,
|
||||
});
|
||||
|
||||
let whereClauses: string[] = [];
|
||||
let params: any[] = [];
|
||||
|
||||
if (pkResult.length > 1) {
|
||||
// 복합키인 경우: id가 객체여야 함
|
||||
console.log(
|
||||
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
|
||||
);
|
||||
console.log(`🔑 복합키 테이블: ${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' }
|
||||
pkResult.forEach((pk, index) => {
|
||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||
|
|
@ -1223,34 +1081,18 @@ class DataService {
|
|||
// 단일키인 경우
|
||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||
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);
|
||||
|
||||
const result = await query<any>(queryText, params);
|
||||
|
||||
// 삭제된 행이 없으면 실패 처리
|
||||
if (result.length === 0) {
|
||||
console.warn(
|
||||
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
||||
{ whereClauses, params }
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||
);
|
||||
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0], // 삭제된 레코드 정보 반환
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||
|
|
@ -1264,14 +1106,10 @@ class DataService {
|
|||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
* @param tableName 테이블명
|
||||
* @param filterConditions 삭제 조건
|
||||
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||||
*/
|
||||
async deleteGroupRecords(
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>,
|
||||
userCompany?: string
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||
try {
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
|
|
@ -1283,7 +1121,6 @@ class DataService {
|
|||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 사용자 필터 조건 추가
|
||||
for (const [key, value] of Object.entries(filterConditions)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
|
|
@ -1291,31 +1128,13 @@ class DataService {
|
|||
}
|
||||
|
||||
if (whereConditions.length === 0) {
|
||||
return {
|
||||
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}`);
|
||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, {
|
||||
tableName,
|
||||
conditions: filterConditions,
|
||||
userCompany,
|
||||
whereClause,
|
||||
});
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
||||
|
||||
const result = await pool.query(deleteQuery, whereValues);
|
||||
|
||||
|
|
@ -1344,9 +1163,7 @@ class DataService {
|
|||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
): Promise<
|
||||
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||
> {
|
||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
|
|
@ -1385,10 +1202,7 @@ class DataService {
|
|||
const whereClause = whereConditions.join(" AND ");
|
||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||
|
||||
console.log(`📋 기존 레코드 조회:`, {
|
||||
query: selectQuery,
|
||||
values: whereValues,
|
||||
});
|
||||
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
||||
|
||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||
|
||||
|
|
@ -1404,8 +1218,8 @@ class DataService {
|
|||
if (value == null) return value;
|
||||
|
||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
||||
}
|
||||
|
||||
return value;
|
||||
|
|
@ -1442,11 +1256,8 @@ class DataService {
|
|||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
// Date 타입 처리
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
|
|
@ -1461,8 +1272,7 @@ class DataService {
|
|||
let updateParamIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(fullRecord)) {
|
||||
if (key !== pkColumn) {
|
||||
// Primary Key는 업데이트하지 않음
|
||||
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||
updateValues.push(value);
|
||||
updateParamIndex++;
|
||||
|
|
@ -1484,21 +1294,15 @@ class DataService {
|
|||
// INSERT: 기존 레코드가 없으면 삽입
|
||||
|
||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||
const recordWithMeta: Record<string, any> = {
|
||||
...recordWithoutCreatedDate,
|
||||
...fullRecord,
|
||||
id: uuidv4(), // 새 ID 생성
|
||||
created_date: "NOW()",
|
||||
updated_date: "NOW()",
|
||||
};
|
||||
|
||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||
if (
|
||||
!recordWithMeta.company_code &&
|
||||
userCompany &&
|
||||
userCompany !== "*"
|
||||
) {
|
||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||
recordWithMeta.company_code = userCompany;
|
||||
}
|
||||
|
||||
|
|
@ -1507,8 +1311,8 @@ class DataService {
|
|||
recordWithMeta.writer = userId;
|
||||
}
|
||||
|
||||
const insertFields = Object.keys(recordWithMeta).filter(
|
||||
(key) => recordWithMeta[key] !== "NOW()"
|
||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
||||
recordWithMeta[key] !== "NOW()"
|
||||
);
|
||||
const insertPlaceholders: string[] = [];
|
||||
const insertValues: any[] = [];
|
||||
|
|
@ -1525,16 +1329,11 @@ class DataService {
|
|||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
|
||||
.map((f) => `"${f}"`)
|
||||
.join(", ")})
|
||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
console.log(`➕ INSERT 쿼리:`, {
|
||||
query: insertQuery,
|
||||
values: insertValues,
|
||||
});
|
||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
||||
|
||||
await pool.query(insertQuery, insertValues);
|
||||
inserted++;
|
||||
|
|
@ -1555,11 +1354,8 @@ class DataService {
|
|||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||
}
|
||||
|
||||
return String(existingValue) === String(newValue);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -134,8 +134,8 @@ export class EntityJoinService {
|
|||
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||
);
|
||||
} else {
|
||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
|
||||
logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`);
|
||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
|
||||
logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`);
|
||||
|
||||
// 참조 테이블의 모든 컬럼 이름 가져오기
|
||||
const tableColumnsResult = await query<{ column_name: string }>(
|
||||
|
|
@ -148,34 +148,10 @@ export class EntityJoinService {
|
|||
);
|
||||
|
||||
if (tableColumnsResult.length > 0) {
|
||||
const allColumns = 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];
|
||||
|
||||
displayColumns = tableColumnsResult.map((col) => col.column_name);
|
||||
logger.info(
|
||||
`✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
|
||||
`✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
|
||||
displayColumns.join(", ")
|
||||
);
|
||||
} else {
|
||||
// 테이블 컬럼을 못 찾으면 기본값 사용
|
||||
|
|
@ -186,13 +162,8 @@ export class EntityJoinService {
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
||||
// 단일 컬럼: manager + user_name → manager_user_name
|
||||
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
||||
const firstDisplayColumn = displayColumns[0] || "name";
|
||||
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
||||
|
||||
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
const aliasColumn = `${column.column_name}_name`;
|
||||
|
||||
const joinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
|
|
@ -432,25 +403,18 @@ export class EntityJoinService {
|
|||
const fromClause = `FROM ${tableName} main`;
|
||||
|
||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
|
||||
const joinClauses = uniqueReferenceTableConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
if (config.referenceTable === "user_info") {
|
||||
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`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.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;
|
||||
activeConnections = 0;
|
||||
maxConnections: number;
|
||||
private isPoolClosed = false;
|
||||
|
||||
constructor(config: ExternalDbConnection) {
|
||||
this.connectionId = config.id!;
|
||||
|
|
@ -132,9 +131,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
|||
waitForConnections: true,
|
||||
queueLimit: 0,
|
||||
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||
// 연결 유지 및 자동 재연결 설정
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
|
||||
ssl:
|
||||
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
|
@ -157,33 +153,11 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
|||
|
||||
async query(sql: string, params?: any[]): Promise<any> {
|
||||
this.lastUsedAt = new Date();
|
||||
|
||||
// 연결 풀이 닫힌 상태인지 확인
|
||||
if (this.isPoolClosed) {
|
||||
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
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> {
|
||||
this.isPoolClosed = true;
|
||||
await this.pool.end();
|
||||
logger.info(
|
||||
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||
|
|
@ -191,10 +165,6 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
|||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
// 연결 풀이 닫혔으면 비정상
|
||||
if (this.isPoolClosed) {
|
||||
return false;
|
||||
}
|
||||
return this.activeConnections < this.maxConnections;
|
||||
}
|
||||
}
|
||||
|
|
@ -260,11 +230,9 @@ export class ExternalDbConnectionPoolService {
|
|||
): Promise<ConnectionPoolWrapper> {
|
||||
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||
|
||||
// DB 연결 정보 조회 (실제 비밀번호 포함)
|
||||
// DB 연결 정보 조회
|
||||
const connectionResult =
|
||||
await ExternalDbConnectionService.getConnectionByIdWithPassword(
|
||||
connectionId
|
||||
);
|
||||
await ExternalDbConnectionService.getConnectionById(connectionId);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||
|
|
@ -328,19 +296,16 @@ export class ExternalDbConnectionPoolService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직)
|
||||
* 쿼리 실행 (자동으로 연결 풀 관리)
|
||||
*/
|
||||
async executeQuery(
|
||||
connectionId: number,
|
||||
sql: string,
|
||||
params?: any[],
|
||||
retryCount = 0
|
||||
params?: any[]
|
||||
): Promise<any> {
|
||||
const MAX_RETRIES = 2;
|
||||
const pool = await this.getPool(connectionId);
|
||||
|
||||
try {
|
||||
const pool = await this.getPool(connectionId);
|
||||
|
||||
logger.debug(
|
||||
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||
);
|
||||
|
|
@ -349,29 +314,7 @@ export class ExternalDbConnectionPoolService {
|
|||
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||
);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// 연결 끊김 오류인 경우 재시도
|
||||
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);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
|
|||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(userCompanyCode);
|
||||
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
||||
} else if (userCompanyCode === "*") {
|
||||
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
||||
// 필터가 있으면 적용
|
||||
if (filter.company_code) {
|
||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(filter.company_code);
|
||||
}
|
||||
} else {
|
||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||
if (filter.company_code) {
|
||||
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(filter.company_code);
|
||||
}
|
||||
}
|
||||
|
||||
// 필터 조건 적용
|
||||
if (filter.db_type) {
|
||||
whereConditions.push(`e.db_type = $${paramIndex++}`);
|
||||
whereConditions.push(`db_type = $${paramIndex++}`);
|
||||
params.push(filter.db_type);
|
||||
}
|
||||
|
||||
if (filter.is_active) {
|
||||
whereConditions.push(`e.is_active = $${paramIndex++}`);
|
||||
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||
params.push(filter.is_active);
|
||||
}
|
||||
|
||||
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
||||
if (filter.search && filter.search.trim()) {
|
||||
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()}%`);
|
||||
paramIndex++;
|
||||
|
|
@ -72,12 +72,9 @@ export class ExternalDbConnectionService {
|
|||
: "";
|
||||
|
||||
const connections = await query<any>(
|
||||
`SELECT e.*,
|
||||
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
|
||||
`SELECT * FROM external_db_connections
|
||||
${whereClause}
|
||||
ORDER BY e.is_active DESC, e.connection_name ASC`,
|
||||
ORDER BY is_active DESC, connection_name ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,17 +31,15 @@ export class ExternalRestApiConnectionService {
|
|||
try {
|
||||
let query = `
|
||||
SELECT
|
||||
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
|
||||
e.default_method,
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method,
|
||||
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||
e.default_request_body AS default_body,
|
||||
e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
|
||||
e.company_code, e.is_active, e.created_date, e.created_by,
|
||||
e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.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 e
|
||||
LEFT JOIN company_mng c ON e.company_code = c.company_code
|
||||
default_request_body AS default_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
FROM external_rest_api_connections
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
|
|
@ -50,7 +48,7 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
query += ` AND e.company_code = $${paramIndex}`;
|
||||
query += ` AND company_code = $${paramIndex}`;
|
||||
params.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
||||
|
|
@ -58,14 +56,14 @@ export class ExternalRestApiConnectionService {
|
|||
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
||||
// 필터가 있으면 적용
|
||||
if (filter.company_code) {
|
||||
query += ` AND e.company_code = $${paramIndex}`;
|
||||
query += ` AND company_code = $${paramIndex}`;
|
||||
params.push(filter.company_code);
|
||||
paramIndex++;
|
||||
}
|
||||
} else {
|
||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||
if (filter.company_code) {
|
||||
query += ` AND e.company_code = $${paramIndex}`;
|
||||
query += ` AND company_code = $${paramIndex}`;
|
||||
params.push(filter.company_code);
|
||||
paramIndex++;
|
||||
}
|
||||
|
|
@ -73,14 +71,14 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
// 활성 상태 필터
|
||||
if (filter.is_active) {
|
||||
query += ` AND e.is_active = $${paramIndex}`;
|
||||
query += ` AND is_active = $${paramIndex}`;
|
||||
params.push(filter.is_active);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 인증 타입 필터
|
||||
if (filter.auth_type) {
|
||||
query += ` AND e.auth_type = $${paramIndex}`;
|
||||
query += ` AND auth_type = $${paramIndex}`;
|
||||
params.push(filter.auth_type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
|
@ -88,9 +86,9 @@ export class ExternalRestApiConnectionService {
|
|||
// 검색어 필터 (연결명, 설명, URL)
|
||||
if (filter.search) {
|
||||
query += ` AND (
|
||||
e.connection_name ILIKE $${paramIndex} OR
|
||||
e.description ILIKE $${paramIndex} OR
|
||||
e.base_url ILIKE $${paramIndex}
|
||||
connection_name ILIKE $${paramIndex} OR
|
||||
description ILIKE $${paramIndex} OR
|
||||
base_url ILIKE $${paramIndex}
|
||||
)`;
|
||||
params.push(`%${filter.search}%`);
|
||||
paramIndex++;
|
||||
|
|
@ -209,8 +207,8 @@ export class ExternalRestApiConnectionService {
|
|||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by, save_to_history
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -230,13 +228,11 @@ export class ExternalRestApiConnectionService {
|
|||
data.company_code || "*",
|
||||
data.is_active || "Y",
|
||||
data.created_by || "system",
|
||||
data.save_to_history || "N",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||
connection_name: data.connection_name,
|
||||
company_code: data.company_code,
|
||||
default_method: data.default_method,
|
||||
endpoint_path: data.endpoint_path,
|
||||
base_url: data.base_url,
|
||||
|
|
@ -378,12 +374,6 @@ export class ExternalRestApiConnectionService {
|
|||
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) {
|
||||
updateFields.push(`updated_by = $${paramIndex}`);
|
||||
params.push(data.updated_by);
|
||||
|
|
@ -1101,150 +1091,4 @@ export class ExternalRestApiConnectionService {
|
|||
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 처리 (기존 로직)
|
||||
return await db.transaction(async (client) => {
|
||||
try {
|
||||
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||
userId || "system",
|
||||
]);
|
||||
|
||||
// 1. 단계 정보 조회
|
||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||
const toStep = await this.flowStepService.findById(toStepId);
|
||||
|
|
@ -689,14 +684,6 @@ export class FlowDataMoveService {
|
|||
dbConnectionId,
|
||||
async (externalClient, dbType) => {
|
||||
try {
|
||||
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||
if (dbType.toLowerCase() === "postgresql") {
|
||||
await externalClient.query(
|
||||
"SELECT set_config('app.user_id', $1, true)",
|
||||
[userId || "system"]
|
||||
);
|
||||
}
|
||||
|
||||
// 1. 단계 정보 조회 (내부 DB에서)
|
||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||
const toStep = await this.flowStepService.findById(toStepId);
|
||||
|
|
|
|||
|
|
@ -27,21 +27,13 @@ export class FlowDefinitionService {
|
|||
tableName: request.tableName,
|
||||
dbSourceType: request.dbSourceType,
|
||||
dbConnectionId: request.dbConnectionId,
|
||||
restApiConnectionId: request.restApiConnectionId,
|
||||
restApiEndpoint: request.restApiEndpoint,
|
||||
restApiJsonPath: request.restApiJsonPath,
|
||||
restApiConnections: request.restApiConnections,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const query = `
|
||||
INSERT INTO flow_definition (
|
||||
name, description, table_name, db_source_type, db_connection_id,
|
||||
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)
|
||||
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -51,10 +43,6 @@ export class FlowDefinitionService {
|
|||
request.tableName || null,
|
||||
request.dbSourceType || "internal",
|
||||
request.dbConnectionId || null,
|
||||
request.restApiConnectionId || null,
|
||||
request.restApiEndpoint || null,
|
||||
request.restApiJsonPath || "response",
|
||||
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
|
||||
companyCode,
|
||||
userId,
|
||||
];
|
||||
|
|
@ -211,19 +199,6 @@ export class FlowDefinitionService {
|
|||
* DB 행을 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 {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
|
|
@ -231,12 +206,6 @@ export class FlowDefinitionService {
|
|||
tableName: row.table_name,
|
||||
dbSourceType: row.db_source_type || "internal",
|
||||
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 || "*",
|
||||
isActive: row.is_active,
|
||||
createdBy: row.created_by,
|
||||
|
|
|
|||
|
|
@ -263,139 +263,4 @@ export class FlowExecutionService {
|
|||
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);
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
if (result.rowCount === 0) return null;
|
||||
|
||||
const rule = result.rows[0];
|
||||
|
||||
|
|
@ -898,15 +896,14 @@ class NumberingRuleService {
|
|||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
|
@ -959,15 +956,14 @@ class NumberingRuleService {
|
|||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (자동 증가 숫자 - 다음 번호 사용)
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
// 순번 (자동 증가 숫자)
|
||||
const length = autoConfig.sequenceLength || 4;
|
||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// 숫자 (고정 자릿수)
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,23 +234,10 @@ export class ReportService {
|
|||
`;
|
||||
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 {
|
||||
report,
|
||||
layout,
|
||||
queries: queries || [],
|
||||
menuObjids,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -490,12 +477,6 @@ export class ReportService {
|
|||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`;
|
||||
|
||||
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
|
||||
const componentsData =
|
||||
typeof originalLayout.components === "string"
|
||||
? originalLayout.components
|
||||
: JSON.stringify(originalLayout.components);
|
||||
|
||||
await client.query(copyLayoutQuery, [
|
||||
newLayoutId,
|
||||
newReportId,
|
||||
|
|
@ -506,7 +487,7 @@ export class ReportService {
|
|||
originalLayout.margin_bottom,
|
||||
originalLayout.margin_left,
|
||||
originalLayout.margin_right,
|
||||
componentsData,
|
||||
JSON.stringify(originalLayout.components),
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
|
@ -580,7 +561,7 @@ export class ReportService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조
|
||||
* 레이아웃 저장 (쿼리 포함)
|
||||
*/
|
||||
async saveLayout(
|
||||
reportId: string,
|
||||
|
|
@ -588,19 +569,6 @@ export class ReportService {
|
|||
userId: string
|
||||
): Promise<boolean> {
|
||||
return transaction(async (client) => {
|
||||
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
|
||||
const firstPage = data.layoutConfig.pages[0];
|
||||
const canvasWidth = firstPage?.width || 210;
|
||||
const canvasHeight = firstPage?.height || 297;
|
||||
const pageOrientation =
|
||||
canvasWidth > canvasHeight ? "landscape" : "portrait";
|
||||
const margins = firstPage?.margins || {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
};
|
||||
|
||||
// 1. 레이아웃 저장
|
||||
const existingQuery = `
|
||||
SELECT layout_id FROM report_layout WHERE report_id = $1
|
||||
|
|
@ -608,7 +576,7 @@ export class ReportService {
|
|||
const existing = await client.query(existingQuery, [reportId]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
|
||||
// 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE report_layout
|
||||
SET
|
||||
|
|
@ -626,14 +594,14 @@ export class ReportService {
|
|||
`;
|
||||
|
||||
await client.query(updateQuery, [
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
userId,
|
||||
reportId,
|
||||
]);
|
||||
|
|
@ -659,14 +627,14 @@ export class ReportService {
|
|||
await client.query(insertQuery, [
|
||||
layoutId,
|
||||
reportId,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,24 +47,9 @@ export class RiskAlertService {
|
|||
|
||||
console.log('✅ 기상청 특보 현황 API 응답 수신 완료');
|
||||
|
||||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||||
// 텍스트 응답 파싱 (EUC-KR 인코딩)
|
||||
const iconv = require('iconv-lite');
|
||||
const buffer = Buffer.from(warningResponse.data);
|
||||
|
||||
// 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 인코딩으로 디코딩');
|
||||
}
|
||||
const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR');
|
||||
|
||||
if (typeof responseText === 'string' && responseText.includes('#START7777')) {
|
||||
const lines = responseText.split('\n');
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue