Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
cbde0894c4
|
|
@ -0,0 +1,310 @@
|
||||||
|
# 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` - 스티키 헤더 테이블
|
||||||
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,28 +1,36 @@
|
||||||
# 프로젝트: Digital Twin 에디터 안정화
|
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
|
||||||
|
|
||||||
## 핵심 기능
|
## 핵심 기능
|
||||||
|
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||||
1. `DigitalTwinEditor` 버그 수정
|
2. **백엔드 로직 개선**:
|
||||||
2. 비동기 함수 입력값 유효성 검증 강화
|
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||||
|
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||||
|
3. **프론트엔드 UI 개선**:
|
||||||
|
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||||
|
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||||
|
|
||||||
## 테스트 계획
|
## 테스트 계획
|
||||||
|
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||||
|
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||||
|
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||||
|
|
||||||
### 1단계: 긴급 버그 수정
|
### 2단계: 백엔드 로직 구현
|
||||||
|
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||||
|
- [x] 커넥션 상세 조회 API 확인
|
||||||
|
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||||
|
|
||||||
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
|
### 3단계: 프론트엔드 구현
|
||||||
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
|
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||||
|
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||||
|
|
||||||
### 2단계: 잠재적 문제 점검
|
## 에러 처리 계획
|
||||||
|
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||||
|
|
||||||
## 진행 상태
|
## 진행 상태
|
||||||
|
- [완료] 모든 단계 구현 완료
|
||||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
|
|
||||||
"sentAt": "2025-10-22T07:13:30.905Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "Fwd: ㄴ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
|
|
||||||
"sentAt": "2025-10-13T01:08:34.764Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "제목 없음",
|
|
||||||
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
|
|
||||||
"templateId": "template-1760315158387",
|
|
||||||
"templateName": "테스트2",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
|
|
||||||
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
|
|
||||||
"sentAt": "2025-10-02T07:50:25.817Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅣ;ㅏㅓ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
|
|
||||||
"sentAt": "2025-10-22T07:18:18.240Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
|
|
||||||
"sentAt": "2025-10-02T07:44:38.128Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "asd",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
|
||||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
|
||||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
|
|
||||||
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
|
|
||||||
"sentAt": "2025-10-22T04:27:51.044Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"\"이희진\" <zian9227@naver.com>"
|
|
||||||
],
|
|
||||||
"subject": "Re: ㅅㄷㄴㅅ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
|
|
||||||
"sentAt": "2025-10-22T03:49:48.461Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
|
|
||||||
"sentAt": "2025-10-13T00:53:55.193Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "제목 없음",
|
|
||||||
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
|
|
||||||
"templateId": "template-1760315158387",
|
|
||||||
"templateName": "테스트2",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "한글.txt",
|
|
||||||
"originalName": "한글.txt",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
|
|
||||||
"mimetype": "text/plain"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
|
|
||||||
"sentAt": "2025-10-02T08:22:14.721Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
|
|
||||||
"sentAt": "2025-10-02T08:41:42.086Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글테스트",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
|
|
||||||
"sentAt": "2025-10-22T07:21:13.723Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{
|
|
||||||
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
|
|
||||||
"sentAt": "2025-10-02T08:57:48.412Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_개선사항_문서.md",
|
|
||||||
"originalName": "UI_개선사항_문서.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지33.jpg",
|
|
||||||
"originalName": "test용 이미지33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지2.png",
|
|
||||||
"originalName": "test용 이미지2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
|
|
||||||
"sentAt": "2025-10-02T08:49:30.356Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글2",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
|
|
||||||
"sentAt": "2025-10-02T08:47:03.481Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글테스트222",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
|
|
||||||
"sentAt": "2025-10-22T04:28:42.686Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"\"권은아\" <chna8137s@gmail.com>"
|
|
||||||
],
|
|
||||||
"subject": "Re: 매우 졸린 오후예요",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지2.png",
|
|
||||||
"originalName": "test용 이미지2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"chna8137s@gmail.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
|
|
||||||
"sentAt": "2025-10-02T08:48:29.740Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글한글",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
|
|
||||||
"sentAt": "2025-10-13T00:21:51.799Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "test용입니다.",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"templateId": "template-1759302346758",
|
|
||||||
"templateName": "test",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
|
|
||||||
"sentAt": "2025-10-22T04:24:54.126Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"\"DHS\" <ddhhss0603@gmail.com>"
|
|
||||||
],
|
|
||||||
"subject": "Re: 안녕하세여",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"ddhhss0603@gmail.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -8,6 +8,7 @@ import path from "path";
|
||||||
import config from "./config/environment";
|
import config from "./config/environment";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { errorHandler } from "./middleware/errorHandler";
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||||
|
|
||||||
// 라우터 임포트
|
// 라우터 임포트
|
||||||
import authRoutes from "./routes/authRoutes";
|
import authRoutes from "./routes/authRoutes";
|
||||||
|
|
@ -70,7 +71,15 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
|
||||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
|
import 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 { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -165,6 +174,10 @@ const limiter = rateLimit({
|
||||||
});
|
});
|
||||||
app.use("/api/", limiter);
|
app.use("/api/", limiter);
|
||||||
|
|
||||||
|
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||||
|
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||||
|
app.use("/api/", refreshTokenIfNeeded);
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
@ -235,7 +248,15 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
||||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
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", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
@ -280,7 +301,7 @@ app.listen(PORT, HOST, async () => {
|
||||||
|
|
||||||
// 배치 스케줄러 초기화
|
// 배치 스케줄러 초기화
|
||||||
try {
|
try {
|
||||||
await BatchSchedulerService.initialize();
|
await BatchSchedulerService.initializeScheduler();
|
||||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
import https from "https";
|
||||||
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { DashboardService } from "../services/DashboardService";
|
import { DashboardService } from "../services/DashboardService";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,6 +10,7 @@ import {
|
||||||
DashboardListQuery,
|
DashboardListQuery,
|
||||||
} from "../types/dashboard";
|
} from "../types/dashboard";
|
||||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||||
|
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 컨트롤러
|
* 대시보드 컨트롤러
|
||||||
|
|
@ -415,7 +419,7 @@ export class DashboardController {
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
createdBy: userId, // 본인이 만든 대시보드만
|
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(
|
const result = await DashboardService.getDashboards(
|
||||||
|
|
@ -590,7 +594,14 @@ export class DashboardController {
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
const {
|
||||||
|
url,
|
||||||
|
method = "GET",
|
||||||
|
headers = {},
|
||||||
|
queryParams = {},
|
||||||
|
body,
|
||||||
|
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -608,85 +619,175 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 외부 API 호출 (타임아웃 30초)
|
// Axios 요청 설정
|
||||||
// @ts-ignore - node-fetch dynamic import
|
const requestConfig: AxiosRequestConfig = {
|
||||||
const fetch = (await import("node-fetch")).default;
|
url: urlObj.toString(),
|
||||||
|
method: method.toUpperCase(),
|
||||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
headers: {
|
||||||
const controller = new (global as any).AbortController();
|
"Content-Type": "application/json",
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
Accept: "application/json",
|
||||||
|
...headers,
|
||||||
let response;
|
},
|
||||||
try {
|
timeout: 60000, // 60초 타임아웃
|
||||||
response = await fetch(urlObj.toString(), {
|
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||||
method: method.toUpperCase(),
|
};
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
// 연결 정보 (응답에 포함용)
|
||||||
...headers,
|
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||||
},
|
|
||||||
signal: controller.signal,
|
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||||
});
|
if (externalConnectionId) {
|
||||||
clearTimeout(timeoutId);
|
try {
|
||||||
} catch (err: any) {
|
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||||
clearTimeout(timeoutId);
|
let companyCode = req.user?.companyCode;
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
if (!companyCode) {
|
||||||
|
companyCode = "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커넥션 로드
|
||||||
|
const connectionResult =
|
||||||
|
await ExternalRestApiConnectionService.getConnectionById(
|
||||||
|
Number(externalConnectionId),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionResult.success && connectionResult.data) {
|
||||||
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 연결 정보 저장 (응답에 포함)
|
||||||
|
connectionInfo = {
|
||||||
|
saveToHistory: connection.save_to_history === "Y",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인증 헤더 생성 (DB 토큰 등)
|
||||||
|
const authHeaders =
|
||||||
|
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||||
|
connection.auth_type,
|
||||||
|
connection.auth_config,
|
||||||
|
connection.company_code
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 헤더에 인증 헤더 병합
|
||||||
|
requestConfig.headers = {
|
||||||
|
...requestConfig.headers,
|
||||||
|
...authHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Key가 Query Param인 경우 처리
|
||||||
|
if (
|
||||||
|
connection.auth_type === "api-key" &&
|
||||||
|
connection.auth_config?.keyLocation === "query" &&
|
||||||
|
connection.auth_config?.keyName &&
|
||||||
|
connection.auth_config?.keyValue
|
||||||
|
) {
|
||||||
|
const currentUrl = new URL(requestConfig.url!);
|
||||||
|
currentUrl.searchParams.append(
|
||||||
|
connection.auth_config.keyName,
|
||||||
|
connection.auth_config.keyValue
|
||||||
|
);
|
||||||
|
requestConfig.url = currentUrl.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (connError) {
|
||||||
|
logger.error(
|
||||||
|
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||||
|
connError
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
// Body 처리
|
||||||
|
if (body) {
|
||||||
|
requestConfig.data = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버깅 로그: 실제 요청 정보 출력
|
||||||
|
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
||||||
|
url: requestConfig.url,
|
||||||
|
method: requestConfig.method,
|
||||||
|
headers: requestConfig.headers,
|
||||||
|
body: requestConfig.data,
|
||||||
|
externalConnectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||||
|
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||||
|
const bypassDomains = ["thiratis.com"];
|
||||||
|
const hostname = urlObj.hostname;
|
||||||
|
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||||
|
hostname.includes(domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldBypassTls) {
|
||||||
|
requestConfig.httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기상청 API 등 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) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content-Type에 따라 응답 파싱
|
let data = response.data;
|
||||||
const contentType = response.headers.get("content-type");
|
const contentType = response.headers["content-type"];
|
||||||
let data: any;
|
|
||||||
|
|
||||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||||
urlObj.hostname.includes('data.go.kr');
|
const iconv = require("iconv-lite");
|
||||||
|
const buffer = Buffer.from(data);
|
||||||
if (isKoreanApi) {
|
const utf8Text = buffer.toString("utf-8");
|
||||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
|
||||||
const buffer = await response.arrayBuffer();
|
// UTF-8로 정상 디코딩되었는지 확인
|
||||||
const decoder = new TextDecoder('euc-kr');
|
if (
|
||||||
const text = decoder.decode(buffer);
|
utf8Text.includes("특보") ||
|
||||||
|
utf8Text.includes("경보") ||
|
||||||
try {
|
utf8Text.includes("주의보") ||
|
||||||
data = JSON.parse(text);
|
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
|
||||||
} catch {
|
) {
|
||||||
data = { text, contentType };
|
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||||
}
|
} else {
|
||||||
} else if (contentType && contentType.includes("application/json")) {
|
// EUC-KR로 디코딩
|
||||||
data = await response.json();
|
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||||
} else if (contentType && contentType.includes("text/")) {
|
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
|
||||||
const text = await response.text();
|
|
||||||
data = { text, contentType };
|
|
||||||
} else {
|
|
||||||
// 기타 응답 (JSON으로 시도)
|
|
||||||
try {
|
|
||||||
data = await response.json();
|
|
||||||
} catch {
|
|
||||||
const text = await response.text();
|
|
||||||
data = { text, contentType };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 텍스트 응답인 경우 포맷팅
|
||||||
|
else if (typeof data === "string") {
|
||||||
|
data = { text: data, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
const status = error.response?.status || 500;
|
||||||
|
const message = error.response?.statusText || error.message;
|
||||||
|
|
||||||
|
logger.error("외부 API 호출 오류:", {
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||||
error:
|
error:
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? (error as Error).message
|
? message
|
||||||
: "외부 API 호출 오류",
|
: "외부 API 호출 오류",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { ApiResponse } from "../types/common";
|
import { ApiResponse } from "../types/common";
|
||||||
import { Client } from "pg";
|
import { Client } from "pg";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne, getPool } from "../database/db";
|
||||||
import config from "../config/environment";
|
import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
|
|
@ -1256,8 +1256,17 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestCompanyCode =
|
let requestCompanyCode =
|
||||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
menuData.companyCode || menuData.company_code;
|
||||||
|
|
||||||
|
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||||
|
if (
|
||||||
|
requestCompanyCode === "none" ||
|
||||||
|
requestCompanyCode === "" ||
|
||||||
|
!requestCompanyCode
|
||||||
|
) {
|
||||||
|
requestCompanyCode = currentMenu.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
// company_code 변경 시도하는 경우 권한 체크
|
// company_code 변경 시도하는 경우 권한 체크
|
||||||
if (requestCompanyCode !== currentMenu.company_code) {
|
if (requestCompanyCode !== currentMenu.company_code) {
|
||||||
|
|
@ -1428,10 +1437,51 @@ export async function deleteMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||||
|
const menuObjid = Number(menuId);
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 삭제
|
// Raw Query를 사용한 메뉴 삭제
|
||||||
const [deletedMenu] = await query<any>(
|
const [deletedMenu] = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[Number(menuId)]
|
[menuObjid]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||||
|
|
@ -3365,3 +3415,395 @@ 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -384,4 +384,69 @@ export class AuthController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/signup
|
||||||
|
* 공차중계 회원가입 API
|
||||||
|
*/
|
||||||
|
static async signup(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body;
|
||||||
|
|
||||||
|
logger.info(`=== 공차중계 회원가입 API 호출 ===`);
|
||||||
|
logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`);
|
||||||
|
|
||||||
|
// 입력값 검증
|
||||||
|
if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 입력값이 누락되었습니다.",
|
||||||
|
error: {
|
||||||
|
code: "INVALID_INPUT",
|
||||||
|
details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회원가입 처리
|
||||||
|
const signupResult = await AuthService.signupDriver({
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signupResult.success) {
|
||||||
|
logger.info(`공차중계 회원가입 성공: ${userId}`);
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "회원가입이 완료되었습니다.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: signupResult.message || "회원가입에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SIGNUP_FAILED",
|
||||||
|
details: signupResult.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("공차중계 회원가입 API 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회원가입 처리 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SIGNUP_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { BatchService } from "../services/batchService";
|
import { BatchService } from "../services/batchService";
|
||||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||||
|
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||||
import {
|
import {
|
||||||
BatchConfigFilter,
|
BatchConfigFilter,
|
||||||
CreateBatchConfigRequest,
|
CreateBatchConfigRequest,
|
||||||
|
|
@ -63,7 +64,7 @@ export class BatchController {
|
||||||
res: Response
|
res: Response
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await BatchService.getAvailableConnections();
|
const result = await BatchExternalDbService.getAvailableConnections();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|
@ -99,8 +100,8 @@ export class BatchController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionId = type === "external" ? Number(id) : undefined;
|
const connectionId = type === "external" ? Number(id) : undefined;
|
||||||
const result = await BatchService.getTablesFromConnection(
|
const result = await BatchService.getTables(
|
||||||
type,
|
type as "internal" | "external",
|
||||||
connectionId
|
connectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -142,10 +143,10 @@ export class BatchController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionId = type === "external" ? Number(id) : undefined;
|
const connectionId = type === "external" ? Number(id) : undefined;
|
||||||
const result = await BatchService.getTableColumns(
|
const result = await BatchService.getColumns(
|
||||||
type,
|
tableName,
|
||||||
connectionId,
|
type as "internal" | "external",
|
||||||
tableName
|
connectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -169,22 +170,18 @@ export class BatchController {
|
||||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const result = await BatchService.getBatchConfigById(Number(id));
|
||||||
const batchConfig = await BatchService.getBatchConfigById(
|
|
||||||
Number(id),
|
|
||||||
userCompanyCode
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!batchConfig) {
|
if (!result.success || !result.data) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 설정을 찾을 수 없습니다.",
|
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: batchConfig,
|
data: result.data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 설정 조회 오류:", error);
|
console.error("배치 설정 조회 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
||||||
try {
|
try {
|
||||||
const data: CreateBatchExecutionLogRequest = req.body;
|
const data: CreateBatchExecutionLogRequest = req.body;
|
||||||
|
|
||||||
|
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||||
|
if (!data.company_code) {
|
||||||
|
data.company_code = req.user?.companyCode || "*";
|
||||||
|
}
|
||||||
|
|
||||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import { Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import {
|
import {
|
||||||
BatchManagementService,
|
BatchManagementService,
|
||||||
|
|
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
|
||||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
export class BatchManagementController {
|
export class BatchManagementController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -265,8 +266,12 @@ export class BatchManagementController {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
executionLog = await BatchService.createExecutionLog({
|
const { BatchExecutionLogService } = await import(
|
||||||
|
"../services/batchExecutionLogService"
|
||||||
|
);
|
||||||
|
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||||
batch_config_id: Number(id),
|
batch_config_id: Number(id),
|
||||||
|
company_code: batchConfig.company_code,
|
||||||
execution_status: "RUNNING",
|
execution_status: "RUNNING",
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
total_records: 0,
|
total_records: 0,
|
||||||
|
|
@ -274,6 +279,14 @@ export class BatchManagementController {
|
||||||
failed_records: 0,
|
failed_records: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!logResult.success || !logResult.data) {
|
||||||
|
throw new Error(
|
||||||
|
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
executionLog = logResult.data;
|
||||||
|
|
||||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||||
const { BatchSchedulerService } = await import(
|
const { BatchSchedulerService } = await import(
|
||||||
"../services/batchSchedulerService"
|
"../services/batchSchedulerService"
|
||||||
|
|
@ -290,7 +303,7 @@ export class BatchManagementController {
|
||||||
const duration = endTime.getTime() - startTime.getTime();
|
const duration = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
// 실행 로그 업데이트 (성공)
|
// 실행 로그 업데이트 (성공)
|
||||||
await BatchService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "SUCCESS",
|
execution_status: "SUCCESS",
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
|
|
@ -319,8 +332,11 @@ export class BatchManagementController {
|
||||||
const duration = endTime.getTime() - startTime.getTime();
|
const duration = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
// executionLog가 정의되어 있는지 확인
|
// executionLog가 정의되어 있는지 확인
|
||||||
if (typeof executionLog !== "undefined") {
|
if (typeof executionLog !== "undefined" && executionLog) {
|
||||||
await BatchService.updateExecutionLog(executionLog.id, {
|
const { BatchExecutionLogService } = await import(
|
||||||
|
"../services/batchExecutionLogService"
|
||||||
|
);
|
||||||
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: "FAILED",
|
execution_status: "FAILED",
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
|
|
@ -406,22 +422,70 @@ export class BatchManagementController {
|
||||||
paramName,
|
paramName,
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
|
requestBody,
|
||||||
|
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||||
|
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!apiUrl || !apiKey || !endpoint) {
|
// apiUrl, endpoint는 항상 필수
|
||||||
|
if (!apiUrl || !endpoint) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "API URL, API Key, 엔드포인트는 필수입니다.",
|
message: "API URL과 엔드포인트는 필수입니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 REST API 미리보기 요청:", {
|
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||||
|
let finalApiKey = apiKey || "";
|
||||||
|
if (authServiceName) {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
|
||||||
|
let tokenQuery: string;
|
||||||
|
let tokenParams: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 회사 토큰 조회 가능
|
||||||
|
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||||
|
WHERE service_name = $1
|
||||||
|
ORDER BY created_date DESC LIMIT 1`;
|
||||||
|
tokenParams = [authServiceName];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 회사 토큰만 조회
|
||||||
|
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||||
|
WHERE service_name = $1 AND company_code = $2
|
||||||
|
ORDER BY created_date DESC LIMIT 1`;
|
||||||
|
tokenParams = [authServiceName, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await query<{ access_token: string }>(
|
||||||
|
tokenQuery,
|
||||||
|
tokenParams
|
||||||
|
);
|
||||||
|
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||||
|
finalApiKey = tokenResult[0].access_token;
|
||||||
|
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
||||||
|
|
||||||
|
console.log("REST API 미리보기 요청:", {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
method,
|
||||||
paramType,
|
paramType,
|
||||||
paramName,
|
paramName,
|
||||||
paramValue,
|
paramValue,
|
||||||
paramSource,
|
paramSource,
|
||||||
|
requestBody: requestBody ? "Included" : "None",
|
||||||
|
authServiceName: authServiceName || "직접 입력",
|
||||||
|
dataArrayPath: dataArrayPath || "전체 응답",
|
||||||
});
|
});
|
||||||
|
|
||||||
// RestApiConnector 사용하여 데이터 조회
|
// RestApiConnector 사용하여 데이터 조회
|
||||||
|
|
@ -429,7 +493,7 @@ export class BatchManagementController {
|
||||||
|
|
||||||
const connector = new RestApiConnector({
|
const connector = new RestApiConnector({
|
||||||
baseUrl: apiUrl,
|
baseUrl: apiUrl,
|
||||||
apiKey: apiKey,
|
apiKey: finalApiKey,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -456,17 +520,78 @@ export class BatchManagementController {
|
||||||
|
|
||||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||||
|
|
||||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
// Request Body 파싱
|
||||||
const result = await connector.executeQuery(finalEndpoint, method);
|
let parsedBody = undefined;
|
||||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
if (requestBody && typeof requestBody === "string") {
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(requestBody);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Request Body JSON 파싱 실패:", e);
|
||||||
|
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||||
|
// 여기서는 경고 로그 남기고 진행
|
||||||
|
}
|
||||||
|
} else if (requestBody) {
|
||||||
|
parsedBody = requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||||
|
const result = await connector.executeRequest(
|
||||||
|
finalEndpoint,
|
||||||
|
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||||
|
parsedBody
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||||
firstRow:
|
firstRow:
|
||||||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
// 데이터 배열 추출 헬퍼 함수
|
||||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, 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
|
||||||
|
);
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
// 첫 번째 객체에서 필드명 추출
|
// 첫 번째 객체에서 필드명 추출
|
||||||
|
|
@ -478,9 +603,9 @@ export class BatchManagementController {
|
||||||
data: {
|
data: {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
samples: data,
|
samples: data,
|
||||||
totalCount: result.rowCount || data.length,
|
totalCount: extractedData.length,
|
||||||
},
|
},
|
||||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
|
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -508,8 +633,17 @@ export class BatchManagementController {
|
||||||
*/
|
*/
|
||||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { batchName, batchType, cronSchedule, description, apiMappings } =
|
const {
|
||||||
req.body;
|
batchName,
|
||||||
|
batchType,
|
||||||
|
cronSchedule,
|
||||||
|
description,
|
||||||
|
apiMappings,
|
||||||
|
authServiceName,
|
||||||
|
dataArrayPath,
|
||||||
|
saveMode,
|
||||||
|
conflictKey,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!batchName ||
|
!batchName ||
|
||||||
|
|
@ -530,22 +664,36 @@ export class BatchManagementController {
|
||||||
cronSchedule,
|
cronSchedule,
|
||||||
description,
|
description,
|
||||||
apiMappings,
|
apiMappings,
|
||||||
|
authServiceName,
|
||||||
|
dataArrayPath,
|
||||||
|
saveMode,
|
||||||
|
conflictKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
// BatchService를 사용하여 배치 설정 저장
|
// BatchService를 사용하여 배치 설정 저장
|
||||||
const batchConfig: CreateBatchConfigRequest = {
|
const batchConfig: CreateBatchConfigRequest = {
|
||||||
batchName: batchName,
|
batchName: batchName,
|
||||||
description: description || "",
|
description: description || "",
|
||||||
cronSchedule: cronSchedule,
|
cronSchedule: cronSchedule,
|
||||||
|
isActive: "Y",
|
||||||
|
companyCode,
|
||||||
|
authServiceName: authServiceName || undefined,
|
||||||
|
dataArrayPath: dataArrayPath || undefined,
|
||||||
|
saveMode: saveMode || "INSERT",
|
||||||
|
conflictKey: conflictKey || undefined,
|
||||||
mappings: apiMappings,
|
mappings: apiMappings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await BatchService.createBatchConfig(batchConfig);
|
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// 스케줄러에 자동 등록 ✅
|
// 스케줄러에 자동 등록 ✅
|
||||||
try {
|
try {
|
||||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
await BatchSchedulerService.scheduleBatch(result.data);
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||||
);
|
);
|
||||||
|
|
@ -573,4 +721,51 @@ 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: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,606 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,562 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,772 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,537 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,772 @@
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export const getCascadingOptions = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!parentValue) {
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 자식 옵션 조회
|
||||||
|
let optionsQuery = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE ${relation.child_filter_column} = $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.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionsParams: any[] = [parentValue];
|
||||||
|
|
||||||
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
|
if (
|
||||||
|
tableInfoResult.rowCount &&
|
||||||
|
tableInfoResult.rowCount > 0 &&
|
||||||
|
companyCode !== "*"
|
||||||
|
) {
|
||||||
|
optionsQuery += ` AND company_code = $2`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
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,
|
||||||
|
parentValue,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,43 +1,25 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { pool, queryOne } from "../database/db";
|
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
|
||||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
|
||||||
|
|
||||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수
|
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
|
||||||
export async function getExternalDbConnector(connectionId: number) {
|
export async function getExternalDbConnector(connectionId: number) {
|
||||||
// 외부 DB 연결 정보 조회
|
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||||
const connection = await queryOne<any>(
|
|
||||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
|
||||||
[connectionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) {
|
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
|
||||||
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
return {
|
||||||
}
|
executeQuery: async (sql: string, params?: any[]) => {
|
||||||
|
const result = await poolService.executeQuery(connectionId, sql, params);
|
||||||
// 패스워드 복호화
|
return { rows: result };
|
||||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
},
|
||||||
|
|
||||||
// DB 연결 설정
|
|
||||||
const config = {
|
|
||||||
host: connection.host,
|
|
||||||
port: connection.port,
|
|
||||||
user: connection.username,
|
|
||||||
password: decryptedPassword,
|
|
||||||
database: connection.database_name,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// DB 커넥터 생성
|
|
||||||
return await DatabaseConnectorFactory.createConnector(
|
|
||||||
connection.db_type || "mariadb",
|
|
||||||
config,
|
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 계층 구조 데이터 조회 (범용)
|
// 동적 계층 구조 데이터 조회 (범용)
|
||||||
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
export const getHierarchyData = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||||
|
|
||||||
|
|
@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
const config = JSON.parse(hierarchyConfig);
|
const config = JSON.parse(hierarchyConfig);
|
||||||
|
|
||||||
const result: any = {
|
const result: any = {
|
||||||
|
|
@ -69,7 +53,7 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
for (const level of config.levels) {
|
for (const level of config.levels) {
|
||||||
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
||||||
const levelResult = await connector.executeQuery(levelQuery);
|
const levelResult = await connector.executeQuery(levelQuery);
|
||||||
|
|
||||||
result.levels.push({
|
result.levels.push({
|
||||||
level: level.level,
|
level: level.level,
|
||||||
name: level.name,
|
name: level.name,
|
||||||
|
|
@ -94,7 +78,10 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
logger.info("동적 계층 구조 데이터 조회", {
|
logger.info("동적 계층 구조 데이터 조회", {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
warehouseCount: result.warehouse?.length || 0,
|
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({
|
return res.json({
|
||||||
|
|
@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
|
||||||
};
|
};
|
||||||
|
|
||||||
// 특정 레벨의 하위 데이터 조회
|
// 특정 레벨의 하위 데이터 조회
|
||||||
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
|
export const getChildrenData = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
const config = JSON.parse(hierarchyConfig);
|
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) {
|
if (!nextLevel) {
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise<Resp
|
||||||
};
|
};
|
||||||
|
|
||||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
export const getWarehouses = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, tableName } = req.query;
|
const { externalDbConnectionId, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
// 테이블명을 사용하여 모든 컬럼 조회
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||||
|
|
@ -215,7 +220,10 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
||||||
};
|
};
|
||||||
|
|
||||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
export const getAreas = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
||||||
};
|
};
|
||||||
|
|
||||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||||
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
export const getLocations = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||||
|
|
||||||
|
|
@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
|
|
@ -301,28 +316,38 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||||
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterials = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
externalDbConnectionId,
|
externalDbConnectionId,
|
||||||
locaKey,
|
locaKey,
|
||||||
tableName,
|
tableName,
|
||||||
keyColumn,
|
keyColumn,
|
||||||
locationKeyColumn,
|
locationKeyColumn,
|
||||||
layerColumn
|
layerColumn,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
if (
|
||||||
|
!externalDbConnectionId ||
|
||||||
|
!locaKey ||
|
||||||
|
!tableName ||
|
||||||
|
!locationKeyColumn
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
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 = `
|
const query = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||||
|
|
@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
export const getMaterialCounts = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||||
|
|
||||||
|
|
@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
const connector = await getExternalDbConnector(
|
||||||
|
Number(externalDbConnectionId)
|
||||||
|
);
|
||||||
|
|
||||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,19 @@ export const getLayouts = async (
|
||||||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||||
WHERE l.company_code = $1
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [companyCode];
|
const params: any[] = [];
|
||||||
let paramIndex = 2;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||||
|
if (companyCode && companyCode !== '*') {
|
||||||
|
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
} else {
|
||||||
|
query += ` WHERE 1=1`;
|
||||||
|
}
|
||||||
|
|
||||||
if (externalDbConnectionId) {
|
if (externalDbConnectionId) {
|
||||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||||
|
|
@ -75,14 +83,27 @@ export const getLayoutById = async (
|
||||||
const companyCode = req.user?.companyCode;
|
const companyCode = req.user?.companyCode;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// 레이아웃 기본 정보
|
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||||
const layoutQuery = `
|
let layoutQuery: string;
|
||||||
SELECT l.*
|
let layoutParams: any[];
|
||||||
FROM digital_twin_layout l
|
|
||||||
WHERE l.id = $1 AND l.company_code = $2
|
|
||||||
`;
|
|
||||||
|
|
||||||
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
if (companyCode && companyCode !== '*') {
|
||||||
|
layoutQuery = `
|
||||||
|
SELECT l.*
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
WHERE l.id = $1 AND l.company_code = $2
|
||||||
|
`;
|
||||||
|
layoutParams = [id, companyCode];
|
||||||
|
} else {
|
||||||
|
layoutQuery = `
|
||||||
|
SELECT l.*
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
WHERE l.id = $1
|
||||||
|
`;
|
||||||
|
layoutParams = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||||
|
|
||||||
if (layoutResult.rowCount === 0) {
|
if (layoutResult.rowCount === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
|
||||||
|
|
@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
// 공차중계 운전자 컨트롤러
|
||||||
|
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(
|
const result = await dynamicFormService.updateFormDataPartial(
|
||||||
parseInt(id),
|
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||||
tableName,
|
tableName,
|
||||||
originalData,
|
originalData,
|
||||||
newDataWithMeta
|
newDataWithMeta
|
||||||
|
|
@ -419,3 +419,207 @@ export const getTableColumns = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||||
|
export const updateFieldValue = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
console.log("🔄 [updateFieldValue] 요청:", {
|
||||||
|
tableName,
|
||||||
|
keyField,
|
||||||
|
keyValue,
|
||||||
|
updateField,
|
||||||
|
updateValue,
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (
|
||||||
|
!tableName ||
|
||||||
|
!keyField ||
|
||||||
|
keyValue === undefined ||
|
||||||
|
!updateField ||
|
||||||
|
updateValue === undefined
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||||
|
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
if (
|
||||||
|
!validNamePattern.test(tableName) ||
|
||||||
|
!validNamePattern.test(keyField) ||
|
||||||
|
!validNamePattern.test(updateField)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트 쿼리 실행
|
||||||
|
const result = await dynamicFormService.updateFieldValue(
|
||||||
|
tableName,
|
||||||
|
keyField,
|
||||||
|
keyValue,
|
||||||
|
updateField,
|
||||||
|
updateValue,
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ [updateFieldValue] 성공:", result);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "필드 값이 업데이트되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [updateFieldValue] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 이력 저장 (연속 위치 추적용)
|
||||||
|
* POST /api/dynamic-form/location-history
|
||||||
|
*/
|
||||||
|
export const saveLocationHistory = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId: loginUserId } = req.user as any;
|
||||||
|
const {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy,
|
||||||
|
altitude,
|
||||||
|
speed,
|
||||||
|
heading,
|
||||||
|
tripId,
|
||||||
|
tripStatus,
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
recordedAt,
|
||||||
|
vehicleId,
|
||||||
|
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||||
|
// 없으면 로그인한 사용자의 userId 사용
|
||||||
|
const userId = requestUserId || loginUserId;
|
||||||
|
|
||||||
|
console.log("📍 [saveLocationHistory] 요청:", {
|
||||||
|
userId,
|
||||||
|
requestUserId,
|
||||||
|
loginUserId,
|
||||||
|
companyCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
tripId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (latitude === undefined || longitude === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (latitude, longitude)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dynamicFormService.saveLocationHistory({
|
||||||
|
userId,
|
||||||
|
companyCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy,
|
||||||
|
altitude,
|
||||||
|
speed,
|
||||||
|
heading,
|
||||||
|
tripId,
|
||||||
|
tripStatus: tripStatus || "active",
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
recordedAt: recordedAt || new Date().toISOString(),
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [saveLocationHistory] 성공:", result);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "위치 이력이 저장되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [saveLocationHistory] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "위치 이력 저장에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 이력 조회 (경로 조회용)
|
||||||
|
* GET /api/dynamic-form/location-history/:tripId
|
||||||
|
*/
|
||||||
|
export const getLocationHistory = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response | void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { userId, startDate, endDate, limit } = req.query;
|
||||||
|
|
||||||
|
console.log("📍 [getLocationHistory] 요청:", {
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await dynamicFormService.getLocationHistory({
|
||||||
|
companyCode,
|
||||||
|
tripId,
|
||||||
|
userId: userId as string,
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
limit: limit ? parseInt(limit as string) : 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [getLocationHistory] 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "위치 이력 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -125,6 +126,19 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
let parsedExcludeFilter: any = undefined;
|
||||||
|
if (excludeFilter) {
|
||||||
|
try {
|
||||||
|
parsedExcludeFilter =
|
||||||
|
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
|
||||||
|
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("제외 필터 파싱 오류:", error);
|
||||||
|
parsedExcludeFilter = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -141,6 +155,7 @@ export class EntityJoinController {
|
||||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
// 검색 필드 파싱
|
// 검색 필드 파싱
|
||||||
const fields = searchFields
|
const requestedFields = searchFields
|
||||||
? (searchFields as string).split(",").map((f) => f.trim())
|
? (searchFields as string).split(",").map((f) => f.trim())
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// 🆕 테이블의 실제 컬럼 목록 조회
|
||||||
|
const pool = getPool();
|
||||||
|
const columnsResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
// 🆕 존재하는 컬럼만 필터링
|
||||||
|
const fields = requestedFields.filter((field) => {
|
||||||
|
if (existingColumns.has(field)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingColumnsArray = Array.from(existingColumns);
|
||||||
|
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
// 멀티테넌시 필터링
|
// 멀티테넌시 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
// 🆕 company_code 컬럼이 있는 경우에만 필터링
|
||||||
params.push(companyCode);
|
if (existingColumns.has("company_code")) {
|
||||||
paramIndex++;
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건
|
// 검색 조건
|
||||||
if (searchText && fields.length > 0) {
|
if (searchText) {
|
||||||
const searchConditions = fields.map((field) => {
|
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
|
||||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
let searchableFields = fields;
|
||||||
paramIndex++;
|
if (searchableFields.length === 0) {
|
||||||
return condition;
|
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
|
||||||
});
|
const defaultSearchColumns = [
|
||||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
'name', 'code', 'description', 'title', 'label',
|
||||||
|
'item_name', 'item_code', 'item_number',
|
||||||
|
'equipment_name', 'equipment_code',
|
||||||
|
'inspection_item', 'consumable_name', // 소모품명 추가
|
||||||
|
'supplier_name', 'customer_name', 'product_name',
|
||||||
|
];
|
||||||
|
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
|
||||||
|
|
||||||
|
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchableFields.length > 0) {
|
||||||
|
const searchConditions = searchableFields.map((field) => {
|
||||||
|
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||||
|
paramIndex++;
|
||||||
|
return condition;
|
||||||
|
});
|
||||||
|
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||||
|
|
||||||
// 검색어 파라미터 추가
|
// 검색어 파라미터 추가
|
||||||
fields.forEach(() => {
|
searchableFields.forEach(() => {
|
||||||
params.push(`%${searchText}%`);
|
params.push(`%${searchText}%`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추가 필터 조건
|
// 추가 필터 조건 (존재하는 컬럼만)
|
||||||
const additionalFilter = JSON.parse(filterCondition as string);
|
const additionalFilter = JSON.parse(filterCondition as string);
|
||||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||||
whereConditions.push(`${key} = $${paramIndex}`);
|
if (existingColumns.has(key)) {
|
||||||
params.push(value);
|
whereConditions.push(`${key} = $${paramIndex}`);
|
||||||
paramIndex++;
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
} else {
|
||||||
|
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이징
|
// 페이징
|
||||||
|
|
@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// 쿼리 실행
|
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||||
const pool = getPool();
|
|
||||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM ${tableName} ${whereClause}
|
SELECT * FROM ${tableName} ${whereClause}
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,64 @@ export const uploadFiles = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
||||||
|
|
||||||
|
// 🔍 디버깅: 레코드 모드 조건 확인
|
||||||
|
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
||||||
|
isRecordMode,
|
||||||
|
linkedTable,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
finalTargetObjid,
|
||||||
|
"req.body.isRecordMode": req.body.isRecordMode,
|
||||||
|
"req.body.linkedTable": req.body.linkedTable,
|
||||||
|
"req.body.recordId": req.body.recordId,
|
||||||
|
"req.body.columnName": req.body.columnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRecordMode && linkedTable && recordId && columnName) {
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 모든 첨부파일 조회
|
||||||
|
const allFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[finalTargetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = allFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${linkedTable}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName: linkedTable,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
fileCount: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${files.length}개 파일 업로드 완료`,
|
message: `${files.length}개 파일 업로드 완료`,
|
||||||
|
|
@ -405,6 +463,56 @@ export const deleteFile = async (
|
||||||
["DELETED", parseInt(objid)]
|
["DELETED", parseInt(objid)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const targetObjid = fileRecord.target_objid;
|
||||||
|
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||||
|
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||||
|
const parts = targetObjid.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [tableName, recordId, columnName] = parts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 남은 첨부파일 조회
|
||||||
|
const remainingFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[targetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${tableName}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
remainingFiles: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "파일이 삭제되었습니다.",
|
message: "파일이 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,17 @@ export class FlowController {
|
||||||
*/
|
*/
|
||||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
const {
|
||||||
req.body;
|
name,
|
||||||
|
description,
|
||||||
|
tableName,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
// REST API 관련 필드
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
} = req.body;
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
const userCompanyCode = (req as any).user?.companyCode;
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
|
@ -43,6 +52,9 @@ export class FlowController {
|
||||||
tableName,
|
tableName,
|
||||||
dbSourceType,
|
dbSourceType,
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
userCompanyCode,
|
userCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,8 +66,12 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 이름이 제공된 경우에만 존재 확인
|
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
|
||||||
if (tableName) {
|
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_")) {
|
||||||
const tableExists =
|
const tableExists =
|
||||||
await this.flowDefinitionService.checkTableExists(tableName);
|
await this.flowDefinitionService.checkTableExists(tableName);
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
|
|
@ -68,7 +84,17 @@ export class FlowController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowDef = await this.flowDefinitionService.create(
|
const flowDef = await this.flowDefinitionService.create(
|
||||||
{ name, description, tableName, dbSourceType, dbConnectionId },
|
{
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tableName,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
||||||
|
},
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode
|
userCompanyCode
|
||||||
);
|
);
|
||||||
|
|
@ -811,4 +837,53 @@ 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,925 @@
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 및 데이터 전달 시스템 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 화면 임베딩 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 목록 조회
|
||||||
|
* GET /api/screen-embedding?parentScreenId=1
|
||||||
|
*/
|
||||||
|
export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { parentScreenId } = req.query;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
if (!parentScreenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "부모 화면 ID가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
se.*,
|
||||||
|
ps.screen_name as parent_screen_name,
|
||||||
|
cs.screen_name as child_screen_name
|
||||||
|
FROM screen_embedding se
|
||||||
|
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||||
|
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||||
|
WHERE se.parent_screen_id = $1
|
||||||
|
AND se.company_code = $2
|
||||||
|
ORDER BY se.position, se.created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [parentScreenId, companyCode]);
|
||||||
|
|
||||||
|
logger.info("화면 임베딩 목록 조회", {
|
||||||
|
companyCode,
|
||||||
|
parentScreenId,
|
||||||
|
count: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("화면 임베딩 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 상세 조회
|
||||||
|
* GET /api/screen-embedding/:id
|
||||||
|
*/
|
||||||
|
export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
se.*,
|
||||||
|
ps.screen_name as parent_screen_name,
|
||||||
|
cs.screen_name as child_screen_name
|
||||||
|
FROM screen_embedding se
|
||||||
|
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||||
|
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||||
|
WHERE se.id = $1
|
||||||
|
AND se.company_code = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("화면 임베딩 상세 조회", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("화면 임베딩 상세 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 생성
|
||||||
|
* POST /api/screen-embedding
|
||||||
|
*/
|
||||||
|
export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
parentScreenId,
|
||||||
|
childScreenId,
|
||||||
|
position,
|
||||||
|
mode,
|
||||||
|
config = {},
|
||||||
|
} = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!parentScreenId || !childScreenId || !position || !mode) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO screen_embedding (
|
||||||
|
parent_screen_id, child_screen_id, position, mode,
|
||||||
|
config, company_code, created_by, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
parentScreenId,
|
||||||
|
childScreenId,
|
||||||
|
position,
|
||||||
|
mode,
|
||||||
|
JSON.stringify(config),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("화면 임베딩 생성", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
id: result.rows[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("화면 임베딩 생성 실패", error);
|
||||||
|
|
||||||
|
// 유니크 제약조건 위반
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 동일한 임베딩 설정이 존재합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 수정
|
||||||
|
* PUT /api/screen-embedding/:id
|
||||||
|
*/
|
||||||
|
export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { position, mode, config } = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (position) {
|
||||||
|
updates.push(`position = $${paramIndex++}`);
|
||||||
|
values.push(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode) {
|
||||||
|
updates.push(`mode = $${paramIndex++}`);
|
||||||
|
values.push(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
updates.push(`config = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정할 내용이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
values.push(id, companyCode);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE screen_embedding
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE id = $${paramIndex++}
|
||||||
|
AND company_code = $${paramIndex++}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, values);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("화면 임베딩 수정", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("화면 임베딩 수정 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 삭제
|
||||||
|
* DELETE /api/screen-embedding/:id
|
||||||
|
*/
|
||||||
|
export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
DELETE FROM screen_embedding
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("화면 임베딩 삭제", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "화면 임베딩이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("화면 임베딩 삭제 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 데이터 전달 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전달 설정 조회
|
||||||
|
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
|
||||||
|
*/
|
||||||
|
export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { sourceScreenId, targetScreenId } = req.query;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
if (!sourceScreenId || !targetScreenId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
sdt.*,
|
||||||
|
ss.screen_name as source_screen_name,
|
||||||
|
ts.screen_name as target_screen_name
|
||||||
|
FROM screen_data_transfer sdt
|
||||||
|
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
|
||||||
|
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
|
||||||
|
WHERE sdt.source_screen_id = $1
|
||||||
|
AND sdt.target_screen_id = $2
|
||||||
|
AND sdt.company_code = $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
sourceScreenId,
|
||||||
|
targetScreenId,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("데이터 전달 설정 조회", {
|
||||||
|
companyCode,
|
||||||
|
sourceScreenId,
|
||||||
|
targetScreenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("데이터 전달 설정 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전달 설정 생성
|
||||||
|
* POST /api/screen-data-transfer
|
||||||
|
*/
|
||||||
|
export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
sourceScreenId,
|
||||||
|
targetScreenId,
|
||||||
|
sourceComponentId,
|
||||||
|
sourceComponentType,
|
||||||
|
dataReceivers,
|
||||||
|
buttonConfig,
|
||||||
|
} = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO screen_data_transfer (
|
||||||
|
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||||
|
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
sourceScreenId,
|
||||||
|
targetScreenId,
|
||||||
|
sourceComponentId,
|
||||||
|
sourceComponentType,
|
||||||
|
JSON.stringify(dataReceivers),
|
||||||
|
JSON.stringify(buttonConfig || {}),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("데이터 전달 설정 생성", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
id: result.rows[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("데이터 전달 설정 생성 실패", error);
|
||||||
|
|
||||||
|
// 유니크 제약조건 위반
|
||||||
|
if (error.code === "23505") {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전달 설정 수정
|
||||||
|
* PUT /api/screen-data-transfer/:id
|
||||||
|
*/
|
||||||
|
export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { dataReceivers, buttonConfig } = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dataReceivers) {
|
||||||
|
updates.push(`data_receivers = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(dataReceivers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonConfig) {
|
||||||
|
updates.push(`button_config = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(buttonConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정할 내용이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`);
|
||||||
|
|
||||||
|
values.push(id, companyCode);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE screen_data_transfer
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE id = $${paramIndex++}
|
||||||
|
AND company_code = $${paramIndex++}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, values);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("데이터 전달 설정 수정", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("데이터 전달 설정 수정 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전달 설정 삭제
|
||||||
|
* DELETE /api/screen-data-transfer/:id
|
||||||
|
*/
|
||||||
|
export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
DELETE FROM screen_data_transfer
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("데이터 전달 설정 삭제", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "데이터 전달 설정이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("데이터 전달 설정 삭제 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 분할 패널 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정 조회
|
||||||
|
* GET /api/screen-split-panel/:screenId
|
||||||
|
*/
|
||||||
|
export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
ssp.*,
|
||||||
|
le.parent_screen_id as le_parent_screen_id,
|
||||||
|
le.child_screen_id as le_child_screen_id,
|
||||||
|
le.position as le_position,
|
||||||
|
le.mode as le_mode,
|
||||||
|
le.config as le_config,
|
||||||
|
re.parent_screen_id as re_parent_screen_id,
|
||||||
|
re.child_screen_id as re_child_screen_id,
|
||||||
|
re.position as re_position,
|
||||||
|
re.mode as re_mode,
|
||||||
|
re.config as re_config,
|
||||||
|
sdt.source_screen_id,
|
||||||
|
sdt.target_screen_id,
|
||||||
|
sdt.source_component_id,
|
||||||
|
sdt.source_component_type,
|
||||||
|
sdt.data_receivers,
|
||||||
|
sdt.button_config
|
||||||
|
FROM screen_split_panel ssp
|
||||||
|
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
|
||||||
|
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
|
||||||
|
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
|
||||||
|
WHERE ssp.screen_id = $1
|
||||||
|
AND ssp.company_code = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [screenId, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
|
||||||
|
// 데이터 구조화
|
||||||
|
const data = {
|
||||||
|
id: row.id,
|
||||||
|
screenId: row.screen_id,
|
||||||
|
leftEmbeddingId: row.left_embedding_id,
|
||||||
|
rightEmbeddingId: row.right_embedding_id,
|
||||||
|
dataTransferId: row.data_transfer_id,
|
||||||
|
layoutConfig: row.layout_config,
|
||||||
|
companyCode: row.company_code,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
leftEmbedding: row.le_child_screen_id
|
||||||
|
? {
|
||||||
|
id: row.left_embedding_id,
|
||||||
|
parentScreenId: row.le_parent_screen_id,
|
||||||
|
childScreenId: row.le_child_screen_id,
|
||||||
|
position: row.le_position,
|
||||||
|
mode: row.le_mode,
|
||||||
|
config: row.le_config,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
rightEmbedding: row.re_child_screen_id
|
||||||
|
? {
|
||||||
|
id: row.right_embedding_id,
|
||||||
|
parentScreenId: row.re_parent_screen_id,
|
||||||
|
childScreenId: row.re_child_screen_id,
|
||||||
|
position: row.re_position,
|
||||||
|
mode: row.re_mode,
|
||||||
|
config: row.re_config,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dataTransfer: row.source_screen_id
|
||||||
|
? {
|
||||||
|
id: row.data_transfer_id,
|
||||||
|
sourceScreenId: row.source_screen_id,
|
||||||
|
targetScreenId: row.target_screen_id,
|
||||||
|
sourceComponentId: row.source_component_id,
|
||||||
|
sourceComponentType: row.source_component_type,
|
||||||
|
dataReceivers: row.data_receivers,
|
||||||
|
buttonConfig: row.button_config,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info("분할 패널 설정 조회", { companyCode, screenId });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("분할 패널 설정 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정 생성
|
||||||
|
* POST /api/screen-split-panel
|
||||||
|
*/
|
||||||
|
export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
screenId,
|
||||||
|
leftEmbedding,
|
||||||
|
rightEmbedding,
|
||||||
|
dataTransfer,
|
||||||
|
layoutConfig,
|
||||||
|
} = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 좌측 임베딩 생성
|
||||||
|
const leftEmbeddingQuery = `
|
||||||
|
INSERT INTO screen_embedding (
|
||||||
|
parent_screen_id, child_screen_id, position, mode,
|
||||||
|
config, company_code, created_by, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const leftResult = await client.query(leftEmbeddingQuery, [
|
||||||
|
screenId,
|
||||||
|
leftEmbedding.childScreenId,
|
||||||
|
leftEmbedding.position,
|
||||||
|
leftEmbedding.mode,
|
||||||
|
JSON.stringify(leftEmbedding.config || {}),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const leftEmbeddingId = leftResult.rows[0].id;
|
||||||
|
|
||||||
|
// 2. 우측 임베딩 생성
|
||||||
|
const rightEmbeddingQuery = `
|
||||||
|
INSERT INTO screen_embedding (
|
||||||
|
parent_screen_id, child_screen_id, position, mode,
|
||||||
|
config, company_code, created_by, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rightResult = await client.query(rightEmbeddingQuery, [
|
||||||
|
screenId,
|
||||||
|
rightEmbedding.childScreenId,
|
||||||
|
rightEmbedding.position,
|
||||||
|
rightEmbedding.mode,
|
||||||
|
JSON.stringify(rightEmbedding.config || {}),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rightEmbeddingId = rightResult.rows[0].id;
|
||||||
|
|
||||||
|
// 3. 데이터 전달 설정 생성
|
||||||
|
const dataTransferQuery = `
|
||||||
|
INSERT INTO screen_data_transfer (
|
||||||
|
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||||
|
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataTransferResult = await client.query(dataTransferQuery, [
|
||||||
|
dataTransfer.sourceScreenId,
|
||||||
|
dataTransfer.targetScreenId,
|
||||||
|
dataTransfer.sourceComponentId,
|
||||||
|
dataTransfer.sourceComponentType,
|
||||||
|
JSON.stringify(dataTransfer.dataReceivers),
|
||||||
|
JSON.stringify(dataTransfer.buttonConfig || {}),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dataTransferId = dataTransferResult.rows[0].id;
|
||||||
|
|
||||||
|
// 4. 분할 패널 생성
|
||||||
|
const splitPanelQuery = `
|
||||||
|
INSERT INTO screen_split_panel (
|
||||||
|
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
|
||||||
|
layout_config, company_code, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const splitPanelResult = await client.query(splitPanelQuery, [
|
||||||
|
screenId,
|
||||||
|
leftEmbeddingId,
|
||||||
|
rightEmbeddingId,
|
||||||
|
dataTransferId,
|
||||||
|
JSON.stringify(layoutConfig || {}),
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("분할 패널 설정 생성", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
screenId,
|
||||||
|
id: splitPanelResult.rows[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: splitPanelResult.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("분할 패널 설정 생성 실패", error);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정 수정
|
||||||
|
* PUT /api/screen-split-panel/:id
|
||||||
|
*/
|
||||||
|
export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { layoutConfig } = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
if (!layoutConfig) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정할 내용이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE screen_split_panel
|
||||||
|
SET layout_config = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
JSON.stringify(layoutConfig),
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("분할 패널 설정 수정", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("분할 패널 설정 수정 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 패널 설정 삭제
|
||||||
|
* DELETE /api/screen-split-panel/:id
|
||||||
|
*/
|
||||||
|
export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 분할 패널 조회
|
||||||
|
const selectQuery = `
|
||||||
|
SELECT left_embedding_id, right_embedding_id, data_transfer_id
|
||||||
|
FROM screen_split_panel
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const selectResult = await client.query(selectQuery, [id, companyCode]);
|
||||||
|
|
||||||
|
if (selectResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { left_embedding_id, right_embedding_id, data_transfer_id } =
|
||||||
|
selectResult.rows[0];
|
||||||
|
|
||||||
|
// 2. 분할 패널 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
|
||||||
|
if (left_embedding_id) {
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||||
|
[left_embedding_id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right_embedding_id) {
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||||
|
[right_embedding_id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data_transfer_id) {
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
|
||||||
|
[data_transfer_id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("분할 패널 설정 삭제", { companyCode, id });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "분할 패널 설정이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("분할 패널 설정 삭제 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -148,11 +148,42 @@ export const updateScreenInfo = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode } = req.user as any;
|
||||||
const { screenName, tableName, description, isActive } = req.body;
|
const {
|
||||||
|
screenName,
|
||||||
|
tableName,
|
||||||
|
description,
|
||||||
|
isActive,
|
||||||
|
// REST API 관련 필드 추가
|
||||||
|
dataSourceType,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
console.log("화면 정보 수정 요청:", {
|
||||||
|
screenId: id,
|
||||||
|
dataSourceType,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
});
|
||||||
|
|
||||||
await screenManagementService.updateScreenInfo(
|
await screenManagementService.updateScreenInfo(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
{ screenName, tableName, description, isActive },
|
{
|
||||||
|
screenName,
|
||||||
|
tableName,
|
||||||
|
description,
|
||||||
|
isActive,
|
||||||
|
dataSourceType,
|
||||||
|
dbSourceType,
|
||||||
|
dbConnectionId,
|
||||||
|
restApiConnectionId,
|
||||||
|
restApiEndpoint,
|
||||||
|
restApiJsonPath,
|
||||||
|
},
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||||
|
|
@ -294,6 +325,53 @@ export const getDeletedScreens = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
|
export const bulkDeleteScreens = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { screenIds, deleteReason, force } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(screenIds) || screenIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 화면 ID 목록이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await screenManagementService.bulkDeleteScreens(
|
||||||
|
screenIds,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
deleteReason,
|
||||||
|
force || false
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||||
|
if (result.skippedCount > 0) {
|
||||||
|
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
result: {
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
skippedCount: result.skippedCount,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("활성 화면 일괄 삭제 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "일괄 삭제에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 휴지통 화면 일괄 영구 삭제
|
// 휴지통 화면 일괄 영구 삭제
|
||||||
export const bulkPermanentDeleteScreens = async (
|
export const bulkPermanentDeleteScreens = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,99 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||||
|
*
|
||||||
|
* DELETE /api/categories/column-mapping/:tableName/:columnName
|
||||||
|
*
|
||||||
|
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||||
|
*/
|
||||||
|
export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { tableName, columnName } = req.params;
|
||||||
|
|
||||||
|
if (!tableName || !columnName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName과 columnName은 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("테이블+컬럼 기준 매핑 삭제", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn(
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`,
|
||||||
|
deletedCount,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 코드로 라벨 조회
|
||||||
|
*
|
||||||
|
* POST /api/table-categories/labels-by-codes
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - { [code]: label } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { valueCodes } = req.body;
|
||||||
|
|
||||||
|
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("카테고리 코드로 라벨 조회", {
|
||||||
|
valueCodes,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
|
||||||
|
valueCodes,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: labels,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2레벨 메뉴 목록 조회
|
* 2레벨 메뉴 목록 조회
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
// 이력 조회 쿼리
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY changed_at DESC
|
ORDER BY log_id DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
// 이력 조회 쿼리
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY changed_at DESC
|
ORDER BY log_id DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,17 @@ export async function addTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
|
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (companyCode && !data.company_code) {
|
||||||
|
// 테이블에 company_code 컬럼이 있는지 확인
|
||||||
|
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||||
|
if (hasCompanyCodeColumn) {
|
||||||
|
data.company_code = companyCode;
|
||||||
|
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
await tableManagementService.addTableData(tableName, data);
|
await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
|
|
@ -1800,3 +1811,334 @@ 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;
|
||||||
|
|
||||||
|
if (!tableName || !items || items.length === 0) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
|
itemsCount: items.length,
|
||||||
|
linkColumn,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 데이터 삭제 옵션
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 컨트롤러
|
||||||
|
* 세금계산서 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 || "통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 리포트 컨트롤러
|
||||||
|
*/
|
||||||
|
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 || "구간별 통계 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 컨트롤러
|
||||||
|
*/
|
||||||
|
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 || "운행 취소에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||||
|
import https from "https";
|
||||||
import {
|
import {
|
||||||
DatabaseConnector,
|
DatabaseConnector,
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
|
|
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
|
|
||||||
constructor(config: RestApiConfig) {
|
constructor(config: RestApiConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
// Axios 인스턴스 생성
|
// Axios 인스턴스 생성
|
||||||
|
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
|
||||||
|
const defaultHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.apiKey) {
|
||||||
|
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
this.httpClient = axios.create({
|
this.httpClient = axios.create({
|
||||||
baseURL: config.baseUrl,
|
baseURL: config.baseUrl,
|
||||||
timeout: config.timeout || 30000,
|
timeout: config.timeout || 30000,
|
||||||
headers: {
|
headers: defaultHeaders,
|
||||||
"Content-Type": "application/json",
|
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||||
Accept: "application/json",
|
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||||
},
|
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 요청/응답 인터셉터 설정
|
// 요청/응답 인터셉터 설정
|
||||||
|
|
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
try {
|
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||||
// 연결 테스트 - 기본 엔드포인트 호출
|
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||||
await this.httpClient.get("/health", { timeout: 5000 });
|
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
//
|
||||||
} catch (error) {
|
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
console.log(
|
||||||
console.log(
|
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
);
|
||||||
);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error(
|
|
||||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,17 @@ export const authenticateToken = (
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||||
);
|
|
||||||
|
|
||||||
|
// 토큰 만료 에러인지 확인
|
||||||
|
const isTokenExpired = errorMessage.includes("만료");
|
||||||
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: "INVALID_TOKEN",
|
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||||
details:
|
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ export const errorHandler = (
|
||||||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||||
if ((err as any).code) {
|
if ((err as any).code) {
|
||||||
const pgError = err as any;
|
const pgError = err as any;
|
||||||
|
// 원본 에러 메시지 로깅 (디버깅용)
|
||||||
|
console.error("🔴 PostgreSQL Error:", {
|
||||||
|
code: pgError.code,
|
||||||
|
message: pgError.message,
|
||||||
|
detail: pgError.detail,
|
||||||
|
hint: pgError.hint,
|
||||||
|
table: pgError.table,
|
||||||
|
column: pgError.column,
|
||||||
|
constraint: pgError.constraint,
|
||||||
|
});
|
||||||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
if (pgError.code === "23505") {
|
if (pgError.code === "23505") {
|
||||||
// unique_violation
|
// unique_violation
|
||||||
|
|
@ -42,7 +52,7 @@ export const errorHandler = (
|
||||||
// 기타 무결성 제약 조건 위반
|
// 기타 무결성 제약 조건 위반
|
||||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||||
} else {
|
} else {
|
||||||
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
getDepartmentList, // 부서 목록 조회
|
getDepartmentList, // 부서 목록 조회
|
||||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
|
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||||
|
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
getCompanyByCode, // 회사 단건 조회
|
getCompanyByCode, // 회사 단건 조회
|
||||||
|
|
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||||
router.get("/users", getUserList);
|
router.get("/users", getUserList);
|
||||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||||
|
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||||
|
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
||||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||||
router.put("/profile", updateProfile); // 프로필 수정
|
router.put("/profile", updateProfile); // 프로필 수정
|
||||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
|
||||||
*/
|
*/
|
||||||
router.post("/refresh", AuthController.refreshToken);
|
router.post("/refresh", AuthController.refreshToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/signup
|
||||||
|
* 공차중계 회원가입 API
|
||||||
|
*/
|
||||||
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
|
||||||
*/
|
*/
|
||||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/batch-management/auth-services
|
||||||
|
* 인증 토큰 서비스명 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
*/
|
*/
|
||||||
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
router.post(
|
||||||
try {
|
"/:flowId/execute",
|
||||||
const { flowId } = req.params;
|
authenticateToken,
|
||||||
const contextData = req.body;
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
const contextData = req.body;
|
||||||
|
|
||||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||||
contextDataKeys: Object.keys(contextData),
|
contextDataKeys: Object.keys(contextData),
|
||||||
userId: req.user?.userId,
|
userId: req.user?.userId,
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사용자 정보를 contextData에 추가
|
// 🔍 디버깅: req.user 전체 확인
|
||||||
const enrichedContextData = {
|
logger.info(`🔍 req.user 전체 정보:`, {
|
||||||
...contextData,
|
user: req.user,
|
||||||
userId: req.user?.userId,
|
hasUser: !!req.user,
|
||||||
userName: req.user?.userName,
|
});
|
||||||
companyCode: req.user?.companyCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 플로우 실행
|
// 사용자 정보를 contextData에 추가
|
||||||
const result = await NodeFlowExecutionService.executeFlow(
|
const enrichedContextData = {
|
||||||
parseInt(flowId, 10),
|
...contextData,
|
||||||
enrichedContextData
|
userId: req.user?.userId,
|
||||||
);
|
userName: req.user?.userName,
|
||||||
|
companyCode: req.user?.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
return res.json({
|
// 🔍 디버깅: enrichedContextData 확인
|
||||||
success: result.success,
|
logger.info(`🔍 enrichedContextData:`, {
|
||||||
message: result.message,
|
userId: enrichedContextData.userId,
|
||||||
data: result,
|
companyCode: enrichedContextData.companyCode,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
logger.error("플로우 실행 실패:", error);
|
// 플로우 실행
|
||||||
return res.status(500).json({
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
success: false,
|
parseInt(flowId, 10),
|
||||||
message:
|
enrichedContextData
|
||||||
error instanceof Error
|
);
|
||||||
? error.message
|
|
||||||
: "플로우 실행 중 오류가 발생했습니다.",
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// 공차중계 운전자 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;
|
||||||
|
|
||||||
|
|
@ -5,12 +5,15 @@ import {
|
||||||
saveFormDataEnhanced,
|
saveFormDataEnhanced,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
updateFormDataPartial,
|
updateFormDataPartial,
|
||||||
|
updateFieldValue,
|
||||||
deleteFormData,
|
deleteFormData,
|
||||||
getFormData,
|
getFormData,
|
||||||
getFormDataList,
|
getFormDataList,
|
||||||
validateFormData,
|
validateFormData,
|
||||||
getTableColumns,
|
getTableColumns,
|
||||||
getTablePrimaryKeys,
|
getTablePrimaryKeys,
|
||||||
|
saveLocationHistory,
|
||||||
|
getLocationHistory,
|
||||||
} from "../controllers/dynamicFormController";
|
} from "../controllers/dynamicFormController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -21,6 +24,7 @@ router.use(authenticateToken);
|
||||||
// 폼 데이터 CRUD
|
// 폼 데이터 CRUD
|
||||||
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||||
|
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
|
||||||
router.put("/:id", updateFormData);
|
router.put("/:id", updateFormData);
|
||||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||||
router.delete("/:id", deleteFormData);
|
router.delete("/:id", deleteFormData);
|
||||||
|
|
@ -38,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns);
|
||||||
// 테이블 기본키 조회
|
// 테이블 기본키 조회
|
||||||
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
|
||||||
|
|
||||||
|
// 위치 이력 (연속 위치 추적)
|
||||||
|
router.post("/location-history", saveLocationHistory);
|
||||||
|
router.get("/location-history/:tripId", getLocationHistory);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ router.post(
|
||||||
const data: ExternalRestApiConnection = {
|
const data: ExternalRestApiConnection = {
|
||||||
...req.body,
|
...req.body,
|
||||||
created_by: req.user?.userId || "system",
|
created_by: req.user?.userId || "system",
|
||||||
|
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
|
||||||
|
company_code: req.body.company_code || req.user?.companyCode || "*",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
|
|
@ -213,7 +215,10 @@ router.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
await ExternalRestApiConnectionService.testConnection(
|
||||||
|
testRequest,
|
||||||
|
req.user?.companyCode
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -264,4 +269,46 @@ router.post(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/external-rest-api-connections/:id/fetch
|
||||||
|
* REST API 데이터 조회 (화면관리용 프록시)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/fetch",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 ID입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endpoint, jsonPath } = req.body;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`);
|
||||||
|
|
||||||
|
const result = await ExternalRestApiConnectionService.fetchData(
|
||||||
|
id,
|
||||||
|
endpoint,
|
||||||
|
jsonPath,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(result.success ? 200 : 400).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("REST API 데이터 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||||
router.post("/move", flowController.moveData);
|
router.post("/move", flowController.moveData);
|
||||||
router.post("/move-batch", flowController.moveBatchData);
|
router.post("/move-batch", flowController.moveBatchData);
|
||||||
|
|
||||||
|
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
|
||||||
|
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
|
||||||
|
|
||||||
// ==================== 오딧 로그 ====================
|
// ==================== 오딧 로그 ====================
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 화면 임베딩 및 데이터 전달 시스템 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
// 화면 임베딩
|
||||||
|
getScreenEmbeddings,
|
||||||
|
getScreenEmbeddingById,
|
||||||
|
createScreenEmbedding,
|
||||||
|
updateScreenEmbedding,
|
||||||
|
deleteScreenEmbedding,
|
||||||
|
// 데이터 전달
|
||||||
|
getScreenDataTransfer,
|
||||||
|
createScreenDataTransfer,
|
||||||
|
updateScreenDataTransfer,
|
||||||
|
deleteScreenDataTransfer,
|
||||||
|
// 분할 패널
|
||||||
|
getScreenSplitPanel,
|
||||||
|
createScreenSplitPanel,
|
||||||
|
updateScreenSplitPanel,
|
||||||
|
deleteScreenSplitPanel,
|
||||||
|
} from "../controllers/screenEmbeddingController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 화면 임베딩 라우트
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 화면 임베딩 목록 조회
|
||||||
|
router.get("/screen-embedding", authenticateToken, getScreenEmbeddings);
|
||||||
|
|
||||||
|
// 화면 임베딩 상세 조회
|
||||||
|
router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById);
|
||||||
|
|
||||||
|
// 화면 임베딩 생성
|
||||||
|
router.post("/screen-embedding", authenticateToken, createScreenEmbedding);
|
||||||
|
|
||||||
|
// 화면 임베딩 수정
|
||||||
|
router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding);
|
||||||
|
|
||||||
|
// 화면 임베딩 삭제
|
||||||
|
router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 데이터 전달 라우트
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 데이터 전달 설정 조회
|
||||||
|
router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer);
|
||||||
|
|
||||||
|
// 데이터 전달 설정 생성
|
||||||
|
router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer);
|
||||||
|
|
||||||
|
// 데이터 전달 설정 수정
|
||||||
|
router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer);
|
||||||
|
|
||||||
|
// 데이터 전달 설정 삭제
|
||||||
|
router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 분할 패널 라우트
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 분할 패널 설정 조회
|
||||||
|
router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel);
|
||||||
|
|
||||||
|
// 분할 패널 설정 생성
|
||||||
|
router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel);
|
||||||
|
|
||||||
|
// 분할 패널 설정 수정
|
||||||
|
router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel);
|
||||||
|
|
||||||
|
// 분할 패널 설정 삭제
|
||||||
|
router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
|
bulkDeleteScreens,
|
||||||
checkScreenDependencies,
|
checkScreenDependencies,
|
||||||
restoreScreen,
|
restoreScreen,
|
||||||
permanentDeleteScreen,
|
permanentDeleteScreen,
|
||||||
|
|
@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
|
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
createColumnMapping,
|
createColumnMapping,
|
||||||
getLogicalColumns,
|
getLogicalColumns,
|
||||||
deleteColumnMapping,
|
deleteColumnMapping,
|
||||||
|
deleteColumnMappingsByColumn,
|
||||||
getSecondLevelMenus,
|
getSecondLevelMenus,
|
||||||
|
getCategoryLabelsByCodes,
|
||||||
} from "../controllers/tableCategoryValueController";
|
} from "../controllers/tableCategoryValueController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -41,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||||
// 카테고리 값 순서 변경
|
// 카테고리 값 순서 변경
|
||||||
router.post("/values/reorder", reorderCategoryValues);
|
router.post("/values/reorder", reorderCategoryValues);
|
||||||
|
|
||||||
|
// 카테고리 코드로 라벨 조회
|
||||||
|
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||||
// ================================================
|
// ================================================
|
||||||
|
|
@ -57,7 +62,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
|
||||||
// 컬럼 매핑 생성/수정
|
// 컬럼 매핑 생성/수정
|
||||||
router.post("/column-mapping", createColumnMapping);
|
router.post("/column-mapping", createColumnMapping);
|
||||||
|
|
||||||
// 컬럼 매핑 삭제
|
// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
|
||||||
|
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
|
||||||
|
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
|
||||||
|
|
||||||
|
// 컬럼 매핑 삭제 (단일)
|
||||||
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
getLogData,
|
getLogData,
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
*/
|
*/
|
||||||
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 범용 다중 테이블 저장 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 테이블 저장 (메인 + 서브 테이블)
|
||||||
|
* POST /api/table-management/multi-table-save
|
||||||
|
*
|
||||||
|
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||||
|
* 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다.
|
||||||
|
*/
|
||||||
|
router.post("/multi-table-save", multiTableSave);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 라우터
|
||||||
|
* /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;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* 차량 운행 이력 및 리포트 라우트
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -178,21 +178,24 @@ export class DashboardService {
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 회사 코드 필터링 (최우선)
|
// 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||||
if (companyCode) {
|
if (companyCode) {
|
||||||
whereConditions.push(`d.company_code = $${paramIndex}`);
|
if (companyCode === '*') {
|
||||||
params.push(companyCode);
|
// 최고 관리자는 모든 대시보드 조회 가능
|
||||||
paramIndex++;
|
} else {
|
||||||
}
|
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
// 권한 필터링
|
paramIndex++;
|
||||||
if (userId) {
|
}
|
||||||
|
} else if (userId) {
|
||||||
|
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
||||||
);
|
);
|
||||||
params.push(userId);
|
params.push(userId);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
} else {
|
||||||
|
// 비로그인 사용자는 공개 대시보드만
|
||||||
whereConditions.push("d.is_public = true");
|
whereConditions.push("d.is_public = true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +231,7 @@ export class DashboardService {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
// 대시보드 목록 조회 (users 테이블 조인 제거)
|
// 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
|
||||||
const dashboardQuery = `
|
const dashboardQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
d.id,
|
d.id,
|
||||||
|
|
@ -242,13 +245,16 @@ export class DashboardService {
|
||||||
d.tags,
|
d.tags,
|
||||||
d.category,
|
d.category,
|
||||||
d.view_count,
|
d.view_count,
|
||||||
|
d.company_code,
|
||||||
|
u.user_name as created_by_name,
|
||||||
COUNT(de.id) as elements_count
|
COUNT(de.id) as elements_count
|
||||||
FROM dashboards d
|
FROM dashboards d
|
||||||
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
||||||
|
LEFT JOIN user_info u ON d.created_by = u.user_id
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
||||||
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
||||||
d.view_count
|
d.view_count, d.company_code, u.user_name
|
||||||
ORDER BY d.updated_at DESC
|
ORDER BY d.updated_at DESC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
@ -277,12 +283,14 @@ export class DashboardService {
|
||||||
thumbnailUrl: row.thumbnail_url,
|
thumbnailUrl: row.thumbnail_url,
|
||||||
isPublic: row.is_public,
|
isPublic: row.is_public,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
|
createdByName: row.created_by_name || row.created_by,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
tags: JSON.parse(row.tags || "[]"),
|
tags: JSON.parse(row.tags || "[]"),
|
||||||
category: row.category,
|
category: row.category,
|
||||||
viewCount: parseInt(row.view_count || "0"),
|
viewCount: parseInt(row.view_count || "0"),
|
||||||
elementsCount: parseInt(row.elements_count || "0"),
|
elementsCount: parseInt(row.elements_count || "0"),
|
||||||
|
companyCode: row.company_code,
|
||||||
})),
|
})),
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
|
|
@ -299,6 +307,8 @@ export class DashboardService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 상세 조회
|
* 대시보드 상세 조회
|
||||||
|
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||||
|
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||||
*/
|
*/
|
||||||
static async getDashboardById(
|
static async getDashboardById(
|
||||||
dashboardId: string,
|
dashboardId: string,
|
||||||
|
|
@ -310,44 +320,43 @@ export class DashboardService {
|
||||||
let dashboardQuery: string;
|
let dashboardQuery: string;
|
||||||
let dashboardParams: any[];
|
let dashboardParams: any[];
|
||||||
|
|
||||||
if (userId) {
|
if (companyCode) {
|
||||||
if (companyCode) {
|
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||||
|
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||||
|
if (companyCode === '*') {
|
||||||
dashboardQuery = `
|
dashboardQuery = `
|
||||||
SELECT d.*
|
SELECT d.*
|
||||||
FROM dashboards d
|
FROM dashboards d
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
AND d.company_code = $2
|
|
||||||
AND (d.created_by = $3 OR d.is_public = true)
|
|
||||||
`;
|
|
||||||
dashboardParams = [dashboardId, companyCode, userId];
|
|
||||||
} else {
|
|
||||||
dashboardQuery = `
|
|
||||||
SELECT d.*
|
|
||||||
FROM dashboards d
|
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
|
||||||
AND (d.created_by = $2 OR d.is_public = true)
|
|
||||||
`;
|
|
||||||
dashboardParams = [dashboardId, userId];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (companyCode) {
|
|
||||||
dashboardQuery = `
|
|
||||||
SELECT d.*
|
|
||||||
FROM dashboards d
|
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
|
||||||
AND d.company_code = $2
|
|
||||||
AND d.is_public = true
|
|
||||||
`;
|
|
||||||
dashboardParams = [dashboardId, companyCode];
|
|
||||||
} else {
|
|
||||||
dashboardQuery = `
|
|
||||||
SELECT d.*
|
|
||||||
FROM dashboards d
|
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
|
||||||
AND d.is_public = true
|
|
||||||
`;
|
`;
|
||||||
dashboardParams = [dashboardId];
|
dashboardParams = [dashboardId];
|
||||||
|
} else {
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND d.company_code = $2
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, companyCode];
|
||||||
}
|
}
|
||||||
|
} else if (userId) {
|
||||||
|
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND (d.created_by = $2 OR d.is_public = true)
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, userId];
|
||||||
|
} else {
|
||||||
|
// 비로그인 사용자는 공개 대시보드만
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND d.is_public = true
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardResult = await PostgreSQLService.query(
|
const dashboardResult = await PostgreSQLService.query(
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,21 @@ export class AdminService {
|
||||||
|
|
||||||
// menuType에 따른 WHERE 조건 생성
|
// menuType에 따른 WHERE 조건 생성
|
||||||
const menuTypeCondition =
|
const menuTypeCondition =
|
||||||
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
|
menuType !== undefined
|
||||||
|
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
|
||||||
|
: "1 = 1";
|
||||||
|
|
||||||
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
||||||
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
||||||
const includeInactive = paramMap.includeInactive === true;
|
const includeInactive = paramMap.includeInactive === true;
|
||||||
const isManagementScreen = includeInactive || menuType === undefined;
|
const isManagementScreen = includeInactive || menuType === undefined;
|
||||||
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
||||||
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
|
const statusCondition = isManagementScreen
|
||||||
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
|
? "1 = 1"
|
||||||
|
: "MENU.STATUS = 'active'";
|
||||||
|
const subStatusCondition = isManagementScreen
|
||||||
|
? "1 = 1"
|
||||||
|
: "MENU_SUB.STATUS = 'active'";
|
||||||
|
|
||||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||||
let authFilter = "";
|
let authFilter = "";
|
||||||
|
|
@ -35,7 +41,11 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
|
if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType !== "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
||||||
const userRoleGroups = await query<any>(
|
const userRoleGroups = await query<any>(
|
||||||
`
|
`
|
||||||
|
|
@ -56,45 +66,45 @@ export class AdminService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userType === "COMPANY_ADMIN") {
|
if (userType === "COMPANY_ADMIN") {
|
||||||
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
|
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||||
if (userRoleGroups.length > 0) {
|
if (userRoleGroups.length > 0) {
|
||||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||||
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
|
||||||
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
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'
|
||||||
|
)
|
||||||
|
`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
const companyParamIndex = paramIndex;
|
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
// 하위 메뉴도 권한 체크
|
||||||
unionFilter = `
|
unionFilter = `
|
||||||
AND (
|
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
|
||||||
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
AND EXISTS (
|
||||||
OR (
|
SELECT 1
|
||||||
MENU_SUB.COMPANY_CODE = '*'
|
FROM rel_menu_auth rma
|
||||||
AND EXISTS (
|
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||||
SELECT 1
|
AND rma.auth_objid = ANY($${paramIndex})
|
||||||
FROM rel_menu_auth rma
|
AND rma.read_yn = 'Y'
|
||||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
|
||||||
AND rma.auth_objid = ANY($${paramIndex})
|
|
||||||
AND rma.read_yn = 'Y'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
queryParams.push(roleObjids);
|
queryParams.push(roleObjids);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
|
||||||
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
logger.warn(
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||||
queryParams.push(userCompanyCode);
|
|
||||||
paramIndex++;
|
|
||||||
logger.info(
|
|
||||||
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
|
|
||||||
);
|
);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자: 권한 그룹 필수
|
// 일반 사용자: 권한 그룹 필수
|
||||||
|
|
@ -131,7 +141,11 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
|
} else if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType === "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||||
|
|
@ -167,7 +181,7 @@ export class AdminService {
|
||||||
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
||||||
if (unionFilter === "") {
|
if (unionFilter === "") {
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
||||||
|
|
|
||||||
|
|
@ -342,4 +342,130 @@ export class AuthService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공차중계 회원가입 처리
|
||||||
|
* - user_info 테이블에 사용자 정보 저장
|
||||||
|
* - vehicles 테이블에 차량 정보 저장
|
||||||
|
*/
|
||||||
|
static async signupDriver(data: {
|
||||||
|
userId: string;
|
||||||
|
password: string;
|
||||||
|
userName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
licenseNumber: string;
|
||||||
|
vehicleNumber: string;
|
||||||
|
vehicleType?: string;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// 1. 중복 사용자 확인
|
||||||
|
const existingUser = await query<any>(
|
||||||
|
`SELECT user_id FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 아이디입니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 중복 차량번호 확인
|
||||||
|
const existingVehicle = await query<any>(
|
||||||
|
`SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`,
|
||||||
|
[vehicleNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingVehicle.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 등록된 차량번호입니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환)
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const hashedPassword = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(password)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// 4. 사용자 정보 저장 (user_info)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_info (
|
||||||
|
user_id,
|
||||||
|
user_password,
|
||||||
|
user_name,
|
||||||
|
cell_phone,
|
||||||
|
license_number,
|
||||||
|
vehicle_number,
|
||||||
|
company_code,
|
||||||
|
user_type,
|
||||||
|
signup_type,
|
||||||
|
status,
|
||||||
|
regdate
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
hashedPassword,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
licenseNumber,
|
||||||
|
vehicleNumber,
|
||||||
|
"COMPANY_13", // 기본 회사 코드
|
||||||
|
null, // user_type: null
|
||||||
|
"DRIVER", // signup_type: 공차중계 회원가입 사용자
|
||||||
|
"active", // status: active
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 차량 정보 저장 (vehicles)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO vehicles (
|
||||||
|
vehicle_number,
|
||||||
|
vehicle_type,
|
||||||
|
driver_name,
|
||||||
|
driver_phone,
|
||||||
|
status,
|
||||||
|
company_code,
|
||||||
|
user_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
vehicleNumber,
|
||||||
|
vehicleType || null,
|
||||||
|
userName,
|
||||||
|
phoneNumber,
|
||||||
|
"off", // 초기 상태: off (대기)
|
||||||
|
"COMPANY_13", // 기본 회사 코드
|
||||||
|
userId, // 사용자 ID 연결
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "회원가입이 완료되었습니다.",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("공차중계 회원가입 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message || "회원가입 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
||||||
try {
|
try {
|
||||||
const log = await queryOne<BatchExecutionLog>(
|
const log = await queryOne<BatchExecutionLog>(
|
||||||
`INSERT INTO batch_execution_logs (
|
`INSERT INTO batch_execution_logs (
|
||||||
batch_config_id, execution_status, start_time, end_time,
|
batch_config_id, company_code, execution_status, start_time, end_time,
|
||||||
duration_ms, total_records, success_records, failed_records,
|
duration_ms, total_records, success_records, failed_records,
|
||||||
error_message, error_details, server_name, process_id
|
error_message, error_details, server_name, process_id
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.batch_config_id,
|
data.batch_config_id,
|
||||||
|
data.company_code,
|
||||||
data.execution_status,
|
data.execution_status,
|
||||||
data.start_time || new Date(),
|
data.start_time || new Date(),
|
||||||
data.end_time,
|
data.end_time,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,258 +1,121 @@
|
||||||
// 배치 스케줄러 서비스
|
import cron, { ScheduledTask } from "node-cron";
|
||||||
// 작성일: 2024-12-24
|
|
||||||
|
|
||||||
import * as cron from "node-cron";
|
|
||||||
import { query, queryOne } from "../database/db";
|
|
||||||
import { BatchService } from "./batchService";
|
import { BatchService } from "./batchService";
|
||||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
export class BatchSchedulerService {
|
export class BatchSchedulerService {
|
||||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
||||||
private static isInitialized = false;
|
|
||||||
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스케줄러 초기화
|
* 모든 활성 배치의 스케줄링 초기화
|
||||||
*/
|
*/
|
||||||
static async initialize() {
|
static async initializeScheduler() {
|
||||||
try {
|
try {
|
||||||
logger.info("배치 스케줄러 초기화 시작...");
|
logger.info("배치 스케줄러 초기화 시작");
|
||||||
|
|
||||||
// 기존 모든 스케줄 정리 (중복 방지)
|
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||||
this.clearAllSchedules();
|
is_active: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||||
await this.loadActiveBatchConfigs();
|
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||||
|
|
||||||
this.isInitialized = true;
|
|
||||||
logger.info("배치 스케줄러 초기화 완료");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("배치 스케줄러 초기화 실패:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 스케줄 정리
|
|
||||||
*/
|
|
||||||
private static clearAllSchedules() {
|
|
||||||
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
|
|
||||||
|
|
||||||
for (const [id, task] of this.scheduledTasks) {
|
|
||||||
try {
|
|
||||||
task.stop();
|
|
||||||
task.destroy();
|
|
||||||
logger.info(`스케줄 정리 완료: ID ${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`스케줄 정리 실패: ID ${id}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduledTasks.clear();
|
|
||||||
this.isInitialized = false;
|
|
||||||
logger.info("모든 스케줄 정리 완료");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
|
||||||
*/
|
|
||||||
private static async loadActiveBatchConfigs() {
|
|
||||||
try {
|
|
||||||
const activeConfigs = await query<any>(
|
|
||||||
`SELECT
|
|
||||||
bc.*,
|
|
||||||
json_agg(
|
|
||||||
json_build_object(
|
|
||||||
'id', bm.id,
|
|
||||||
'batch_config_id', bm.batch_config_id,
|
|
||||||
'from_connection_type', bm.from_connection_type,
|
|
||||||
'from_connection_id', bm.from_connection_id,
|
|
||||||
'from_table_name', bm.from_table_name,
|
|
||||||
'from_column_name', bm.from_column_name,
|
|
||||||
'from_column_type', bm.from_column_type,
|
|
||||||
'to_connection_type', bm.to_connection_type,
|
|
||||||
'to_connection_id', bm.to_connection_id,
|
|
||||||
'to_table_name', bm.to_table_name,
|
|
||||||
'to_column_name', bm.to_column_name,
|
|
||||||
'to_column_type', bm.to_column_type,
|
|
||||||
'mapping_order', bm.mapping_order,
|
|
||||||
'from_api_url', bm.from_api_url,
|
|
||||||
'from_api_key', bm.from_api_key,
|
|
||||||
'from_api_method', bm.from_api_method,
|
|
||||||
'from_api_param_type', bm.from_api_param_type,
|
|
||||||
'from_api_param_name', bm.from_api_param_name,
|
|
||||||
'from_api_param_value', bm.from_api_param_value,
|
|
||||||
'from_api_param_source', bm.from_api_param_source,
|
|
||||||
'to_api_url', bm.to_api_url,
|
|
||||||
'to_api_key', bm.to_api_key,
|
|
||||||
'to_api_method', bm.to_api_method,
|
|
||||||
'to_api_body', bm.to_api_body
|
|
||||||
)
|
|
||||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
|
||||||
FROM batch_configs bc
|
|
||||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
|
||||||
WHERE bc.is_active = 'Y'
|
|
||||||
GROUP BY bc.id`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
|
||||||
|
|
||||||
for (const config of activeConfigs) {
|
|
||||||
await this.scheduleBatchConfig(config);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("활성화된 배치 설정 로드 실패:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치 설정을 스케줄에 등록
|
|
||||||
*/
|
|
||||||
static async scheduleBatchConfig(config: any) {
|
|
||||||
try {
|
|
||||||
const { id, batch_name, cron_schedule } = config;
|
|
||||||
|
|
||||||
// 기존 스케줄이 있다면 제거
|
|
||||||
if (this.scheduledTasks.has(id)) {
|
|
||||||
this.scheduledTasks.get(id)?.stop();
|
|
||||||
this.scheduledTasks.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// cron 스케줄 유효성 검사
|
|
||||||
if (!cron.validate(cron_schedule)) {
|
|
||||||
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 스케줄 등록
|
const batchConfigs = batchConfigsResponse.data;
|
||||||
const task = cron.schedule(cron_schedule, async () => {
|
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||||
// 중복 실행 방지 체크
|
|
||||||
if (this.executingBatches.has(id)) {
|
|
||||||
logger.warn(
|
|
||||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
for (const config of batchConfigs) {
|
||||||
|
await this.scheduleBatch(config);
|
||||||
// 실행 중 플래그 설정
|
|
||||||
this.executingBatches.add(id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.executeBatchConfig(config);
|
|
||||||
} finally {
|
|
||||||
// 실행 완료 후 플래그 제거
|
|
||||||
this.executingBatches.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
|
||||||
task.start();
|
|
||||||
|
|
||||||
this.scheduledTasks.set(id, task);
|
|
||||||
logger.info(
|
|
||||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배치 설정 스케줄 제거
|
|
||||||
*/
|
|
||||||
static async unscheduleBatchConfig(batchConfigId: number) {
|
|
||||||
try {
|
|
||||||
if (this.scheduledTasks.has(batchConfigId)) {
|
|
||||||
this.scheduledTasks.get(batchConfigId)?.stop();
|
|
||||||
this.scheduledTasks.delete(batchConfigId);
|
|
||||||
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("배치 스케줄러 초기화 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 설정 업데이트 시 스케줄 재등록
|
* 개별 배치 작업 스케줄링
|
||||||
|
*/
|
||||||
|
static async scheduleBatch(config: any) {
|
||||||
|
try {
|
||||||
|
// 기존 스케줄이 있으면 제거
|
||||||
|
if (this.scheduledTasks.has(config.id)) {
|
||||||
|
this.scheduledTasks.get(config.id)?.stop();
|
||||||
|
this.scheduledTasks.delete(config.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.is_active !== "Y") {
|
||||||
|
logger.info(
|
||||||
|
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cron.validate(config.cron_schedule)) {
|
||||||
|
logger.error(
|
||||||
|
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`배치 스케줄 등록: ${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", // 한국 시간 기준으로 스케줄 실행
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.scheduledTasks.set(config.id, task);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 스케줄 업데이트 (설정 변경 시 호출)
|
||||||
*/
|
*/
|
||||||
static async updateBatchSchedule(
|
static async updateBatchSchedule(
|
||||||
configId: number,
|
configId: number,
|
||||||
executeImmediately: boolean = true
|
executeImmediately: boolean = true
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 기존 스케줄 제거
|
const result = await BatchService.getBatchConfigById(configId);
|
||||||
await this.unscheduleBatchConfig(configId);
|
if (!result.success || !result.data) {
|
||||||
|
// 설정이 없으면 스케줄 제거
|
||||||
// 업데이트된 배치 설정 조회
|
if (this.scheduledTasks.has(configId)) {
|
||||||
const configResult = await query<any>(
|
this.scheduledTasks.get(configId)?.stop();
|
||||||
`SELECT
|
this.scheduledTasks.delete(configId);
|
||||||
bc.*,
|
}
|
||||||
json_agg(
|
|
||||||
json_build_object(
|
|
||||||
'id', bm.id,
|
|
||||||
'batch_config_id', bm.batch_config_id,
|
|
||||||
'from_connection_type', bm.from_connection_type,
|
|
||||||
'from_connection_id', bm.from_connection_id,
|
|
||||||
'from_table_name', bm.from_table_name,
|
|
||||||
'from_column_name', bm.from_column_name,
|
|
||||||
'from_column_type', bm.from_column_type,
|
|
||||||
'to_connection_type', bm.to_connection_type,
|
|
||||||
'to_connection_id', bm.to_connection_id,
|
|
||||||
'to_table_name', bm.to_table_name,
|
|
||||||
'to_column_name', bm.to_column_name,
|
|
||||||
'to_column_type', bm.to_column_type,
|
|
||||||
'mapping_order', bm.mapping_order,
|
|
||||||
'from_api_url', bm.from_api_url,
|
|
||||||
'from_api_key', bm.from_api_key,
|
|
||||||
'from_api_method', bm.from_api_method,
|
|
||||||
'from_api_param_type', bm.from_api_param_type,
|
|
||||||
'from_api_param_name', bm.from_api_param_name,
|
|
||||||
'from_api_param_value', bm.from_api_param_value,
|
|
||||||
'from_api_param_source', bm.from_api_param_source,
|
|
||||||
'to_api_url', bm.to_api_url,
|
|
||||||
'to_api_key', bm.to_api_key,
|
|
||||||
'to_api_method', bm.to_api_method,
|
|
||||||
'to_api_body', bm.to_api_body
|
|
||||||
)
|
|
||||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
|
||||||
FROM batch_configs bc
|
|
||||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
|
||||||
WHERE bc.id = $1
|
|
||||||
GROUP BY bc.id`,
|
|
||||||
[configId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = configResult[0] || null;
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 활성화된 배치만 다시 스케줄 등록
|
const config = result.data;
|
||||||
if (config.is_active === "Y") {
|
|
||||||
await this.scheduleBatchConfig(config);
|
|
||||||
logger.info(
|
|
||||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 활성화 시 즉시 실행 (옵션)
|
// 스케줄 재등록
|
||||||
if (executeImmediately) {
|
await this.scheduleBatch(config);
|
||||||
logger.info(
|
|
||||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
// 즉시 실행 옵션이 있으면 실행
|
||||||
);
|
/*
|
||||||
await this.executeBatchConfig(config);
|
if (executeImmediately && config.is_active === "Y") {
|
||||||
}
|
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||||
} else {
|
this.executeBatchConfig(config).catch((err) =>
|
||||||
logger.info(
|
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -268,10 +131,19 @@ export class BatchSchedulerService {
|
||||||
try {
|
try {
|
||||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||||
|
|
||||||
|
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
||||||
|
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||||
|
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||||
|
if (fullConfig.success && fullConfig.data) {
|
||||||
|
config = fullConfig.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
const executionLogResponse =
|
const executionLogResponse =
|
||||||
await BatchExecutionLogService.createExecutionLog({
|
await BatchExecutionLogService.createExecutionLog({
|
||||||
batch_config_id: config.id,
|
batch_config_id: config.id,
|
||||||
|
company_code: config.company_code,
|
||||||
execution_status: "RUNNING",
|
execution_status: "RUNNING",
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
total_records: 0,
|
total_records: 0,
|
||||||
|
|
@ -313,7 +185,7 @@ export class BatchSchedulerService {
|
||||||
// 성공 결과 반환
|
// 성공 결과 반환
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||||
|
|
||||||
// 실행 로그 업데이트 (실패)
|
// 실행 로그 업데이트 (실패)
|
||||||
if (executionLog) {
|
if (executionLog) {
|
||||||
|
|
@ -323,11 +195,10 @@ export class BatchSchedulerService {
|
||||||
duration_ms: Date.now() - startTime.getTime(),
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
error_message:
|
error_message:
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
error_details: error instanceof Error ? error.stack : String(error),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실패 시에도 결과 반환
|
// 실패 결과 반환
|
||||||
return {
|
return {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
successRecords: 0,
|
successRecords: 0,
|
||||||
|
|
@ -350,9 +221,16 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블별로 매핑을 그룹화
|
// 테이블별로 매핑을 그룹화
|
||||||
|
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
|
||||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||||
|
const fixedMappingsGlobal: typeof config.batch_mappings = [];
|
||||||
|
|
||||||
for (const mapping of config.batch_mappings) {
|
for (const mapping of config.batch_mappings) {
|
||||||
|
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
|
||||||
|
if (mapping.mapping_type === "fixed") {
|
||||||
|
fixedMappingsGlobal.push(mapping);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
||||||
if (!tableGroups.has(key)) {
|
if (!tableGroups.has(key)) {
|
||||||
tableGroups.set(key, []);
|
tableGroups.set(key, []);
|
||||||
|
|
@ -360,6 +238,14 @@ export class BatchSchedulerService {
|
||||||
tableGroups.get(key)!.push(mapping);
|
tableGroups.get(key)!.push(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
|
||||||
|
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
|
||||||
|
);
|
||||||
|
return { totalRecords, successRecords, failedRecords };
|
||||||
|
}
|
||||||
|
|
||||||
// 각 테이블 그룹별로 처리
|
// 각 테이블 그룹별로 처리
|
||||||
for (const [tableKey, mappings] of tableGroups) {
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -379,9 +265,47 @@ export class BatchSchedulerService {
|
||||||
const { BatchExternalDbService } = await import(
|
const { BatchExternalDbService } = await import(
|
||||||
"./batchExternalDbService"
|
"./batchExternalDbService"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
|
||||||
|
let apiKey = firstMapping.from_api_key || "";
|
||||||
|
if (config.auth_service_name) {
|
||||||
|
let tokenQuery: string;
|
||||||
|
let tokenParams: any[];
|
||||||
|
|
||||||
|
if (config.company_code === "*") {
|
||||||
|
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
|
||||||
|
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||||
|
WHERE service_name = $1
|
||||||
|
ORDER BY created_date DESC LIMIT 1`;
|
||||||
|
tokenParams = [config.auth_service_name];
|
||||||
|
} else {
|
||||||
|
// 일반 회사 배치: 자신의 회사 토큰만 조회
|
||||||
|
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||||
|
WHERE service_name = $1 AND company_code = $2
|
||||||
|
ORDER BY created_date DESC LIMIT 1`;
|
||||||
|
tokenParams = [config.auth_service_name, config.company_code];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await query<{ access_token: string }>(
|
||||||
|
tokenQuery,
|
||||||
|
tokenParams
|
||||||
|
);
|
||||||
|
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||||
|
apiKey = tokenResult[0].access_token;
|
||||||
|
logger.info(
|
||||||
|
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||||
firstMapping.from_api_url!,
|
firstMapping.from_api_url!,
|
||||||
firstMapping.from_api_key!,
|
apiKey,
|
||||||
firstMapping.from_table_name,
|
firstMapping.from_table_name,
|
||||||
(firstMapping.from_api_method as
|
(firstMapping.from_api_method as
|
||||||
| "GET"
|
| "GET"
|
||||||
|
|
@ -394,11 +318,42 @@ export class BatchSchedulerService {
|
||||||
firstMapping.from_api_param_type,
|
firstMapping.from_api_param_type,
|
||||||
firstMapping.from_api_param_name,
|
firstMapping.from_api_param_name,
|
||||||
firstMapping.from_api_param_value,
|
firstMapping.from_api_param_value,
|
||||||
firstMapping.from_api_param_source
|
firstMapping.from_api_param_source,
|
||||||
|
// 👇 Body 전달 (FROM - REST API - POST 요청)
|
||||||
|
firstMapping.from_api_body
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiResult.success && apiResult.data) {
|
if (apiResult.success && apiResult.data) {
|
||||||
fromData = 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;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -416,9 +371,25 @@ export class BatchSchedulerService {
|
||||||
totalRecords += fromData.length;
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||||
|
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
|
||||||
|
const getValueByPath = (obj: any, path: string) => {
|
||||||
|
if (!path) return undefined;
|
||||||
|
// path가 'response.access_token' 처럼 점을 포함하는 경우
|
||||||
|
if (path.includes(".")) {
|
||||||
|
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||||
|
}
|
||||||
|
// 단순 키인 경우
|
||||||
|
return obj[path];
|
||||||
|
};
|
||||||
|
|
||||||
const mappedData = fromData.map((row) => {
|
const mappedData = fromData.map((row) => {
|
||||||
const mappedRow: any = {};
|
const mappedRow: any = {};
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
|
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
|
||||||
|
if (mapping.mapping_type === "fixed") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// DB → REST API 배치인지 확인
|
// DB → REST API 배치인지 확인
|
||||||
if (
|
if (
|
||||||
firstMapping.to_connection_type === "restapi" &&
|
firstMapping.to_connection_type === "restapi" &&
|
||||||
|
|
@ -428,10 +399,32 @@ export class BatchSchedulerService {
|
||||||
mappedRow[mapping.from_column_name] =
|
mappedRow[mapping.from_column_name] =
|
||||||
row[mapping.from_column_name];
|
row[mapping.from_column_name];
|
||||||
} else {
|
} else {
|
||||||
// 기존 로직: to_column_name을 키로 사용
|
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
// row[mapping.from_column_name] 대신 getValueByPath 사용
|
||||||
|
const value = getValueByPath(row, mapping.from_column_name);
|
||||||
|
|
||||||
|
mappedRow[mapping.to_column_name] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 고정값 매핑 적용 (전역으로 분리된 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를 명시적으로 다루지 않은 경우만
|
||||||
|
if (
|
||||||
|
firstMapping.to_connection_type !== "restapi" &&
|
||||||
|
config.company_code &&
|
||||||
|
mappedRow.company_code === undefined
|
||||||
|
) {
|
||||||
|
mappedRow.company_code = config.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
return mappedRow;
|
return mappedRow;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -482,42 +475,31 @@ export class BatchSchedulerService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기존 REST API 전송 (REST API → DB 배치)
|
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
// 지원하지 않음
|
||||||
firstMapping.to_api_url!,
|
logger.warn(
|
||||||
firstMapping.to_api_key!,
|
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||||
firstMapping.to_table_name,
|
|
||||||
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
|
||||||
mappedData
|
|
||||||
);
|
);
|
||||||
|
insertResult = { successCount: 0, failedCount: 0 };
|
||||||
if (apiResult.success && apiResult.data) {
|
|
||||||
insertResult = apiResult.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// DB에 데이터 삽입
|
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
|
||||||
insertResult = await BatchService.insertDataToTable(
|
insertResult = await BatchService.insertDataToTable(
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
mappedData,
|
mappedData,
|
||||||
firstMapping.to_connection_type as "internal" | "external",
|
firstMapping.to_connection_type as "internal" | "external",
|
||||||
firstMapping.to_connection_id || undefined
|
firstMapping.to_connection_id || undefined,
|
||||||
|
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
|
||||||
|
config.conflict_key || undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
successRecords += insertResult.successCount;
|
successRecords += insertResult.successCount;
|
||||||
failedRecords += insertResult.failedCount;
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||||
failedRecords += 1;
|
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||||
|
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,153 +507,9 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
* 개별 배치 작업 스케줄링 (scheduleBatch의 별칭)
|
||||||
*/
|
*/
|
||||||
private static async processBatchMappings(config: any) {
|
static async scheduleBatchConfig(config: any) {
|
||||||
const { batch_mappings } = config;
|
return this.scheduleBatch(config);
|
||||||
let totalRecords = 0;
|
|
||||||
let successRecords = 0;
|
|
||||||
let failedRecords = 0;
|
|
||||||
|
|
||||||
if (!batch_mappings || batch_mappings.length === 0) {
|
|
||||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
|
||||||
return { totalRecords, successRecords, failedRecords };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mapping of batch_mappings) {
|
|
||||||
try {
|
|
||||||
logger.info(
|
|
||||||
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// FROM 테이블에서 데이터 조회
|
|
||||||
const fromData = await this.getDataFromSource(mapping);
|
|
||||||
totalRecords += fromData.length;
|
|
||||||
|
|
||||||
// TO 테이블에 데이터 삽입
|
|
||||||
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
|
||||||
successRecords += insertResult.successCount;
|
|
||||||
failedRecords += insertResult.failedCount;
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
failedRecords += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { totalRecords, successRecords, failedRecords };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FROM 테이블에서 데이터 조회
|
|
||||||
*/
|
|
||||||
private static async getDataFromSource(mapping: any) {
|
|
||||||
try {
|
|
||||||
if (mapping.from_connection_type === "internal") {
|
|
||||||
// 내부 DB에서 조회
|
|
||||||
const result = await query<any>(
|
|
||||||
`SELECT * FROM ${mapping.from_table_name}`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
// 외부 DB에서 조회 (구현 필요)
|
|
||||||
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TO 테이블에 데이터 삽입
|
|
||||||
*/
|
|
||||||
private static async insertDataToTarget(mapping: any, data: any[]) {
|
|
||||||
let successCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mapping.to_connection_type === "internal") {
|
|
||||||
// 내부 DB에 삽입
|
|
||||||
for (const record of data) {
|
|
||||||
try {
|
|
||||||
// 매핑된 컬럼만 추출
|
|
||||||
const mappedData = this.mapColumns(record, mapping);
|
|
||||||
|
|
||||||
const columns = Object.keys(mappedData);
|
|
||||||
const values = Object.values(mappedData);
|
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
|
||||||
|
|
||||||
await query(
|
|
||||||
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
successCount++;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`레코드 삽입 실패:`, error);
|
|
||||||
failedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 외부 DB에 삽입 (구현 필요)
|
|
||||||
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
|
|
||||||
failedCount = data.length;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { successCount, failedCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 매핑
|
|
||||||
*/
|
|
||||||
private static mapColumns(record: any, mapping: any) {
|
|
||||||
const mappedData: any = {};
|
|
||||||
|
|
||||||
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
|
||||||
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
|
||||||
|
|
||||||
return mappedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 스케줄 중지
|
|
||||||
*/
|
|
||||||
static async stopAllSchedules() {
|
|
||||||
try {
|
|
||||||
for (const [id, task] of this.scheduledTasks) {
|
|
||||||
task.stop();
|
|
||||||
logger.info(`배치 스케줄 중지: ID ${id}`);
|
|
||||||
}
|
|
||||||
this.scheduledTasks.clear();
|
|
||||||
this.isInitialized = false;
|
|
||||||
logger.info("모든 배치 스케줄이 중지되었습니다.");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("배치 스케줄 중지 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 등록된 스케줄 목록 조회
|
|
||||||
*/
|
|
||||||
static getScheduledTasks() {
|
|
||||||
return Array.from(this.scheduledTasks.keys());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* 동적 데이터 서비스
|
* 동적 데이터 서비스
|
||||||
*
|
*
|
||||||
* 주요 특징:
|
* 주요 특징:
|
||||||
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
||||||
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
||||||
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
||||||
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
||||||
*
|
*
|
||||||
* 보안:
|
* 보안:
|
||||||
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
||||||
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
||||||
|
|
@ -70,11 +70,11 @@ class DataService {
|
||||||
|
|
||||||
// 그룹별로 데이터 분류
|
// 그룹별로 데이터 분류
|
||||||
const groups: Record<string, any[]> = {};
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const row of data) {
|
for (const row of data) {
|
||||||
const groupKey = row[config.groupByColumn];
|
const groupKey = row[config.groupByColumn];
|
||||||
if (groupKey === undefined || groupKey === null) continue;
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
if (!groups[groupKey]) {
|
if (!groups[groupKey]) {
|
||||||
groups[groupKey] = [];
|
groups[groupKey] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -83,12 +83,12 @@ class DataService {
|
||||||
|
|
||||||
// 각 그룹에서 하나의 행만 선택
|
// 각 그룹에서 하나의 행만 선택
|
||||||
const result: any[] = [];
|
const result: any[] = [];
|
||||||
|
|
||||||
for (const [groupKey, rows] of Object.entries(groups)) {
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
if (rows.length === 0) continue;
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
let selectedRow: any;
|
let selectedRow: any;
|
||||||
|
|
||||||
switch (config.keepStrategy) {
|
switch (config.keepStrategy) {
|
||||||
case "latest":
|
case "latest":
|
||||||
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
|
@ -103,7 +103,7 @@ class DataService {
|
||||||
}
|
}
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "earliest":
|
case "earliest":
|
||||||
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
if (config.sortColumn) {
|
if (config.sortColumn) {
|
||||||
|
|
@ -117,38 +117,41 @@ class DataService {
|
||||||
}
|
}
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "base_price":
|
case "base_price":
|
||||||
// base_price = true인 행 찾기
|
// base_price = true인 행 찾기
|
||||||
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "current_date":
|
case "current_date":
|
||||||
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||||
|
|
||||||
selectedRow = rows.find(row => {
|
selectedRow =
|
||||||
const startDate = row.start_date ? new Date(row.start_date) : null;
|
rows.find((row) => {
|
||||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
const startDate = row.start_date
|
||||||
|
? new Date(row.start_date)
|
||||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
: null;
|
||||||
if (endDate) endDate.setHours(0, 0, 0, 0);
|
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||||
|
|
||||||
const afterStart = !startDate || today >= startDate;
|
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||||
const beforeEnd = !endDate || today <= endDate;
|
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
return afterStart && beforeEnd;
|
const afterStart = !startDate || today >= startDate;
|
||||||
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
const beforeEnd = !endDate || today <= endDate;
|
||||||
|
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(selectedRow);
|
result.push(selectedRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,12 +233,17 @@ class DataService {
|
||||||
|
|
||||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
tableName,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
queryParams.push(userCompany);
|
queryParams.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +516,8 @@ class DataService {
|
||||||
const entityJoinService = new EntityJoinService();
|
const entityJoinService = new EntityJoinService();
|
||||||
|
|
||||||
// Entity Join 구성 감지
|
// Entity Join 구성 감지
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(tableName);
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||||
|
|
@ -518,7 +527,7 @@ class DataService {
|
||||||
tableName,
|
tableName,
|
||||||
joinConfigs,
|
joinConfigs,
|
||||||
["*"],
|
["*"],
|
||||||
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await pool.query(joinQuery, [id]);
|
const result = await pool.query(joinQuery, [id]);
|
||||||
|
|
@ -533,14 +542,14 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -551,17 +560,20 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
console.log(
|
||||||
|
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
|
||||||
|
normalizedRows[0]
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||||
if (groupByColumns.length > 0) {
|
if (groupByColumns.length > 0) {
|
||||||
const baseRecord = result.rows[0];
|
const baseRecord = result.rows[0];
|
||||||
|
|
||||||
// 그룹핑 컬럼들의 값 추출
|
// 그룹핑 컬럼들의 값 추출
|
||||||
const groupConditions: string[] = [];
|
const groupConditions: string[] = [];
|
||||||
const groupValues: any[] = [];
|
const groupValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
for (const col of groupByColumns) {
|
for (const col of groupByColumns) {
|
||||||
const value = normalizedRows[0][col];
|
const value = normalizedRows[0][col];
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
|
|
@ -570,12 +582,15 @@ class DataService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupConditions.length > 0) {
|
if (groupConditions.length > 0) {
|
||||||
const groupWhereClause = groupConditions.join(" AND ");
|
const groupWhereClause = groupConditions.join(" AND ");
|
||||||
|
|
||||||
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
console.log(
|
||||||
|
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
|
||||||
|
groupValues
|
||||||
|
);
|
||||||
|
|
||||||
// 그룹핑 기준으로 모든 레코드 조회
|
// 그룹핑 기준으로 모든 레코드 조회
|
||||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -583,12 +598,14 @@ class DataService {
|
||||||
["*"],
|
["*"],
|
||||||
groupWhereClause
|
groupWhereClause
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupResult = await pool.query(groupQuery, groupValues);
|
const groupResult = await pool.query(groupQuery, groupValues);
|
||||||
|
|
||||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
console.log(
|
||||||
|
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||||
|
|
@ -642,7 +659,8 @@ class DataService {
|
||||||
dataFilter?: any, // 🆕 데이터 필터
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||||
deduplication?: { // 🆕 중복 제거 설정
|
deduplication?: {
|
||||||
|
// 🆕 중복 제거 설정
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupByColumn: string;
|
groupByColumn: string;
|
||||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
|
@ -666,36 +684,41 @@ class DataService {
|
||||||
if (enableEntityJoin) {
|
if (enableEntityJoin) {
|
||||||
try {
|
try {
|
||||||
const { entityJoinService } = await import("./entityJoinService");
|
const { entityJoinService } = await import("./entityJoinService");
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(rightTable);
|
||||||
|
|
||||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||||
if (displayColumns && Array.isArray(displayColumns)) {
|
if (displayColumns && Array.isArray(displayColumns)) {
|
||||||
// 테이블별로 요청된 컬럼들을 그룹핑
|
// 테이블별로 요청된 컬럼들을 그룹핑
|
||||||
const tableColumns: Record<string, Set<string>> = {};
|
const tableColumns: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
for (const col of displayColumns) {
|
for (const col of displayColumns) {
|
||||||
if (col.name && col.name.includes('.')) {
|
if (col.name && col.name.includes(".")) {
|
||||||
const [refTable, refColumn] = col.name.split('.');
|
const [refTable, refColumn] = col.name.split(".");
|
||||||
if (!tableColumns[refTable]) {
|
if (!tableColumns[refTable]) {
|
||||||
tableColumns[refTable] = new Set();
|
tableColumns[refTable] = new Set();
|
||||||
}
|
}
|
||||||
tableColumns[refTable].add(refColumn);
|
tableColumns[refTable].add(refColumn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 각 테이블별로 처리
|
// 각 테이블별로 처리
|
||||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
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) {
|
if (existingJoins.length > 0) {
|
||||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||||
for (const refColumn of refColumns) {
|
for (const refColumn of refColumns) {
|
||||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||||
const existingJoin = existingJoins.find(
|
const existingJoin = existingJoins.find(
|
||||||
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
(jc) =>
|
||||||
|
jc.displayColumns.length === 1 &&
|
||||||
|
jc.displayColumns[0] === refColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!existingJoin) {
|
if (!existingJoin) {
|
||||||
// 없으면 새 조인 설정 복제하여 추가
|
// 없으면 새 조인 설정 복제하여 추가
|
||||||
const baseJoin = existingJoins[0];
|
const baseJoin = existingJoins[0];
|
||||||
|
|
@ -708,7 +731,9 @@ class DataService {
|
||||||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||||
};
|
};
|
||||||
joinConfigs.push(newJoin);
|
joinConfigs.push(newJoin);
|
||||||
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
console.log(
|
||||||
|
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -718,7 +743,9 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
console.log(
|
||||||
|
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
|
||||||
|
);
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
|
|
@ -735,7 +762,10 @@ class DataService {
|
||||||
|
|
||||||
// 회사별 필터링
|
// 회사별 필터링
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
|
|
@ -744,48 +774,64 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
dataFilter &&
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const { buildDataFilterWhereClause } = await import(
|
||||||
|
"../utils/dataFilterUtil"
|
||||||
|
);
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"main",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
const whereClause =
|
||||||
|
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||||
|
|
||||||
// Entity 조인 쿼리 빌드
|
// Entity 조인 쿼리 빌드
|
||||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||||
const selectColumns = ["*"];
|
const selectColumns = ["*"];
|
||||||
|
|
||||||
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
const { query: finalQuery, aliasMap } =
|
||||||
rightTable,
|
entityJoinService.buildJoinQuery(
|
||||||
joinConfigs,
|
rightTable,
|
||||||
selectColumns,
|
joinConfigs,
|
||||||
whereClause,
|
selectColumns,
|
||||||
"",
|
whereClause,
|
||||||
undefined,
|
"",
|
||||||
undefined
|
undefined,
|
||||||
);
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
||||||
console.log(`🔍 파라미터:`, values);
|
console.log(`🔍 파라미터:`, values);
|
||||||
|
|
||||||
const result = await pool.query(finalQuery, values);
|
const result = await pool.query(finalQuery, values);
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결
|
// 🔧 날짜 타입 타임존 문제 해결
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -794,18 +840,24 @@ class DataService {
|
||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
console.log(
|
||||||
|
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = normalizedRows;
|
let finalData = normalizedRows;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: finalData,
|
data: finalData,
|
||||||
|
|
@ -838,23 +890,40 @@ class DataService {
|
||||||
|
|
||||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
dataFilter &&
|
||||||
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"r",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -871,9 +940,13 @@ class DataService {
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = result;
|
let finalData = result;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(result, deduplication);
|
finalData = this.deduplicateData(result, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -907,8 +980,31 @@ class DataService {
|
||||||
return validation.error!;
|
return validation.error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = Object.keys(data);
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
const values = Object.values(data);
|
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 placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||||
|
|
||||||
|
|
@ -951,9 +1047,32 @@ class DataService {
|
||||||
|
|
||||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||||
const relationInfo = data._relationInfo;
|
const relationInfo = data._relationInfo;
|
||||||
const cleanData = { ...data };
|
let cleanData = { ...data };
|
||||||
delete cleanData._relationInfo;
|
delete cleanData._relationInfo;
|
||||||
|
|
||||||
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
|
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||||
|
const validColumnNames = new Set(
|
||||||
|
tableColumns.map((col: any) => col.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidColumns: string[] = [];
|
||||||
|
cleanData = Object.fromEntries(
|
||||||
|
Object.entries(cleanData).filter(([key]) => {
|
||||||
|
if (validColumnNames.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
invalidColumns.push(key);
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidColumns.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Primary Key 컬럼 찾기
|
// Primary Key 컬럼 찾기
|
||||||
const pkResult = await query<{ attname: string }>(
|
const pkResult = await query<{ attname: string }>(
|
||||||
`SELECT a.attname
|
`SELECT a.attname
|
||||||
|
|
@ -993,8 +1112,14 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||||
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
if (
|
||||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
relationInfo &&
|
||||||
|
relationInfo.rightTable &&
|
||||||
|
relationInfo.leftColumn &&
|
||||||
|
relationInfo.rightColumn
|
||||||
|
) {
|
||||||
|
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
|
||||||
|
relationInfo;
|
||||||
const newLeftValue = cleanData[leftColumn];
|
const newLeftValue = cleanData[leftColumn];
|
||||||
|
|
||||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||||
|
|
@ -1012,8 +1137,13 @@ class DataService {
|
||||||
SET "${rightColumn}" = $1
|
SET "${rightColumn}" = $1
|
||||||
WHERE "${rightColumn}" = $2
|
WHERE "${rightColumn}" = $2
|
||||||
`;
|
`;
|
||||||
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
const updateResult = await query(updateRelatedQuery, [
|
||||||
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
newLeftValue,
|
||||||
|
oldLeftValue,
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
|
||||||
|
);
|
||||||
} catch (relError) {
|
} catch (relError) {
|
||||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||||
|
|
@ -1064,9 +1194,11 @@ class DataService {
|
||||||
|
|
||||||
if (pkResult.length > 1) {
|
if (pkResult.length > 1) {
|
||||||
// 복합키인 경우: id가 객체여야 함
|
// 복합키인 경우: 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' }
|
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
||||||
pkResult.forEach((pk, index) => {
|
pkResult.forEach((pk, index) => {
|
||||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||||
|
|
@ -1081,15 +1213,17 @@ class DataService {
|
||||||
// 단일키인 경우
|
// 단일키인 경우
|
||||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||||
whereClauses.push(`"${pkColumn}" = $1`);
|
whereClauses.push(`"${pkColumn}" = $1`);
|
||||||
params.push(typeof id === 'object' ? id[pkColumn] : id);
|
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
console.log(
|
||||||
|
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -1128,7 +1262,11 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whereConditions.length === 0) {
|
if (whereConditions.length === 0) {
|
||||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "삭제 조건이 없습니다.",
|
||||||
|
error: "NO_CONDITIONS",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
@ -1163,7 +1301,9 @@ class DataService {
|
||||||
records: Array<Record<string, any>>,
|
records: Array<Record<string, any>>,
|
||||||
userCompany?: string,
|
userCompany?: string,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
): Promise<
|
||||||
|
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 권한 검증
|
// 테이블 접근 권한 검증
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1201,11 +1341,14 @@ class DataService {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||||
|
|
||||||
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
console.log(`📋 기존 레코드 조회:`, {
|
||||||
|
query: selectQuery,
|
||||||
|
values: whereValues,
|
||||||
|
});
|
||||||
|
|
||||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||||
|
|
||||||
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||||
|
|
||||||
// 2. 새 레코드와 기존 레코드 비교
|
// 2. 새 레코드와 기존 레코드 비교
|
||||||
|
|
@ -1216,50 +1359,53 @@ class DataService {
|
||||||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||||
const normalizeDateValue = (value: any): any => {
|
const normalizeDateValue = (value: any): any => {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
|
|
||||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새 레코드 처리 (INSERT or UPDATE)
|
// 새 레코드 처리 (INSERT or UPDATE)
|
||||||
for (const newRecord of records) {
|
for (const newRecord of records) {
|
||||||
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
||||||
|
|
||||||
// 날짜 필드 정규화
|
// 날짜 필드 정규화
|
||||||
const normalizedRecord: Record<string, any> = {};
|
const normalizedRecord: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(newRecord)) {
|
for (const [key, value] of Object.entries(newRecord)) {
|
||||||
normalizedRecord[key] = normalizeDateValue(value);
|
normalizedRecord[key] = normalizeDateValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
||||||
|
|
||||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||||
|
|
||||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||||
const uniqueFields = Object.keys(normalizedRecord);
|
const uniqueFields = Object.keys(normalizedRecord);
|
||||||
|
|
||||||
console.log(`🔑 고유 필드들:`, uniqueFields);
|
console.log(`🔑 고유 필드들:`, uniqueFields);
|
||||||
|
|
||||||
// 기존 레코드에서 일치하는 것 찾기
|
// 기존 레코드에서 일치하는 것 찾기
|
||||||
const existingRecord = existingRecords.rows.find((existing) => {
|
const existingRecord = existingRecords.rows.find((existing) => {
|
||||||
return uniqueFields.every((field) => {
|
return uniqueFields.every((field) => {
|
||||||
const existingValue = existing[field];
|
const existingValue = existing[field];
|
||||||
const newValue = normalizedRecord[field];
|
const newValue = normalizedRecord[field];
|
||||||
|
|
||||||
// null/undefined 처리
|
// null/undefined 처리
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
// Date 타입 처리
|
// Date 타입 처리
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 비교
|
// 문자열 비교
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
});
|
});
|
||||||
|
|
@ -1272,7 +1418,8 @@ class DataService {
|
||||||
let updateParamIndex = 1;
|
let updateParamIndex = 1;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fullRecord)) {
|
for (const [key, value] of Object.entries(fullRecord)) {
|
||||||
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
if (key !== pkColumn) {
|
||||||
|
// Primary Key는 업데이트하지 않음
|
||||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||||
updateValues.push(value);
|
updateValues.push(value);
|
||||||
updateParamIndex++;
|
updateParamIndex++;
|
||||||
|
|
@ -1288,36 +1435,42 @@ class DataService {
|
||||||
|
|
||||||
await pool.query(updateQuery, updateValues);
|
await pool.query(updateQuery, updateValues);
|
||||||
updated++;
|
updated++;
|
||||||
|
|
||||||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
} else {
|
} else {
|
||||||
// INSERT: 기존 레코드가 없으면 삽입
|
// INSERT: 기존 레코드가 없으면 삽입
|
||||||
|
|
||||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||||
|
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||||
|
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||||
const recordWithMeta: Record<string, any> = {
|
const recordWithMeta: Record<string, any> = {
|
||||||
...fullRecord,
|
...recordWithoutCreatedDate,
|
||||||
id: uuidv4(), // 새 ID 생성
|
id: uuidv4(), // 새 ID 생성
|
||||||
created_date: "NOW()",
|
created_date: "NOW()",
|
||||||
updated_date: "NOW()",
|
updated_date: "NOW()",
|
||||||
};
|
};
|
||||||
|
|
||||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
if (
|
||||||
|
!recordWithMeta.company_code &&
|
||||||
|
userCompany &&
|
||||||
|
userCompany !== "*"
|
||||||
|
) {
|
||||||
recordWithMeta.company_code = userCompany;
|
recordWithMeta.company_code = userCompany;
|
||||||
}
|
}
|
||||||
|
|
||||||
// writer가 없으면 userId 사용
|
// writer가 없으면 userId 사용
|
||||||
if (!recordWithMeta.writer && userId) {
|
if (!recordWithMeta.writer && userId) {
|
||||||
recordWithMeta.writer = userId;
|
recordWithMeta.writer = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
const insertFields = Object.keys(recordWithMeta).filter(
|
||||||
recordWithMeta[key] !== "NOW()"
|
(key) => recordWithMeta[key] !== "NOW()"
|
||||||
);
|
);
|
||||||
const insertPlaceholders: string[] = [];
|
const insertPlaceholders: string[] = [];
|
||||||
const insertValues: any[] = [];
|
const insertValues: any[] = [];
|
||||||
let insertParamIndex = 1;
|
let insertParamIndex = 1;
|
||||||
|
|
||||||
for (const field of Object.keys(recordWithMeta)) {
|
for (const field of Object.keys(recordWithMeta)) {
|
||||||
if (recordWithMeta[field] === "NOW()") {
|
if (recordWithMeta[field] === "NOW()") {
|
||||||
insertPlaceholders.push("NOW()");
|
insertPlaceholders.push("NOW()");
|
||||||
|
|
@ -1329,15 +1482,20 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertQuery = `
|
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(", ")})
|
VALUES (${insertPlaceholders.join(", ")})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
console.log(`➕ INSERT 쿼리:`, {
|
||||||
|
query: insertQuery,
|
||||||
|
values: insertValues,
|
||||||
|
});
|
||||||
|
|
||||||
await pool.query(insertQuery, insertValues);
|
await pool.query(insertQuery, insertValues);
|
||||||
inserted++;
|
inserted++;
|
||||||
|
|
||||||
console.log(`➕ INSERT: 새 레코드`);
|
console.log(`➕ INSERT: 새 레코드`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1345,19 +1503,22 @@ class DataService {
|
||||||
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||||
for (const existingRecord of existingRecords.rows) {
|
for (const existingRecord of existingRecords.rows) {
|
||||||
const uniqueFields = Object.keys(records[0] || {});
|
const uniqueFields = Object.keys(records[0] || {});
|
||||||
|
|
||||||
const stillExists = records.some((newRecord) => {
|
const stillExists = records.some((newRecord) => {
|
||||||
return uniqueFields.every((field) => {
|
return uniqueFields.every((field) => {
|
||||||
const existingValue = existingRecord[field];
|
const existingValue = existingRecord[field];
|
||||||
const newValue = newRecord[field];
|
const newValue = newRecord[field];
|
||||||
|
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1367,7 +1528,7 @@ class DataService {
|
||||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||||
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||||
deleted++;
|
deleted++;
|
||||||
|
|
||||||
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -134,8 +134,8 @@ export class EntityJoinService {
|
||||||
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
|
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
|
||||||
logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`);
|
logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`);
|
||||||
|
|
||||||
// 참조 테이블의 모든 컬럼 이름 가져오기
|
// 참조 테이블의 모든 컬럼 이름 가져오기
|
||||||
const tableColumnsResult = await query<{ column_name: string }>(
|
const tableColumnsResult = await query<{ column_name: string }>(
|
||||||
|
|
@ -148,10 +148,34 @@ export class EntityJoinService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tableColumnsResult.length > 0) {
|
if (tableColumnsResult.length > 0) {
|
||||||
displayColumns = tableColumnsResult.map((col) => col.column_name);
|
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];
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
|
`✅ ${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
|
||||||
displayColumns.join(", ")
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 테이블 컬럼을 못 찾으면 기본값 사용
|
// 테이블 컬럼을 못 찾으면 기본값 사용
|
||||||
|
|
@ -403,18 +427,25 @@ export class EntityJoinService {
|
||||||
const fromClause = `FROM ${tableName} main`;
|
const fromClause = `FROM ${tableName} main`;
|
||||||
|
|
||||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||||
|
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
|
|
||||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
// 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`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
lastUsedAt: Date;
|
lastUsedAt: Date;
|
||||||
activeConnections = 0;
|
activeConnections = 0;
|
||||||
maxConnections: number;
|
maxConnections: number;
|
||||||
|
private isPoolClosed = false;
|
||||||
|
|
||||||
constructor(config: ExternalDbConnection) {
|
constructor(config: ExternalDbConnection) {
|
||||||
this.connectionId = config.id!;
|
this.connectionId = config.id!;
|
||||||
|
|
@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
connectTimeout: (config.connection_timeout || 30) * 1000,
|
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||||
|
// 연결 유지 및 자동 재연결 설정
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
|
||||||
ssl:
|
ssl:
|
||||||
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
|
|
||||||
async query(sql: string, params?: any[]): Promise<any> {
|
async query(sql: string, params?: any[]): Promise<any> {
|
||||||
this.lastUsedAt = new Date();
|
this.lastUsedAt = new Date();
|
||||||
|
|
||||||
|
// 연결 풀이 닫힌 상태인지 확인
|
||||||
|
if (this.isPoolClosed) {
|
||||||
|
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const [rows] = await this.pool.execute(sql, params);
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
// 연결 닫힘 오류 감지
|
||||||
|
if (
|
||||||
|
error.message.includes("closed state") ||
|
||||||
|
error.code === "PROTOCOL_CONNECTION_LOST" ||
|
||||||
|
error.code === "ECONNRESET"
|
||||||
|
) {
|
||||||
|
this.isPoolClosed = true;
|
||||||
|
logger.warn(
|
||||||
|
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
this.isPoolClosed = true;
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||||
|
|
@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
isHealthy(): boolean {
|
||||||
|
// 연결 풀이 닫혔으면 비정상
|
||||||
|
if (this.isPoolClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return this.activeConnections < this.maxConnections;
|
return this.activeConnections < this.maxConnections;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService {
|
||||||
): Promise<ConnectionPoolWrapper> {
|
): Promise<ConnectionPoolWrapper> {
|
||||||
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||||
|
|
||||||
// DB 연결 정보 조회
|
// DB 연결 정보 조회 (실제 비밀번호 포함)
|
||||||
const connectionResult =
|
const connectionResult =
|
||||||
await ExternalDbConnectionService.getConnectionById(connectionId);
|
await ExternalDbConnectionService.getConnectionByIdWithPassword(
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
if (!connectionResult.success || !connectionResult.data) {
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||||
|
|
@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 실행 (자동으로 연결 풀 관리)
|
* 쿼리 실행 (자동으로 연결 풀 관리 + 재시도 로직)
|
||||||
*/
|
*/
|
||||||
async executeQuery(
|
async executeQuery(
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
sql: string,
|
sql: string,
|
||||||
params?: any[]
|
params?: any[],
|
||||||
|
retryCount = 0
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const pool = await this.getPool(connectionId);
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const pool = await this.getPool(connectionId);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||||
);
|
);
|
||||||
|
|
@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService {
|
||||||
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`e.company_code = $${paramIndex++}`);
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 조건 적용
|
// 필터 조건 적용
|
||||||
if (filter.db_type) {
|
if (filter.db_type) {
|
||||||
whereConditions.push(`db_type = $${paramIndex++}`);
|
whereConditions.push(`e.db_type = $${paramIndex++}`);
|
||||||
params.push(filter.db_type);
|
params.push(filter.db_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
whereConditions.push(`is_active = $${paramIndex++}`);
|
whereConditions.push(`e.is_active = $${paramIndex++}`);
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
||||||
if (filter.search && filter.search.trim()) {
|
if (filter.search && filter.search.trim()) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
`(e.connection_name ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex})`
|
||||||
);
|
);
|
||||||
params.push(`%${filter.search.trim()}%`);
|
params.push(`%${filter.search.trim()}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -72,9 +72,12 @@ export class ExternalDbConnectionService {
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const connections = await query<any>(
|
const connections = await query<any>(
|
||||||
`SELECT * FROM external_db_connections
|
`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
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY is_active DESC, connection_name ASC`,
|
ORDER BY e.is_active DESC, e.connection_name ASC`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { Pool, QueryResult } from "pg";
|
import { Pool, QueryResult } from "pg";
|
||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
import https from "https";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,11 +31,17 @@ export class ExternalRestApiConnectionService {
|
||||||
try {
|
try {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
e.default_method,
|
||||||
company_code, is_active, created_date, created_by,
|
-- DB 스키마의 컬럼명은 default_request_body 기준이고
|
||||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
-- 코드에서는 default_body 필드로 사용하기 위해 alias 처리
|
||||||
FROM external_rest_api_connections
|
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
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -42,7 +50,7 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(userCompanyCode);
|
params.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
|
||||||
|
|
@ -50,14 +58,14 @@ export class ExternalRestApiConnectionService {
|
||||||
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
|
||||||
// 필터가 있으면 적용
|
// 필터가 있으면 적용
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userCompanyCode가 없는 경우 (하위 호환성)
|
// userCompanyCode가 없는 경우 (하위 호환성)
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
query += ` AND company_code = $${paramIndex}`;
|
query += ` AND e.company_code = $${paramIndex}`;
|
||||||
params.push(filter.company_code);
|
params.push(filter.company_code);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -65,14 +73,14 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
// 활성 상태 필터
|
// 활성 상태 필터
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
query += ` AND is_active = $${paramIndex}`;
|
query += ` AND e.is_active = $${paramIndex}`;
|
||||||
params.push(filter.is_active);
|
params.push(filter.is_active);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 타입 필터
|
// 인증 타입 필터
|
||||||
if (filter.auth_type) {
|
if (filter.auth_type) {
|
||||||
query += ` AND auth_type = $${paramIndex}`;
|
query += ` AND e.auth_type = $${paramIndex}`;
|
||||||
params.push(filter.auth_type);
|
params.push(filter.auth_type);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +88,9 @@ export class ExternalRestApiConnectionService {
|
||||||
// 검색어 필터 (연결명, 설명, URL)
|
// 검색어 필터 (연결명, 설명, URL)
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
query += ` AND (
|
query += ` AND (
|
||||||
connection_name ILIKE $${paramIndex} OR
|
e.connection_name ILIKE $${paramIndex} OR
|
||||||
description ILIKE $${paramIndex} OR
|
e.description ILIKE $${paramIndex} OR
|
||||||
base_url ILIKE $${paramIndex}
|
e.base_url ILIKE $${paramIndex}
|
||||||
)`;
|
)`;
|
||||||
params.push(`%${filter.search}%`);
|
params.push(`%${filter.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -129,6 +137,8 @@ export class ExternalRestApiConnectionService {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
|
default_method,
|
||||||
|
default_request_body AS default_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_date, created_by,
|
company_code, is_active, created_date, created_by,
|
||||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||||
|
|
@ -158,6 +168,9 @@ export class ExternalRestApiConnectionService {
|
||||||
? this.decryptSensitiveData(connection.auth_config)
|
? this.decryptSensitiveData(connection.auth_config)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 디버깅: 조회된 연결 정보 로깅
|
||||||
|
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: connection,
|
data: connection,
|
||||||
|
|
@ -194,9 +207,10 @@ export class ExternalRestApiConnectionService {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO external_rest_api_connections (
|
INSERT INTO external_rest_api_connections (
|
||||||
connection_name, description, base_url, endpoint_path, default_headers,
|
connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
|
default_method, default_request_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_by
|
company_code, is_active, created_by, save_to_history
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -206,6 +220,8 @@ export class ExternalRestApiConnectionService {
|
||||||
data.base_url,
|
data.base_url,
|
||||||
data.endpoint_path || null,
|
data.endpoint_path || null,
|
||||||
JSON.stringify(data.default_headers || {}),
|
JSON.stringify(data.default_headers || {}),
|
||||||
|
data.default_method || "GET",
|
||||||
|
data.default_body || null,
|
||||||
data.auth_type,
|
data.auth_type,
|
||||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||||
data.timeout || 30000,
|
data.timeout || 30000,
|
||||||
|
|
@ -214,8 +230,19 @@ export class ExternalRestApiConnectionService {
|
||||||
data.company_code || "*",
|
data.company_code || "*",
|
||||||
data.is_active || "Y",
|
data.is_active || "Y",
|
||||||
data.created_by || "system",
|
data.created_by || "system",
|
||||||
|
data.save_to_history || "N",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
|
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||||
|
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,
|
||||||
|
default_body: data.default_body ? "있음" : "없음",
|
||||||
|
});
|
||||||
|
|
||||||
const result: QueryResult<any> = await pool.query(query, params);
|
const result: QueryResult<any> = await pool.query(query, params);
|
||||||
|
|
||||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||||
|
|
@ -301,6 +328,20 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.default_method !== undefined) {
|
||||||
|
updateFields.push(`default_method = $${paramIndex}`);
|
||||||
|
params.push(data.default_method);
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.default_body !== undefined) {
|
||||||
|
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||||
|
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||||
|
paramIndex++;
|
||||||
|
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.auth_type !== undefined) {
|
if (data.auth_type !== undefined) {
|
||||||
updateFields.push(`auth_type = $${paramIndex}`);
|
updateFields.push(`auth_type = $${paramIndex}`);
|
||||||
params.push(data.auth_type);
|
params.push(data.auth_type);
|
||||||
|
|
@ -337,6 +378,12 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.save_to_history !== undefined) {
|
||||||
|
updateFields.push(`save_to_history = $${paramIndex}`);
|
||||||
|
params.push(data.save_to_history);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.updated_by !== undefined) {
|
if (data.updated_by !== undefined) {
|
||||||
updateFields.push(`updated_by = $${paramIndex}`);
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
params.push(data.updated_by);
|
params.push(data.updated_by);
|
||||||
|
|
@ -437,38 +484,125 @@ export class ExternalRestApiConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 헤더 생성
|
||||||
|
*/
|
||||||
|
static async getAuthHeaders(
|
||||||
|
authType: AuthType,
|
||||||
|
authConfig: any,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (authType === "db-token") {
|
||||||
|
const cfg = authConfig || {};
|
||||||
|
const {
|
||||||
|
dbTableName,
|
||||||
|
dbValueColumn,
|
||||||
|
dbWhereColumn,
|
||||||
|
dbWhereValue,
|
||||||
|
dbHeaderName,
|
||||||
|
dbHeaderTemplate,
|
||||||
|
} = cfg;
|
||||||
|
|
||||||
|
if (!dbTableName || !dbValueColumn) {
|
||||||
|
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWhereColumn = !!dbWhereColumn;
|
||||||
|
const hasWhereValue =
|
||||||
|
dbWhereValue !== undefined &&
|
||||||
|
dbWhereValue !== null &&
|
||||||
|
dbWhereValue !== "";
|
||||||
|
|
||||||
|
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||||
|
if (hasWhereColumn !== hasWhereValue) {
|
||||||
|
throw new Error(
|
||||||
|
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 식별자 검증 (간단한 화이트리스트)
|
||||||
|
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
if (
|
||||||
|
!identifierRegex.test(dbTableName) ||
|
||||||
|
!identifierRegex.test(dbValueColumn) ||
|
||||||
|
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT ${dbValueColumn} AS token_value
|
||||||
|
FROM ${dbTableName}
|
||||||
|
WHERE company_code = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
|
||||||
|
if (hasWhereColumn && hasWhereValue) {
|
||||||
|
sql += ` AND ${dbWhereColumn} = $2`;
|
||||||
|
params.push(dbWhereValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||||
|
|
||||||
|
if (tokenResult.rowCount === 0) {
|
||||||
|
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||||
|
const headerName = dbHeaderName || "Authorization";
|
||||||
|
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||||
|
|
||||||
|
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||||
|
} else if (authType === "bearer" && authConfig?.token) {
|
||||||
|
headers["Authorization"] = `Bearer ${authConfig.token}`;
|
||||||
|
} else if (authType === "basic" && authConfig) {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${authConfig.username}:${authConfig.password}`
|
||||||
|
).toString("base64");
|
||||||
|
headers["Authorization"] = `Basic ${credentials}`;
|
||||||
|
} else if (authType === "api-key" && authConfig) {
|
||||||
|
if (authConfig.keyLocation === "header") {
|
||||||
|
headers[authConfig.keyName] = authConfig.keyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||||
*/
|
*/
|
||||||
static async testConnection(
|
static async testConnection(
|
||||||
testRequest: RestApiTestRequest
|
testRequest: RestApiTestRequest,
|
||||||
|
userCompanyCode?: string
|
||||||
): Promise<RestApiTestResult> {
|
): Promise<RestApiTestResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 헤더 구성
|
// 헤더 구성
|
||||||
const headers = { ...testRequest.headers };
|
let headers = { ...testRequest.headers };
|
||||||
|
|
||||||
// 인증 헤더 추가
|
// 인증 헤더 생성 및 병합
|
||||||
if (
|
const authHeaders = await this.getAuthHeaders(
|
||||||
testRequest.auth_type === "bearer" &&
|
testRequest.auth_type,
|
||||||
testRequest.auth_config?.token
|
testRequest.auth_config,
|
||||||
) {
|
userCompanyCode
|
||||||
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
|
);
|
||||||
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
|
headers = { ...headers, ...authHeaders };
|
||||||
const credentials = Buffer.from(
|
|
||||||
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
|
||||||
).toString("base64");
|
|
||||||
headers["Authorization"] = `Basic ${credentials}`;
|
|
||||||
} else if (
|
|
||||||
testRequest.auth_type === "api-key" &&
|
|
||||||
testRequest.auth_config
|
|
||||||
) {
|
|
||||||
if (testRequest.auth_config.keyLocation === "header") {
|
|
||||||
headers[testRequest.auth_config.keyName] =
|
|
||||||
testRequest.auth_config.keyValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL 구성
|
// URL 구성
|
||||||
let url = testRequest.base_url;
|
let url = testRequest.base_url;
|
||||||
|
|
@ -493,25 +627,84 @@ export class ExternalRestApiConnectionService {
|
||||||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// HTTP 요청 실행
|
// Body 처리
|
||||||
const response = await fetch(url, {
|
let body: any = undefined;
|
||||||
method: testRequest.method || "GET",
|
if (testRequest.body) {
|
||||||
headers,
|
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
if (typeof testRequest.body === "string") {
|
||||||
});
|
body = testRequest.body;
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify(testRequest.body);
|
||||||
|
}
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||||
let responseData = null;
|
const hasContentType = Object.keys(headers).some(
|
||||||
|
(k) => k.toLowerCase() === "content-type"
|
||||||
try {
|
);
|
||||||
responseData = await response.json();
|
if (!hasContentType) {
|
||||||
} catch {
|
headers["Content-Type"] = "application/json";
|
||||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP 요청 실행
|
||||||
|
// [인수인계 중요] 2024-11-27 추가
|
||||||
|
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
|
||||||
|
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
|
||||||
|
//
|
||||||
|
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
|
||||||
|
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
|
||||||
|
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
|
||||||
|
//
|
||||||
|
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
|
||||||
|
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
|
||||||
|
const bypassDomains = ["thiratis.com"];
|
||||||
|
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||||
|
url.includes(domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
const httpsAgent = new https.Agent({
|
||||||
|
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
|
||||||
|
rejectUnauthorized: !shouldBypassTls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
url,
|
||||||
|
method: (testRequest.method || "GET") as any,
|
||||||
|
headers,
|
||||||
|
data: body,
|
||||||
|
httpsAgent,
|
||||||
|
timeout: testRequest.timeout || 30000,
|
||||||
|
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
||||||
|
validateStatus: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 요청 상세 로그 (민감 정보는 최소화)
|
||||||
|
logger.info(
|
||||||
|
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
|
||||||
|
method: requestConfig.method,
|
||||||
|
url: requestConfig.url,
|
||||||
|
headers: {
|
||||||
|
...requestConfig.headers,
|
||||||
|
// Authorization 헤더는 마스킹
|
||||||
|
Authorization: requestConfig.headers?.Authorization
|
||||||
|
? "***masked***"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
hasBody: !!body,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: AxiosResponse = await axios.request(requestConfig);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
|
||||||
|
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
|
||||||
|
const responseData = response.data ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: response.ok,
|
success: response.status >= 200 && response.status < 300,
|
||||||
message: response.ok
|
message:
|
||||||
|
response.status >= 200 && response.status < 300
|
||||||
? "연결 성공"
|
? "연결 성공"
|
||||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||||
response_time: responseTime,
|
response_time: responseTime,
|
||||||
|
|
@ -552,17 +745,27 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
const connection = connectionResult.data;
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 리스트에서 endpoint를 넘기지 않으면,
|
||||||
|
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||||
|
const effectiveEndpoint =
|
||||||
|
endpoint || connection.endpoint_path || undefined;
|
||||||
|
|
||||||
const testRequest: RestApiTestRequest = {
|
const testRequest: RestApiTestRequest = {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
base_url: connection.base_url,
|
base_url: connection.base_url,
|
||||||
endpoint,
|
endpoint: effectiveEndpoint,
|
||||||
|
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
|
||||||
headers: connection.default_headers,
|
headers: connection.default_headers,
|
||||||
|
body: connection.default_body, // 기본 바디 적용
|
||||||
auth_type: connection.auth_type,
|
auth_type: connection.auth_type,
|
||||||
auth_config: connection.auth_config,
|
auth_config: connection.auth_config,
|
||||||
timeout: connection.timeout,
|
timeout: connection.timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this.testConnection(testRequest);
|
const result = await this.testConnection(
|
||||||
|
testRequest,
|
||||||
|
connection.company_code
|
||||||
|
);
|
||||||
|
|
||||||
// 테스트 결과 저장
|
// 테스트 결과 저장
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|
@ -580,11 +783,34 @@ export class ExternalRestApiConnectionService {
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("REST API 연결 테스트 (ID) 오류:", error);
|
logger.error("REST API 연결 테스트 (ID) 오류:", error);
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||||
|
|
||||||
|
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
UPDATE external_rest_api_connections
|
||||||
|
SET
|
||||||
|
last_test_date = NOW(),
|
||||||
|
last_test_result = $1,
|
||||||
|
last_test_message = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
["N", errorMessage, id]
|
||||||
|
);
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(
|
||||||
|
"REST API 연결 테스트 (ID) 오류 기록 실패:",
|
||||||
|
updateError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 테스트에 실패했습니다.",
|
message: "연결 테스트에 실패했습니다.",
|
||||||
error_details:
|
error_details: errorMessage,
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -683,6 +909,166 @@ export class ExternalRestApiConnectionService {
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 데이터 조회 (화면관리용 프록시)
|
||||||
|
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||||
|
*/
|
||||||
|
static async fetchData(
|
||||||
|
connectionId: number,
|
||||||
|
endpoint?: string,
|
||||||
|
jsonPath?: string,
|
||||||
|
userCompanyCode?: string
|
||||||
|
): Promise<ApiResponse<any>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||||
|
|
||||||
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "REST API 연결을 찾을 수 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_NOT_FOUND",
|
||||||
|
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 비활성화된 연결인지 확인
|
||||||
|
if (connection.is_active !== "Y") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "비활성화된 REST API 연결입니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_INACTIVE",
|
||||||
|
details: "연결이 비활성화 상태입니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||||
|
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||||
|
|
||||||
|
// API 호출을 위한 테스트 요청 생성
|
||||||
|
const testRequest: RestApiTestRequest = {
|
||||||
|
id: connection.id,
|
||||||
|
base_url: connection.base_url,
|
||||||
|
endpoint: effectiveEndpoint,
|
||||||
|
method: (connection.default_method as any) || "GET",
|
||||||
|
headers: connection.default_headers,
|
||||||
|
body: connection.default_body,
|
||||||
|
auth_type: connection.auth_type,
|
||||||
|
auth_config: connection.auth_config,
|
||||||
|
timeout: connection.timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const result = await this.testConnection(testRequest, connection.company_code);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || "REST API 호출에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "API_CALL_FAILED",
|
||||||
|
details: result.error_details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||||
|
let extractedData = result.response_data;
|
||||||
|
|
||||||
|
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||||
|
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||||
|
|
||||||
|
if (jsonPath && result.response_data) {
|
||||||
|
try {
|
||||||
|
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||||
|
const pathParts = jsonPath.split(".");
|
||||||
|
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
|
for (const part of pathParts) {
|
||||||
|
if (extractedData && typeof extractedData === "object") {
|
||||||
|
extractedData = (extractedData as any)[part];
|
||||||
|
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (pathError) {
|
||||||
|
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||||
|
// 추출 실패 시 원본 데이터 반환
|
||||||
|
extractedData = result.response_data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||||
|
// null이나 undefined인 경우 빈 배열로 처리
|
||||||
|
let dataArray: any[] = [];
|
||||||
|
if (extractedData === null || extractedData === undefined) {
|
||||||
|
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||||
|
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||||
|
if (result.response_data && typeof result.response_data === "object") {
|
||||||
|
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||||
|
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||||
|
|
||||||
|
// 첫 번째 유효한 객체 찾기
|
||||||
|
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||||
|
|
||||||
|
if (firstValidItem) {
|
||||||
|
columns = Object.keys(firstValidItem).map((key) => ({
|
||||||
|
columnName: key,
|
||||||
|
columnLabel: key,
|
||||||
|
dataType: typeof firstValidItem[key],
|
||||||
|
}));
|
||||||
|
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||||
|
} else {
|
||||||
|
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
rows: dataArray,
|
||||||
|
columns,
|
||||||
|
total: dataArray.length,
|
||||||
|
connectionInfo: {
|
||||||
|
connectionId: connection.id,
|
||||||
|
connectionName: connection.connection_name,
|
||||||
|
baseUrl: connection.base_url,
|
||||||
|
endpoint: effectiveEndpoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("REST API 데이터 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "REST API 데이터 조회에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "FETCH_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 연결 데이터 유효성 검증
|
* 연결 데이터 유효성 검증
|
||||||
*/
|
*/
|
||||||
|
|
@ -709,9 +1095,156 @@ export class ExternalRestApiConnectionService {
|
||||||
"bearer",
|
"bearer",
|
||||||
"basic",
|
"basic",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
|
"db-token",
|
||||||
];
|
];
|
||||||
if (!validAuthTypes.includes(data.auth_type)) {
|
if (!validAuthTypes.includes(data.auth_type)) {
|
||||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 REST API 데이터 조회 및 병합
|
||||||
|
* 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환
|
||||||
|
*/
|
||||||
|
static async fetchMultipleData(
|
||||||
|
configs: Array<{
|
||||||
|
connectionId: number;
|
||||||
|
endpoint: string;
|
||||||
|
jsonPath: string;
|
||||||
|
alias: string;
|
||||||
|
}>,
|
||||||
|
userCompanyCode?: string
|
||||||
|
): Promise<ApiResponse<{
|
||||||
|
rows: any[];
|
||||||
|
columns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }>;
|
||||||
|
total: number;
|
||||||
|
sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`);
|
||||||
|
|
||||||
|
// 각 API에서 데이터 조회
|
||||||
|
const results = await Promise.all(
|
||||||
|
configs.map(async (config) => {
|
||||||
|
try {
|
||||||
|
const result = await this.fetchData(
|
||||||
|
config.connectionId,
|
||||||
|
config.endpoint,
|
||||||
|
config.jsonPath,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
connectionId: config.connectionId,
|
||||||
|
connectionName: result.data.connectionInfo.connectionName,
|
||||||
|
alias: config.alias,
|
||||||
|
rows: result.data.rows,
|
||||||
|
columns: result.data.columns,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.warn(`API ${config.connectionId} 조회 실패:`, result.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
connectionId: config.connectionId,
|
||||||
|
connectionName: "",
|
||||||
|
alias: config.alias,
|
||||||
|
rows: [],
|
||||||
|
columns: [],
|
||||||
|
error: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`API ${config.connectionId} 조회 오류:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
connectionId: config.connectionId,
|
||||||
|
connectionName: "",
|
||||||
|
alias: config.alias,
|
||||||
|
rows: [],
|
||||||
|
columns: [],
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 성공한 결과만 필터링
|
||||||
|
const successfulResults = results.filter(r => r.success);
|
||||||
|
|
||||||
|
if (successfulResults.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "모든 REST API 조회에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "ALL_APIS_FAILED",
|
||||||
|
details: results.map(r => ({ connectionId: r.connectionId, error: r.error })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 병합 (별칭 적용)
|
||||||
|
const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = [];
|
||||||
|
|
||||||
|
for (const result of successfulResults) {
|
||||||
|
for (const col of result.columns) {
|
||||||
|
const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName;
|
||||||
|
mergedColumns.push({
|
||||||
|
columnName: prefixedColumnName,
|
||||||
|
columnLabel: `${col.columnLabel} (${result.connectionName})`,
|
||||||
|
dataType: col.dataType,
|
||||||
|
sourceApi: result.connectionName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합)
|
||||||
|
// 참고: 실제 사용 시에는 조인 키가 필요할 수 있음
|
||||||
|
const maxRows = Math.max(...successfulResults.map(r => r.rows.length));
|
||||||
|
const mergedRows: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRows; i++) {
|
||||||
|
const mergedRow: any = {};
|
||||||
|
|
||||||
|
for (const result of successfulResults) {
|
||||||
|
const row = result.rows[i] || {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
const prefixedKey = result.alias ? `${result.alias}${key}` : key;
|
||||||
|
mergedRow[prefixedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedRows.push(mergedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
rows: mergedRows,
|
||||||
|
columns: mergedColumns,
|
||||||
|
total: mergedRows.length,
|
||||||
|
sources: successfulResults.map(r => ({
|
||||||
|
connectionId: r.connectionId,
|
||||||
|
connectionName: r.connectionName,
|
||||||
|
rowCount: r.rows.length,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("다중 REST API 데이터 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "다중 REST API 데이터 조회에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "MULTI_FETCH_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ export class FlowDataMoveService {
|
||||||
// 내부 DB 처리 (기존 로직)
|
// 내부 DB 처리 (기존 로직)
|
||||||
return await db.transaction(async (client) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
|
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId || "system",
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
@ -684,6 +689,14 @@ export class FlowDataMoveService {
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
async (externalClient, dbType) => {
|
async (externalClient, dbType) => {
|
||||||
try {
|
try {
|
||||||
|
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||||
|
if (dbType.toLowerCase() === "postgresql") {
|
||||||
|
await externalClient.query(
|
||||||
|
"SELECT set_config('app.user_id', $1, true)",
|
||||||
|
[userId || "system"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 단계 정보 조회 (내부 DB에서)
|
// 1. 단계 정보 조회 (내부 DB에서)
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,21 @@ export class FlowDefinitionService {
|
||||||
tableName: request.tableName,
|
tableName: request.tableName,
|
||||||
dbSourceType: request.dbSourceType,
|
dbSourceType: request.dbSourceType,
|
||||||
dbConnectionId: request.dbConnectionId,
|
dbConnectionId: request.dbConnectionId,
|
||||||
|
restApiConnectionId: request.restApiConnectionId,
|
||||||
|
restApiEndpoint: request.restApiEndpoint,
|
||||||
|
restApiJsonPath: request.restApiJsonPath,
|
||||||
|
restApiConnections: request.restApiConnections,
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
|
INSERT INTO flow_definition (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
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)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -43,6 +51,10 @@ export class FlowDefinitionService {
|
||||||
request.tableName || null,
|
request.tableName || null,
|
||||||
request.dbSourceType || "internal",
|
request.dbSourceType || "internal",
|
||||||
request.dbConnectionId || null,
|
request.dbConnectionId || null,
|
||||||
|
request.restApiConnectionId || null,
|
||||||
|
request.restApiEndpoint || null,
|
||||||
|
request.restApiJsonPath || "response",
|
||||||
|
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
];
|
];
|
||||||
|
|
@ -199,6 +211,19 @@ export class FlowDefinitionService {
|
||||||
* DB 행을 FlowDefinition 객체로 변환
|
* DB 행을 FlowDefinition 객체로 변환
|
||||||
*/
|
*/
|
||||||
private mapToFlowDefinition(row: any): FlowDefinition {
|
private mapToFlowDefinition(row: any): FlowDefinition {
|
||||||
|
// rest_api_connections 파싱 (JSONB → 배열)
|
||||||
|
let restApiConnections = undefined;
|
||||||
|
if (row.rest_api_connections) {
|
||||||
|
try {
|
||||||
|
restApiConnections = typeof row.rest_api_connections === 'string'
|
||||||
|
? JSON.parse(row.rest_api_connections)
|
||||||
|
: row.rest_api_connections;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to parse rest_api_connections:", e);
|
||||||
|
restApiConnections = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|
@ -206,6 +231,12 @@ export class FlowDefinitionService {
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
dbSourceType: row.db_source_type || "internal",
|
dbSourceType: row.db_source_type || "internal",
|
||||||
dbConnectionId: row.db_connection_id,
|
dbConnectionId: row.db_connection_id,
|
||||||
|
// REST API 관련 필드 (단일)
|
||||||
|
restApiConnectionId: row.rest_api_connection_id,
|
||||||
|
restApiEndpoint: row.rest_api_endpoint,
|
||||||
|
restApiJsonPath: row.rest_api_json_path,
|
||||||
|
// 다중 REST API 관련 필드
|
||||||
|
restApiConnections: restApiConnections,
|
||||||
companyCode: row.company_code || "*",
|
companyCode: row.company_code || "*",
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
|
|
|
||||||
|
|
@ -263,4 +263,139 @@ export class FlowExecutionService {
|
||||||
tableName: result[0].table_name,
|
tableName: result[0].table_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
* 원본 테이블의 데이터를 직접 업데이트합니다.
|
||||||
|
*/
|
||||||
|
async updateStepData(
|
||||||
|
flowId: number,
|
||||||
|
stepId: number,
|
||||||
|
recordId: string,
|
||||||
|
updateData: Record<string, any>,
|
||||||
|
userId: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
// 1. 플로우 정의 조회
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(flowId);
|
||||||
|
if (!flowDef) {
|
||||||
|
throw new Error(`Flow definition not found: ${flowId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 스텝 조회
|
||||||
|
const step = await this.flowStepService.findById(stepId);
|
||||||
|
if (!step) {
|
||||||
|
throw new Error(`Flow step not found: ${stepId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 테이블명 결정
|
||||||
|
const tableName = step.tableName || flowDef.tableName;
|
||||||
|
if (!tableName) {
|
||||||
|
throw new Error("Table name not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||||
|
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. SET 절 생성
|
||||||
|
const updateColumns = Object.keys(updateData);
|
||||||
|
if (updateColumns.length === 0) {
|
||||||
|
throw new Error("No columns to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 외부 DB vs 내부 DB 구분
|
||||||
|
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||||
|
// 외부 DB 업데이트
|
||||||
|
console.log(
|
||||||
|
"✅ [updateStepData] Using EXTERNAL DB:",
|
||||||
|
flowDef.dbConnectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connectionResult = await db.query(
|
||||||
|
"SELECT * FROM external_db_connection WHERE id = $1",
|
||||||
|
[flowDef.dbConnectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionResult.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`External DB connection not found: ${flowDef.dbConnectionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionResult[0];
|
||||||
|
const dbType = connection.db_type?.toLowerCase();
|
||||||
|
|
||||||
|
// DB 타입에 따른 placeholder 및 쿼리 생성
|
||||||
|
let setClause: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
|
// MySQL/MariaDB: ? placeholder
|
||||||
|
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else if (dbType === "mssql") {
|
||||||
|
// MSSQL: @p1, @p2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `[${col}] = @p${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else {
|
||||||
|
// PostgreSQL: $1, $2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
||||||
|
} else {
|
||||||
|
// 내부 DB 업데이트
|
||||||
|
console.log("✅ [updateStepData] Using INTERNAL DB");
|
||||||
|
|
||||||
|
const setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
const params = [...Object.values(updateData), recordId];
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
||||||
|
// (트리거에서 changed_by를 기록하기 위함)
|
||||||
|
await db.transaction(async (client) => {
|
||||||
|
// 안전한 파라미터 바인딩 방식 사용
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
await client.query(updateQuery, params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
|
||||||
|
{
|
||||||
|
updatedFields: updateColumns,
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [updateStepData] Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -334,9 +334,12 @@ class MailSendSimpleService {
|
||||||
if (variables) {
|
if (variables) {
|
||||||
buttonText = this.replaceVariables(buttonText, variables);
|
buttonText = this.replaceVariables(buttonText, variables);
|
||||||
}
|
}
|
||||||
|
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||||
|
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||||
|
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||||
html += `<div style="margin: 30px 0; text-align: left;">
|
html += `<div style="margin: 30px 0; text-align: left;">
|
||||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
|
|
@ -348,6 +351,89 @@ class MailSendSimpleService {
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||||
break;
|
break;
|
||||||
|
case 'header':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">
|
||||||
|
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||||
|
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||||
|
${component.sendDate || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'infoTable':
|
||||||
|
html += `
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||||
|
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
${(component.rows || []).map((row: any, i: number) => `
|
||||||
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||||
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||||
|
<td style="padding: 12px 16px;">${row.value}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'alertBox':
|
||||||
|
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||||
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||||
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||||
|
};
|
||||||
|
const colors = alertColors[component.alertType || 'info'];
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||||
|
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||||
|
<div>${component.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'divider':
|
||||||
|
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||||
|
break;
|
||||||
|
case 'footer':
|
||||||
|
html += `
|
||||||
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||||
|
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||||
|
${(component.ceoName || component.businessNumber) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||||
|
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||||
|
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||||
|
${(component.phone || component.email) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||||
|
${component.phone && component.email ? ' | ' : ''}
|
||||||
|
${component.email ? `Email: ${component.email}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'numberedList':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px;">
|
||||||
|
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||||
|
<ol style="margin: 0; padding-left: 20px;">
|
||||||
|
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,35 @@ import path from "path";
|
||||||
// MailComponent 인터페이스 정의
|
// MailComponent 인터페이스 정의
|
||||||
export interface MailComponent {
|
export interface MailComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text" | "button" | "image" | "spacer";
|
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||||
content?: string;
|
content?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
styles?: Record<string, string>;
|
styles?: Record<string, string>;
|
||||||
|
// 헤더 컴포넌트용
|
||||||
|
logoSrc?: string;
|
||||||
|
brandName?: string;
|
||||||
|
sendDate?: string;
|
||||||
|
headerBgColor?: string;
|
||||||
|
// 정보 테이블용
|
||||||
|
rows?: Array<{ label: string; value: string }>;
|
||||||
|
tableTitle?: string;
|
||||||
|
// 강조 박스용
|
||||||
|
alertType?: "info" | "warning" | "danger" | "success";
|
||||||
|
alertTitle?: string;
|
||||||
|
// 푸터용
|
||||||
|
companyName?: string;
|
||||||
|
ceoName?: string;
|
||||||
|
businessNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
copyright?: string;
|
||||||
|
// 번호 리스트용
|
||||||
|
listItems?: string[];
|
||||||
|
listTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||||
|
|
@ -236,6 +258,89 @@ class MailTemplateFileService {
|
||||||
case "spacer":
|
case "spacer":
|
||||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||||
break;
|
break;
|
||||||
|
case "header":
|
||||||
|
html += `
|
||||||
|
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">
|
||||||
|
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||||
|
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||||
|
${comp.sendDate || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "infoTable":
|
||||||
|
html += `
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||||
|
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
${(comp.rows || []).map((row, i) => `
|
||||||
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||||
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||||
|
<td style="padding: 12px 16px;">${row.value}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "alertBox":
|
||||||
|
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||||
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||||
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||||
|
};
|
||||||
|
const colors = alertColors[comp.alertType || 'info'];
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||||
|
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||||
|
<div>${comp.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "divider":
|
||||||
|
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||||
|
break;
|
||||||
|
case "footer":
|
||||||
|
html += `
|
||||||
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||||
|
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||||
|
${(comp.ceoName || comp.businessNumber) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||||
|
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||||
|
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||||
|
${(comp.phone || comp.email) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||||
|
${comp.phone && comp.email ? ' | ' : ''}
|
||||||
|
${comp.email ? `Email: ${comp.email}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "numberedList":
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; ${styles}">
|
||||||
|
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||||
|
<ol style="margin: 0; padding-left: 20px;">
|
||||||
|
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
|
||||||
|
*
|
||||||
|
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
|
||||||
|
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
|
||||||
|
*
|
||||||
|
* @param menuObjid 메뉴 OBJID
|
||||||
|
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 메뉴 구조:
|
||||||
|
* // └── 구매관리 (100)
|
||||||
|
* // ├── 공급업체관리 (101)
|
||||||
|
* // ├── 발주관리 (102)
|
||||||
|
* // └── 입고관리 (103)
|
||||||
|
* // └── 입고상세 (104)
|
||||||
|
*
|
||||||
|
* await getMenuAndChildObjids(100);
|
||||||
|
* // 결과: [100, 101, 102, 103, 104]
|
||||||
|
*/
|
||||||
|
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
|
||||||
|
|
||||||
|
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
|
||||||
|
const query = `
|
||||||
|
WITH RECURSIVE menu_tree AS (
|
||||||
|
-- 시작점: 선택한 메뉴
|
||||||
|
SELECT objid, parent_obj_id, 1 AS depth
|
||||||
|
FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 재귀: 하위 메뉴들
|
||||||
|
SELECT m.objid, m.parent_obj_id, mt.depth + 1
|
||||||
|
FROM menu_info m
|
||||||
|
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
|
||||||
|
WHERE mt.depth < 10 -- 무한 루프 방지
|
||||||
|
)
|
||||||
|
SELECT objid FROM menu_tree ORDER BY depth, objid
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [menuObjid]);
|
||||||
|
const objids = result.rows.map((row) => Number(row.objid));
|
||||||
|
|
||||||
|
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
|
||||||
|
menuObjid,
|
||||||
|
totalCount: objids.length,
|
||||||
|
objids
|
||||||
|
});
|
||||||
|
|
||||||
|
return objids;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("메뉴 및 하위 메뉴 조회 실패", {
|
||||||
|
menuObjid,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||||
|
return [menuObjid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue