chpark-sync #425

Merged
kjs merged 293 commits from chpark-sync into main 2026-03-23 09:36:36 +09:00
444 changed files with 99815 additions and 14755 deletions
Showing only changes of commit cbde0894c4 - Show all commits

View File

@ -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
View File

@ -1,28 +1,36 @@
# 프로젝트: Digital Twin 에디터 안정화
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
## 핵심 기능
1. `DigitalTwinEditor` 버그 수정
2. 비동기 함수 입력값 유효성 검증 강화
3. 외부 DB 연결 상태에 따른 방어 코드 추가
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
2. **백엔드 로직 개선**:
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
## 테스트 계획
### 1단계: 기본 기능 및 DB 마이그레이션
- [x] DB 마이그레이션 스크립트 작성 및 실행
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
### 1단계: 긴급 버그 수정
### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
### 3단계: 프론트엔드 구현
- [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
### 2단계: 잠재적 문제 점검
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
- [완료] 모든 단계 구현 완료

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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

View File

@ -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

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -8,6 +8,7 @@ import path from "path";
import config from "./config/environment";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
@ -70,7 +71,15 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import 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 collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -165,6 +174,10 @@ const limiter = rateLimit({
});
app.use("/api/", limiter);
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트
app.get("/health", (req, res) => {
res.status(200).json({
@ -235,7 +248,15 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/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/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@ -280,7 +301,7 @@ app.listen(PORT, HOST, async () => {
// 배치 스케줄러 초기화
try {
await BatchSchedulerService.initialize();
await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);

View File

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

View File

@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { Client } from "pg";
import { query, queryOne } from "../database/db";
import { query, queryOne, getPool } from "../database/db";
import config from "../config/environment";
import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil";
@ -1256,8 +1256,17 @@ export async function updateMenu(
}
}
const requestCompanyCode =
menuData.companyCode || menuData.company_code || currentMenu.company_code;
let requestCompanyCode =
menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = currentMenu.company_code;
}
// 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를 사용한 메뉴 삭제
const [deletedMenu] = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[Number(menuId)]
[menuObjid]
);
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 },
});
}
}

View File

@ -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 : "알 수 없는 오류",
},
});
}
}
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { Response } from "express";
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import {
BatchManagementService,
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
import { query } from "../database/db";
export class BatchManagementController {
/**
@ -265,8 +266,12 @@ export class BatchManagementController {
try {
// 실행 로그 생성
executionLog = await BatchService.createExecutionLog({
const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
const logResult = await BatchExecutionLogService.createExecutionLog({
batch_config_id: Number(id),
company_code: batchConfig.company_code,
execution_status: "RUNNING",
start_time: startTime,
total_records: 0,
@ -274,6 +279,14 @@ export class BatchManagementController {
failed_records: 0,
});
if (!logResult.success || !logResult.data) {
throw new Error(
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
);
}
executionLog = logResult.data;
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
const { BatchSchedulerService } = await import(
"../services/batchSchedulerService"
@ -290,7 +303,7 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime();
// 실행 로그 업데이트 (성공)
await BatchService.updateExecutionLog(executionLog.id, {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
end_time: endTime,
duration_ms: duration,
@ -319,8 +332,11 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime();
// executionLog가 정의되어 있는지 확인
if (typeof executionLog !== "undefined") {
await BatchService.updateExecutionLog(executionLog.id, {
if (typeof executionLog !== "undefined" && executionLog) {
const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
end_time: endTime,
duration_ms: duration,
@ -406,22 +422,70 @@ export class BatchManagementController {
paramName,
paramValue,
paramSource,
requestBody,
authServiceName, // DB에서 토큰 가져올 서비스명
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
} = req.body;
if (!apiUrl || !apiKey || !endpoint) {
// apiUrl, endpoint는 항상 필수
if (!apiUrl || !endpoint) {
return res.status(400).json({
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,
endpoint,
method,
paramType,
paramName,
paramValue,
paramSource,
requestBody: requestBody ? "Included" : "None",
authServiceName: authServiceName || "직접 입력",
dataArrayPath: dataArrayPath || "전체 응답",
});
// RestApiConnector 사용하여 데이터 조회
@ -429,7 +493,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
apiKey: finalApiKey,
timeout: 30000,
});
@ -456,17 +520,78 @@ export class BatchManagementController {
console.log("🔗 최종 엔드포인트:", finalEndpoint);
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
const result = await connector.executeQuery(finalEndpoint, method);
console.log(`[previewRestApiData] executeQuery 결과:`, {
// Request Body 파싱
let parsedBody = undefined;
if (requestBody && typeof requestBody === "string") {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
console.warn("Request Body JSON 파싱 실패:", e);
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
// 여기서는 경고 로그 남기고 진행
}
} else if (requestBody) {
parsedBody = requestBody;
}
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
const result = await connector.executeRequest(
finalEndpoint,
method as "GET" | "POST" | "PUT" | "DELETE",
parsedBody
);
console.log(`[previewRestApiData] executeRequest 결과:`, {
rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : "undefined",
firstRow:
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) {
// 첫 번째 객체에서 필드명 추출
@ -478,9 +603,9 @@ export class BatchManagementController {
data: {
fields: fields,
samples: data,
totalCount: result.rowCount || data.length,
totalCount: extractedData.length,
},
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
});
} else {
return res.json({
@ -508,8 +633,17 @@ export class BatchManagementController {
*/
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, batchType, cronSchedule, description, apiMappings } =
req.body;
const {
batchName,
batchType,
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
} = req.body;
if (
!batchName ||
@ -530,22 +664,36 @@ export class BatchManagementController {
cronSchedule,
description,
apiMappings,
authServiceName,
dataArrayPath,
saveMode,
conflictKey,
});
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
// BatchService를 사용하여 배치 설정 저장
const batchConfig: CreateBatchConfigRequest = {
batchName: batchName,
description: description || "",
cronSchedule: cronSchedule,
isActive: "Y",
companyCode,
authServiceName: authServiceName || undefined,
dataArrayPath: dataArrayPath || undefined,
saveMode: saveMode || "INSERT",
conflictKey: conflictKey || undefined,
mappings: apiMappings,
};
const result = await BatchService.createBatchConfig(batchConfig);
const result = await BatchService.createBatchConfig(batchConfig, userId);
if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅
try {
await BatchSchedulerService.scheduleBatchConfig(result.data);
await BatchSchedulerService.scheduleBatch(result.data);
console.log(
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${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: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -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,
});
}
};

View File

@ -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;
}
}

View File

@ -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,
});
}
};

View File

@ -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,
});
}
};

View File

@ -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,
});
}
};

View File

@ -1,43 +1,25 @@
import { Request, Response } from "express";
import { pool, queryOne } from "../database/db";
import logger from "../utils/logger";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
// 외부 DB 커넥터를 가져오는 헬퍼 함수
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
export async function getExternalDbConnector(connectionId: number) {
// 외부 DB 연결 정보 조회
const connection = await queryOne<any>(
`SELECT * FROM external_db_connections WHERE id = $1`,
[connectionId]
);
const poolService = ExternalDbConnectionPoolService.getInstance();
if (!connection) {
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
}
// 패스워드 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
return {
executeQuery: async (sql: string, params?: any[]) => {
const result = await poolService.executeQuery(connectionId, sql, params);
return { rows: result };
},
};
// DB 커넥터 생성
return await DatabaseConnectorFactory.createConnector(
connection.db_type || "mariadb",
config,
connectionId
);
}
// 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
export const getHierarchyData = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig } = req.body;
@ -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 result: any = {
@ -69,7 +53,7 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({
level: level.level,
name: level.name,
@ -94,7 +78,10 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
levelCounts: result.levels.map((l: any) => ({
level: l.level,
count: l.data.length,
})),
});
return res.json({
@ -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 {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
req.body;
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
if (
!externalDbConnectionId ||
!hierarchyConfig ||
!parentLevel ||
!parentKey
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
const nextLevel = config.levels?.find(
(l: any) => l.level === parentLevel + 1
);
if (!nextLevel) {
return res.json({
@ -168,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 {
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`;
@ -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 {
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 = `
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 {
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 = `
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 {
const {
externalDbConnectionId,
locaKey,
const {
externalDbConnectionId,
locaKey,
tableName,
keyColumn,
locationKeyColumn,
layerColumn
layerColumn,
} = req.query;
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
if (
!externalDbConnectionId ||
!locaKey ||
!tableName ||
!locationKeyColumn
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 동적 쿼리 생성
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
const query = `
SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}'
@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
};
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
export const getMaterialCounts = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, locationKeys, tableName } = req.body;
@ -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(",");

View File

@ -22,11 +22,19 @@ export const getLayouts = async (
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
WHERE l.company_code = $1
`;
const params: any[] = [companyCode];
let paramIndex = 2;
const params: any[] = [];
let paramIndex = 1;
// 최고 관리자는 모든 레이아웃 조회 가능
if (companyCode && companyCode !== '*') {
query += ` WHERE l.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
} else {
query += ` WHERE 1=1`;
}
if (externalDbConnectionId) {
query += ` AND l.external_db_connection_id = $${paramIndex}`;
@ -75,14 +83,27 @@ export const getLayoutById = async (
const companyCode = req.user?.companyCode;
const { id } = req.params;
// 레이아웃 기본 정보
const layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
let layoutQuery: string;
let layoutParams: any[];
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) {
return res.status(404).json({

View File

@ -161,3 +161,4 @@ export const createMappingTemplate = async (

View File

@ -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: "회원 탈퇴 처리 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
};
const result = await dynamicFormService.updateFormDataPartial(
parseInt(id),
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
tableName,
originalData,
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 || "위치 이력 조회에 실패했습니다.",
});
}
};

View File

@ -29,6 +29,7 @@ export class EntityJoinController {
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = 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(
tableName,
{
@ -141,6 +155,7 @@ export class EntityJoinController {
additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
}
);

View File

@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
const companyCode = req.user!.companyCode;
// 검색 필드 파싱
const fields = searchFields
const requestedFields = searchFields
? (searchFields as string).split(",").map((f) => f.trim())
: [];
// 🆕 테이블의 실제 컬럼 목록 조회
const pool = getPool();
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 🆕 존재하는 컬럼만 필터링
const fields = requestedFields.filter((field) => {
if (existingColumns.has(field)) {
return true;
} else {
logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`);
return false;
}
});
const existingColumnsArray = Array.from(existingColumns);
logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`);
// WHERE 조건 생성
const whereConditions: string[] = [];
const params: any[] = [];
@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
// 멀티테넌시 필터링
if (companyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
// 🆕 company_code 컬럼이 있는 경우에만 필터링
if (existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
}
// 검색 조건
if (searchText && fields.length > 0) {
const searchConditions = fields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
if (searchText) {
// 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색
let searchableFields = fields;
if (searchableFields.length === 0) {
// 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명
const defaultSearchColumns = [
'name', 'code', 'description', 'title', 'label',
'item_name', 'item_code', 'item_number',
'equipment_name', 'equipment_code',
'inspection_item', 'consumable_name', // 소모품명 추가
'supplier_name', 'customer_name', 'product_name',
];
searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col));
logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`);
}
if (searchableFields.length > 0) {
const searchConditions = searchableFields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가
fields.forEach(() => {
params.push(`%${searchText}%`);
});
// 검색어 파라미터 추가
searchableFields.forEach(() => {
params.push(`%${searchText}%`);
});
}
}
// 추가 필터 조건
// 추가 필터 조건 (존재하는 컬럼만)
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
if (existingColumns.has(key)) {
whereConditions.push(`${key} = $${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 ")}`
: "";
// 쿼리 실행
const pool = getPool();
// 쿼리 실행 (pool은 위에서 이미 선언됨)
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}

View File

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

View File

@ -32,8 +32,17 @@ export class FlowController {
*/
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, tableName, dbSourceType, dbConnectionId } =
req.body;
const {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
// REST API 관련 필드
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
} = req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
@ -43,6 +52,9 @@ export class FlowController {
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode,
});
@ -54,8 +66,12 @@ export class FlowController {
return;
}
// 테이블 이름이 제공된 경우에만 존재 확인
if (tableName) {
// REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵
const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi";
const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db";
// 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외)
if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) {
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
@ -68,7 +84,17 @@ export class FlowController {
}
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,
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",
});
}
};
}

View File

@ -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,
});
}
}

View File

@ -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();
}
}

View File

@ -148,11 +148,42 @@ export const updateScreenInfo = async (
try {
const { id } = req.params;
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(
parseInt(id),
{ screenName, tableName, description, isActive },
{
screenName,
tableName,
description,
isActive,
dataSourceType,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
},
companyCode
);
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 (
req: AuthenticatedRequest,

View File

@ -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
*

View File

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

View File

@ -870,6 +870,17 @@ export async function addTableData(
const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 데이터 추가
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();
}
}

View File

@ -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 || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -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 || "구간별 통계 조회에 실패했습니다.",
});
}
};

View File

@ -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 || "운행 취소에 실패했습니다.",
});
}
};

View File

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

View File

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

View File

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

View File

@ -18,6 +18,8 @@ import {
getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!)
getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크

View File

@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
*/
router.post("/refresh", AuthController.refreshToken);
/**
* POST /api/auth/signup
* API
*/
router.post("/signup", AuthController.signup);
export default router;

View File

@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
*/
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
/**
* GET /api/batch-management/auth-services
*
*/
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
export default router;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
*
* POST /api/dataflow/node-flows/:flowId/execute
*/
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const contextData = req.body;
router.post(
"/:flowId/execute",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const contextData = req.body;
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
contextDataKeys: Object.keys(contextData),
userId: req.user?.userId,
companyCode: req.user?.companyCode,
});
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
contextDataKeys: Object.keys(contextData),
userId: req.user?.userId,
companyCode: req.user?.companyCode,
});
// 사용자 정보를 contextData에 추가
const enrichedContextData = {
...contextData,
userId: req.user?.userId,
userName: req.user?.userName,
companyCode: req.user?.companyCode,
};
// 🔍 디버깅: req.user 전체 확인
logger.info(`🔍 req.user 전체 정보:`, {
user: req.user,
hasUser: !!req.user,
});
// 플로우 실행
const result = await NodeFlowExecutionService.executeFlow(
parseInt(flowId, 10),
enrichedContextData
);
// 사용자 정보를 contextData에 추가
const enrichedContextData = {
...contextData,
userId: req.user?.userId,
userName: req.user?.userName,
companyCode: req.user?.companyCode,
};
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
: "플로우 실행 중 오류가 발생했습니다.",
});
// 🔍 디버깅: enrichedContextData 확인
logger.info(`🔍 enrichedContextData:`, {
userId: enrichedContextData.userId,
companyCode: enrichedContextData.companyCode,
});
// 플로우 실행
const result = await NodeFlowExecutionService.executeFlow(
parseInt(flowId, 10),
enrichedContextData
);
return res.json({
success: result.success,
message: result.message,
data: result,
});
} catch (error) {
logger.error("플로우 실행 실패:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error
? error.message
: "플로우 실행 중 오류가 발생했습니다.",
});
}
}
});
);
export default router;

View File

@ -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;

View File

@ -5,12 +5,15 @@ import {
saveFormDataEnhanced,
updateFormData,
updateFormDataPartial,
updateFieldValue,
deleteFormData,
getFormData,
getFormDataList,
validateFormData,
getTableColumns,
getTablePrimaryKeys,
saveLocationHistory,
getLocationHistory,
} from "../controllers/dynamicFormController";
const router = express.Router();
@ -21,6 +24,7 @@ router.use(authenticateToken);
// 폼 데이터 CRUD
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언!
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.delete("/:id", deleteFormData);
@ -38,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns);
// 테이블 기본키 조회
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
// 위치 이력 (연속 위치 추적)
router.post("/location-history", saveLocationHistory);
router.get("/location-history/:tripId", getLocationHistory);
export default router;

View File

@ -97,6 +97,8 @@ router.post(
const data: ExternalRestApiConnection = {
...req.body,
created_by: req.user?.userId || "system",
// 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정)
company_code: req.body.company_code || req.user?.companyCode || "*",
};
const result =
@ -213,7 +215,10 @@ router.post(
}
const result =
await ExternalRestApiConnectionService.testConnection(testRequest);
await ExternalRestApiConnectionService.testConnection(
testRequest,
req.user?.companyCode
);
return res.status(200).json(result);
} 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;

View File

@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData);
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
// ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs);

View File

@ -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;

View File

@ -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;

View File

@ -8,6 +8,7 @@ import {
updateScreen,
updateScreenInfo,
deleteScreen,
bulkDeleteScreens,
checkScreenDependencies,
restoreScreen,
permanentDeleteScreen,
@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)

View File

@ -11,7 +11,9 @@ import {
createColumnMapping,
getLogicalColumns,
deleteColumnMapping,
deleteColumnMappingsByColumn,
getSecondLevelMenus,
getCategoryLabelsByCodes,
} from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware";
@ -41,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
// 카테고리 값 순서 변경
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);
// 컬럼 매핑 삭제
// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
// 컬럼 매핑 삭제 (단일)
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
export default router;

View File

@ -24,6 +24,7 @@ import {
getLogData,
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
} from "../controllers/tableManagementController";
const router = express.Router();
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
// ========================================
// 범용 다중 테이블 저장 API
// ========================================
/**
* ( + )
* POST /api/table-management/multi-table-save
*
* () .
* +, + 1:N .
*/
router.post("/multi-table-save", multiTableSave);
export default router;

View File

@ -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;

View File

@ -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;

View File

@ -178,21 +178,24 @@ export class DashboardService {
let params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (최우선)
// 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
if (companyCode) {
whereConditions.push(`d.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 권한 필터링
if (userId) {
if (companyCode === '*') {
// 최고 관리자는 모든 대시보드 조회 가능
} else {
whereConditions.push(`d.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
} else if (userId) {
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
whereConditions.push(
`(d.created_by = $${paramIndex} OR d.is_public = true)`
);
params.push(userId);
paramIndex++;
} else {
// 비로그인 사용자는 공개 대시보드만
whereConditions.push("d.is_public = true");
}
@ -228,7 +231,7 @@ export class DashboardService {
const whereClause = whereConditions.join(" AND ");
// 대시보드 목록 조회 (users 테이블 조인 제거)
// 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
const dashboardQuery = `
SELECT
d.id,
@ -242,13 +245,16 @@ export class DashboardService {
d.tags,
d.category,
d.view_count,
d.company_code,
u.user_name as created_by_name,
COUNT(de.id) as elements_count
FROM dashboards d
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}
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.view_count
d.view_count, d.company_code, u.user_name
ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
@ -277,12 +283,14 @@ export class DashboardService {
thumbnailUrl: row.thumbnail_url,
isPublic: row.is_public,
createdBy: row.created_by,
createdByName: row.created_by_name || row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags || "[]"),
category: row.category,
viewCount: parseInt(row.view_count || "0"),
elementsCount: parseInt(row.elements_count || "0"),
companyCode: row.company_code,
})),
pagination: {
page,
@ -299,6 +307,8 @@ export class DashboardService {
/**
*
* - company_code가
* - company_code가 '*'
*/
static async getDashboardById(
dashboardId: string,
@ -310,44 +320,43 @@ export class DashboardService {
let dashboardQuery: string;
let dashboardParams: any[];
if (userId) {
if (companyCode) {
if (companyCode) {
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
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.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];
} 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(

View File

@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {

View File

@ -19,15 +19,21 @@ export class AdminService {
// menuType에 따른 WHERE 조건 생성
const menuTypeCondition =
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
menuType !== undefined
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
: "1 = 1";
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
const includeInactive = paramMap.includeInactive === true;
const isManagementScreen = includeInactive || menuType === undefined;
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
const statusCondition = isManagementScreen
? "1 = 1"
: "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen
? "1 = 1"
: "MENU_SUB.STATUS = 'active'";
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
let authFilter = "";
@ -35,7 +41,11 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
if (
menuType !== undefined &&
userType !== "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
const userRoleGroups = await query<any>(
`
@ -56,45 +66,45 @@ export class AdminService {
);
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
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);
const companyParamIndex = paramIndex;
paramIndex++;
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
// 하위 메뉴 권한 체크
unionFilter = `
AND (
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
OR (
MENU_SUB.COMPANY_CODE = '*'
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
)
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(roleObjids);
paramIndex++;
logger.info(
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
);
} else {
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
logger.info(
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
logger.warn(
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
);
return [];
}
} else {
// 일반 사용자: 권한 그룹 필수
@ -131,7 +141,11 @@ export class AdminService {
return [];
}
}
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
@ -167,7 +181,7 @@ export class AdminService {
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
if (unionFilter === "") {
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;

View File

@ -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 || "회원가입 중 오류가 발생했습니다.",
};
}
}
}

View File

@ -130,13 +130,14 @@ export class BatchExecutionLogService {
try {
const log = await queryOne<BatchExecutionLog>(
`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,
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 *`,
[
data.batch_config_id,
data.company_code,
data.execution_status,
data.start_time || new Date(),
data.end_time,

File diff suppressed because it is too large Load Diff

View File

@ -1,258 +1,121 @@
// 배치 스케줄러 서비스
// 작성일: 2024-12-24
import * as cron from "node-cron";
import { query, queryOne } from "../database/db";
import cron, { ScheduledTask } from "node-cron";
import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger";
import { query } from "../database/db";
export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
private static isInitialized = false;
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
/**
*
*
*/
static async initialize() {
static async initializeScheduler() {
try {
logger.info("배치 스케줄러 초기화 시작...");
logger.info("배치 스케줄러 초기화 시작");
// 기존 모든 스케줄 정리 (중복 방지)
this.clearAllSchedules();
const batchConfigsResponse = await BatchService.getBatchConfigs({
is_active: "Y",
});
// 활성화된 배치 설정들을 로드하여 스케줄 등록
await this.loadActiveBatchConfigs();
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})`);
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
return;
}
// 새로운 스케줄 등록
const task = cron.schedule(cron_schedule, async () => {
// 중복 실행 방지 체크
if (this.executingBatches.has(id)) {
logger.warn(
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
);
return;
}
const batchConfigs = batchConfigsResponse.data;
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
// 실행 중 플래그 설정
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})`);
for (const config of batchConfigs) {
await this.scheduleBatch(config);
}
logger.info("배치 스케줄러 초기화 완료");
} 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(
configId: number,
executeImmediately: boolean = true
) {
try {
// 기존 스케줄 제거
await this.unscheduleBatchConfig(configId);
// 업데이트된 배치 설정 조회
const configResult = 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.id = $1
GROUP BY bc.id`,
[configId]
);
const config = configResult[0] || null;
if (!config) {
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
const result = await BatchService.getBatchConfigById(configId);
if (!result.success || !result.data) {
// 설정이 없으면 스케줄 제거
if (this.scheduledTasks.has(configId)) {
this.scheduledTasks.get(configId)?.stop();
this.scheduledTasks.delete(configId);
}
return;
}
// 활성화된 배치만 다시 스케줄 등록
if (config.is_active === "Y") {
await this.scheduleBatchConfig(config);
logger.info(
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
);
const config = result.data;
// 활성화 시 즉시 실행 (옵션)
if (executeImmediately) {
logger.info(
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
);
await this.executeBatchConfig(config);
}
} else {
logger.info(
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
// 스케줄 재등록
await this.scheduleBatch(config);
// 즉시 실행 옵션이 있으면 실행
/*
if (executeImmediately && config.is_active === "Y") {
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
this.executeBatchConfig(config).catch((err) =>
logger.error(`즉시 실행 중 오류 발생:`, err)
);
}
*/
} catch (error) {
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
}
@ -268,10 +131,19 @@ export class BatchSchedulerService {
try {
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 =
await BatchExecutionLogService.createExecutionLog({
batch_config_id: config.id,
company_code: config.company_code,
execution_status: "RUNNING",
start_time: startTime,
total_records: 0,
@ -313,7 +185,7 @@ export class BatchSchedulerService {
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
@ -323,11 +195,10 @@ export class BatchSchedulerService {
duration_ms: Date.now() - startTime.getTime(),
error_message:
error instanceof Error ? error.message : "알 수 없는 오류",
error_details: error instanceof Error ? error.stack : String(error),
});
}
// 실패 시에도 결과 반환
// 실패 결과 반환
return {
totalRecords: 0,
successRecords: 0,
@ -350,9 +221,16 @@ export class BatchSchedulerService {
}
// 테이블별로 매핑을 그룹화
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
const tableGroups = new Map<string, typeof config.batch_mappings>();
const fixedMappingsGlobal: typeof config.batch_mappings = [];
for (const mapping of config.batch_mappings) {
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
if (mapping.mapping_type === "fixed") {
fixedMappingsGlobal.push(mapping);
continue;
}
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) {
tableGroups.set(key, []);
@ -360,6 +238,14 @@ export class BatchSchedulerService {
tableGroups.get(key)!.push(mapping);
}
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
logger.warn(
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
);
return { totalRecords, successRecords, failedRecords };
}
// 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) {
try {
@ -379,9 +265,47 @@ export class BatchSchedulerService {
const { BatchExternalDbService } = await import(
"./batchExternalDbService"
);
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
let apiKey = firstMapping.from_api_key || "";
if (config.auth_service_name) {
let tokenQuery: string;
let tokenParams: any[];
if (config.company_code === "*") {
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name];
} else {
// 일반 회사 배치: 자신의 회사 토큰만 조회
tokenQuery = `SELECT access_token FROM auth_tokens
WHERE service_name = $1 AND company_code = $2
ORDER BY created_date DESC LIMIT 1`;
tokenParams = [config.auth_service_name, config.company_code];
}
const tokenResult = await query<{ access_token: string }>(
tokenQuery,
tokenParams
);
if (tokenResult.length > 0 && tokenResult[0].access_token) {
apiKey = tokenResult[0].access_token;
logger.info(
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
);
} else {
logger.warn(
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
);
}
}
// 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
apiKey,
firstMapping.from_table_name,
(firstMapping.from_api_method as
| "GET"
@ -394,11 +318,42 @@ export class BatchSchedulerService {
firstMapping.from_api_param_type,
firstMapping.from_api_param_name,
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) {
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 {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
}
@ -416,9 +371,25 @@ export class BatchSchedulerService {
totalRecords += fromData.length;
// 컬럼 매핑 적용하여 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 mappedRow: any = {};
for (const mapping of mappings) {
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
if (mapping.mapping_type === "fixed") {
continue;
}
// DB → REST API 배치인지 확인
if (
firstMapping.to_connection_type === "restapi" &&
@ -428,10 +399,32 @@ export class BatchSchedulerService {
mappedRow[mapping.from_column_name] =
row[mapping.from_column_name];
} else {
// 기존 로직: to_column_name을 키로 사용
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
// 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;
});
@ -482,42 +475,31 @@ export class BatchSchedulerService {
);
}
} else {
// 기존 REST API 전송 (REST API → DB 배치)
const apiResult = await BatchExternalDbService.sendDataToRestApi(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
mappedData
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
// 지원하지 않음
logger.warn(
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(
`REST API 데이터 전송 실패: ${apiResult.message}`
);
}
insertResult = { successCount: 0, failedCount: 0 };
}
} else {
// DB에 데이터 삽입
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name,
mappedData,
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;
failedRecords += insertResult.failedCount;
logger.info(
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
);
} catch (error) {
logger.error(`테이블 처리 실패: ${tableKey}`, error);
failedRecords += 1;
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
}
}
@ -525,153 +507,9 @@ export class BatchSchedulerService {
}
/**
* ( - )
* (scheduleBatch의 )
*/
private static async processBatchMappings(config: any) {
const { batch_mappings } = 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());
static async scheduleBatchConfig(config: any) {
return this.scheduleBatch(config);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
/**
*
*
*
* :
* 1. -
* 2. -
* 3. - company_code
* 4. SQL - /
*
*
* :
* - , ,
* - (pg_*, information_schema )
@ -70,11 +70,11 @@ class DataService {
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
@ -83,12 +83,12 @@ class DataService {
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
@ -103,7 +103,7 @@ class DataService {
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
@ -117,38 +117,41 @@ class DataService {
}
selectedRow = rows[0];
break;
case "base_price":
// base_price = true인 행 찾기
selectedRow = rows.find(row => row.base_price === true) || rows[0];
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
break;
case "current_date":
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
const today = new Date();
today.setHours(0, 0, 0, 0); // 시간 제거
selectedRow = rows.find(row => {
const startDate = row.start_date ? new Date(row.start_date) : null;
const endDate = row.end_date ? new Date(row.end_date) : null;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
selectedRow =
rows.find((row) => {
const startDate = row.start_date
? new Date(row.start_date)
: null;
const endDate = row.end_date ? new Date(row.end_date) : null;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
break;
default:
selectedRow = rows[0];
}
result.push(selectedRow);
}
return result;
}
@ -230,12 +233,17 @@ class DataService {
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
const hasCompanyCode = await this.checkColumnExists(
tableName,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
console.log(
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
);
}
}
@ -508,7 +516,8 @@ class DataService {
const entityJoinService = new EntityJoinService();
// Entity Join 구성 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
const joinConfigs =
await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length > 0) {
console.log(`✅ Entity Join 감지: ${joinConfigs.length}`);
@ -518,7 +527,7 @@ class DataService {
tableName,
joinConfigs,
["*"],
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
);
const result = await pool.query(joinQuery, [id]);
@ -533,14 +542,14 @@ class DataService {
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
return rows.map((row) => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
@ -551,17 +560,20 @@ class DataService {
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
console.log(
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
normalizedRows[0]
);
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
if (groupByColumns.length > 0) {
const baseRecord = result.rows[0];
// 그룹핑 컬럼들의 값 추출
const groupConditions: string[] = [];
const groupValues: any[] = [];
let paramIndex = 1;
for (const col of groupByColumns) {
const value = normalizedRows[0][col];
if (value !== undefined && value !== null) {
@ -570,12 +582,15 @@ class DataService {
paramIndex++;
}
}
if (groupConditions.length > 0) {
const groupWhereClause = groupConditions.join(" AND ");
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
console.log(
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
groupValues
);
// 그룹핑 기준으로 모든 레코드 조회
const { query: groupQuery } = entityJoinService.buildJoinQuery(
tableName,
@ -583,12 +598,14 @@ class DataService {
["*"],
groupWhereClause
);
const groupResult = await pool.query(groupQuery, groupValues);
const normalizedGroupRows = normalizeDates(groupResult.rows);
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`);
console.log(
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`
);
return {
success: true,
data: normalizedGroupRows, // 🔧 배열로 반환!
@ -642,7 +659,8 @@ class DataService {
dataFilter?: any, // 🆕 데이터 필터
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
deduplication?: { // 🆕 중복 제거 설정
deduplication?: {
// 🆕 중복 제거 설정
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
@ -666,36 +684,41 @@ class DataService {
if (enableEntityJoin) {
try {
const { entityJoinService } = await import("./entityJoinService");
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
const joinConfigs =
await entityJoinService.detectEntityJoins(rightTable);
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
if (displayColumns && Array.isArray(displayColumns)) {
// 테이블별로 요청된 컬럼들을 그룹핑
const tableColumns: Record<string, Set<string>> = {};
for (const col of displayColumns) {
if (col.name && col.name.includes('.')) {
const [refTable, refColumn] = col.name.split('.');
if (col.name && col.name.includes(".")) {
const [refTable, refColumn] = col.name.split(".");
if (!tableColumns[refTable]) {
tableColumns[refTable] = new Set();
}
tableColumns[refTable].add(refColumn);
}
}
// 각 테이블별로 처리
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
// 이미 조인 설정에 있는지 확인
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
const existingJoins = joinConfigs.filter(
(jc) => jc.referenceTable === refTable
);
if (existingJoins.length > 0) {
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
for (const refColumn of refColumns) {
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
const existingJoin = existingJoins.find(
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
(jc) =>
jc.displayColumns.length === 1 &&
jc.displayColumns[0] === refColumn
);
if (!existingJoin) {
// 없으면 새 조인 설정 복제하여 추가
const baseJoin = existingJoins[0];
@ -708,7 +731,9 @@ class DataService {
referenceColumn: baseJoin.referenceColumn, // item_number 등
};
joinConfigs.push(newJoin);
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
console.log(
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
);
}
}
} else {
@ -718,7 +743,9 @@ class DataService {
}
if (joinConfigs.length > 0) {
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
console.log(
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
);
// WHERE 조건 생성
const whereConditions: string[] = [];
@ -735,7 +762,10 @@ class DataService {
// 회사별 필터링
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`main.company_code = $${paramIndex}`);
values.push(userCompany);
@ -744,48 +774,64 @@ class DataService {
}
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const { buildDataFilterWhereClause } = await import(
"../utils/dataFilterUtil"
);
const filterResult = buildDataFilterWhereClause(
dataFilter,
"main",
paramIndex
);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
console.log(`📊 필터 파라미터:`, filterResult.params);
}
}
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
const whereClause =
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
// Entity 조인 쿼리 빌드
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
const selectColumns = ["*"];
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
const { query: finalQuery, aliasMap } =
entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
console.log(`🔍 파라미터:`, values);
const result = await pool.query(finalQuery, values);
// 🔧 날짜 타입 타임존 문제 해결
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
return rows.map((row) => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
@ -794,18 +840,24 @@ class DataService {
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
console.log(
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
);
// 🆕 중복 제거 처리
let finalData = normalizedRows;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(normalizedRows, deduplication);
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`);
console.log(
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`
);
}
return {
success: true,
data: finalData,
@ -838,23 +890,40 @@ class DataService {
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`r.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
console.log(
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
);
}
}
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const filterResult = buildDataFilterWhereClause(
dataFilter,
"r",
paramIndex
);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(
`🔍 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
}
}
@ -871,9 +940,13 @@ class DataService {
// 🆕 중복 제거 처리
let finalData = result;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(result, deduplication);
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`);
console.log(
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`
);
}
return {
@ -907,8 +980,31 @@ class DataService {
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 columnNames = columns.map((col) => `"${col}"`).join(", ");
@ -951,9 +1047,32 @@ class DataService {
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
const cleanData = { ...data };
let cleanData = { ...data };
delete cleanData._relationInfo;
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
const tableColumns = await this.getTableColumnsSimple(tableName);
const validColumnNames = new Set(
tableColumns.map((col: any) => col.column_name)
);
const invalidColumns: string[] = [];
cleanData = Object.fromEntries(
Object.entries(cleanData).filter(([key]) => {
if (validColumnNames.has(key)) {
return true;
}
invalidColumns.push(key);
return false;
})
);
if (invalidColumns.length > 0) {
console.log(
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
);
}
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
@ -993,8 +1112,14 @@ class DataService {
}
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
if (
relationInfo &&
relationInfo.rightTable &&
relationInfo.leftColumn &&
relationInfo.rightColumn
) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
relationInfo;
const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
@ -1012,8 +1137,13 @@ class DataService {
SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2
`;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
const updateResult = await query(updateRelatedQuery, [
newLeftValue,
oldLeftValue,
]);
console.log(
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
);
} catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
@ -1064,9 +1194,11 @@ class DataService {
if (pkResult.length > 1) {
// 복합키인 경우: id가 객체여야 함
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
if (typeof id === 'object' && !Array.isArray(id)) {
console.log(
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
);
if (typeof id === "object" && !Array.isArray(id)) {
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
pkResult.forEach((pk, index) => {
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
@ -1081,15 +1213,17 @@ class DataService {
// 단일키인 경우
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
whereClauses.push(`"${pkColumn}" = $1`);
params.push(typeof id === 'object' ? id[pkColumn] : id);
params.push(typeof id === "object" ? id[pkColumn] : id);
}
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
);
return {
success: true,
@ -1128,7 +1262,11 @@ class DataService {
}
if (whereConditions.length === 0) {
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
return {
success: false,
message: "삭제 조건이 없습니다.",
error: "NO_CONDITIONS",
};
}
const whereClause = whereConditions.join(" AND ");
@ -1163,7 +1301,9 @@ class DataService {
records: Array<Record<string, any>>,
userCompany?: string,
userId?: string
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
): Promise<
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
> {
try {
// 테이블 접근 권한 검증
const validation = await this.validateTableAccess(tableName);
@ -1201,11 +1341,14 @@ class DataService {
const whereClause = whereConditions.join(" AND ");
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
console.log(`📋 기존 레코드 조회:`, {
query: selectQuery,
values: whereValues,
});
const existingRecords = await pool.query(selectQuery, whereValues);
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
@ -1216,50 +1359,53 @@ class DataService {
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split('T')[0]; // YYYY-MM-DD 만 추출
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split("T")[0]; // YYYY-MM-DD 만 추출
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord);
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
@ -1272,7 +1418,8 @@ class DataService {
let updateParamIndex = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
if (key !== pkColumn) {
// Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateValues.push(value);
updateParamIndex++;
@ -1288,36 +1435,42 @@ class DataService {
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
const recordWithMeta: Record<string, any> = {
...fullRecord,
...recordWithoutCreatedDate,
id: uuidv4(), // 새 ID 생성
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
if (
!recordWithMeta.company_code &&
userCompany &&
userCompany !== "*"
) {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(key =>
recordWithMeta[key] !== "NOW()"
const insertFields = Object.keys(recordWithMeta).filter(
(key) => recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
@ -1329,15 +1482,20 @@ class DataService {
}
const insertQuery = `
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
.map((f) => `"${f}"`)
.join(", ")})
VALUES (${insertPlaceholders.join(", ")})
`;
console.log(` INSERT 쿼리:`, { query: insertQuery, values: insertValues });
console.log(` INSERT 쿼리:`, {
query: insertQuery,
values: insertValues,
});
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
}
}
@ -1345,19 +1503,22 @@ class DataService {
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
return String(existingValue) === String(newValue);
});
});
@ -1367,7 +1528,7 @@ class DataService {
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -134,8 +134,8 @@ export class EntityJoinService {
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
logger.info(`🔍 ${referenceTable}모든 컬럼 조회 중...`);
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
logger.info(`🔍 ${referenceTable}표시 컬럼 자동 감지 중...`);
// 참조 테이블의 모든 컬럼 이름 가져오기
const tableColumnsResult = await query<{ column_name: string }>(
@ -148,10 +148,34 @@ export class EntityJoinService {
);
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(
`${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
displayColumns.join(", ")
`${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
);
} else {
// 테이블 컬럼을 못 찾으면 기본값 사용
@ -403,18 +427,25 @@ export class EntityJoinService {
const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
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");

View File

@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
lastUsedAt: Date;
activeConnections = 0;
maxConnections: number;
private isPoolClosed = false;
constructor(config: ExternalDbConnection) {
this.connectionId = config.id!;
@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
waitForConnections: true,
queueLimit: 0,
connectTimeout: (config.connection_timeout || 30) * 1000,
// 연결 유지 및 자동 재연결 설정
enableKeepAlive: true,
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
ssl:
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
});
@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
async query(sql: string, params?: any[]): Promise<any> {
this.lastUsedAt = new Date();
// 연결 풀이 닫힌 상태인지 확인
if (this.isPoolClosed) {
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
}
try {
const [rows] = await this.pool.execute(sql, params);
return rows;
} catch (error: any) {
// 연결 닫힘 오류 감지
if (
error.message.includes("closed state") ||
error.code === "PROTOCOL_CONNECTION_LOST" ||
error.code === "ECONNRESET"
) {
this.isPoolClosed = true;
logger.warn(
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
);
}
throw error;
}
}
async disconnect(): Promise<void> {
this.isPoolClosed = true;
await this.pool.end();
logger.info(
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
}
isHealthy(): boolean {
// 연결 풀이 닫혔으면 비정상
if (this.isPoolClosed) {
return false;
}
return this.activeConnections < this.maxConnections;
}
}
@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService {
): Promise<ConnectionPoolWrapper> {
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
// DB 연결 정보 조회
// DB 연결 정보 조회 (실제 비밀번호 포함)
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
await ExternalDbConnectionService.getConnectionByIdWithPassword(
connectionId
);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService {
}
/**
* ( )
* ( + )
*/
async executeQuery(
connectionId: number,
sql: string,
params?: any[]
params?: any[],
retryCount = 0
): Promise<any> {
const pool = await this.getPool(connectionId);
const MAX_RETRIES = 2;
try {
const pool = await this.getPool(connectionId);
logger.debug(
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
);
@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService {
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}`
);
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);
throw error;
}

View File

@ -28,39 +28,39 @@ export class ExternalDbConnectionService {
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex++}`);
whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(userCompanyCode);
logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`);
} else if (userCompanyCode === "*") {
logger.info(`최고 관리자: 모든 외부 DB 연결 조회`);
// 필터가 있으면 적용
if (filter.company_code) {
whereConditions.push(`company_code = $${paramIndex++}`);
whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(filter.company_code);
}
} else {
// userCompanyCode가 없는 경우 (하위 호환성)
if (filter.company_code) {
whereConditions.push(`company_code = $${paramIndex++}`);
whereConditions.push(`e.company_code = $${paramIndex++}`);
params.push(filter.company_code);
}
}
// 필터 조건 적용
if (filter.db_type) {
whereConditions.push(`db_type = $${paramIndex++}`);
whereConditions.push(`e.db_type = $${paramIndex++}`);
params.push(filter.db_type);
}
if (filter.is_active) {
whereConditions.push(`is_active = $${paramIndex++}`);
whereConditions.push(`e.is_active = $${paramIndex++}`);
params.push(filter.is_active);
}
// 검색 조건 적용 (연결명 또는 설명에서 검색)
if (filter.search && filter.search.trim()) {
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()}%`);
paramIndex++;
@ -72,9 +72,12 @@ export class ExternalDbConnectionService {
: "";
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}
ORDER BY is_active DESC, connection_name ASC`,
ORDER BY e.is_active DESC, e.connection_name ASC`,
params
);

View File

@ -1,4 +1,6 @@
import { Pool, QueryResult } from "pg";
import axios, { AxiosResponse } from "axios";
import https from "https";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import {
@ -29,11 +31,17 @@ export class ExternalRestApiConnectionService {
try {
let query = `
SELECT
id, connection_name, description, base_url, endpoint_path, default_headers,
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message
FROM external_rest_api_connections
e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers,
e.default_method,
-- DB default_request_body
-- default_body alias
e.default_request_body AS default_body,
e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay,
e.company_code, e.is_active, e.created_date, e.created_by,
e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message,
COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name
FROM external_rest_api_connections e
LEFT JOIN company_mng c ON e.company_code = c.company_code
WHERE 1=1
`;
@ -42,7 +50,7 @@ export class ExternalRestApiConnectionService {
// 회사별 필터링 (최고 관리자가 아닌 경우 필수)
if (userCompanyCode && userCompanyCode !== "*") {
query += ` AND company_code = $${paramIndex}`;
query += ` AND e.company_code = $${paramIndex}`;
params.push(userCompanyCode);
paramIndex++;
logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`);
@ -50,14 +58,14 @@ export class ExternalRestApiConnectionService {
logger.info(`최고 관리자: 모든 REST API 연결 조회`);
// 필터가 있으면 적용
if (filter.company_code) {
query += ` AND company_code = $${paramIndex}`;
query += ` AND e.company_code = $${paramIndex}`;
params.push(filter.company_code);
paramIndex++;
}
} else {
// userCompanyCode가 없는 경우 (하위 호환성)
if (filter.company_code) {
query += ` AND company_code = $${paramIndex}`;
query += ` AND e.company_code = $${paramIndex}`;
params.push(filter.company_code);
paramIndex++;
}
@ -65,14 +73,14 @@ export class ExternalRestApiConnectionService {
// 활성 상태 필터
if (filter.is_active) {
query += ` AND is_active = $${paramIndex}`;
query += ` AND e.is_active = $${paramIndex}`;
params.push(filter.is_active);
paramIndex++;
}
// 인증 타입 필터
if (filter.auth_type) {
query += ` AND auth_type = $${paramIndex}`;
query += ` AND e.auth_type = $${paramIndex}`;
params.push(filter.auth_type);
paramIndex++;
}
@ -80,9 +88,9 @@ export class ExternalRestApiConnectionService {
// 검색어 필터 (연결명, 설명, URL)
if (filter.search) {
query += ` AND (
connection_name ILIKE $${paramIndex} OR
description ILIKE $${paramIndex} OR
base_url ILIKE $${paramIndex}
e.connection_name ILIKE $${paramIndex} OR
e.description ILIKE $${paramIndex} OR
e.base_url ILIKE $${paramIndex}
)`;
params.push(`%${filter.search}%`);
paramIndex++;
@ -129,6 +137,8 @@ export class ExternalRestApiConnectionService {
let query = `
SELECT
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,
company_code, is_active, created_date, created_by,
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)
: null;
// 디버깅: 조회된 연결 정보 로깅
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
return {
success: true,
data: connection,
@ -194,9 +207,10 @@ export class ExternalRestApiConnectionService {
const query = `
INSERT INTO external_rest_api_connections (
connection_name, description, base_url, endpoint_path, default_headers,
default_method, default_request_body,
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
company_code, is_active, created_by, save_to_history
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *
`;
@ -206,6 +220,8 @@ export class ExternalRestApiConnectionService {
data.base_url,
data.endpoint_path || null,
JSON.stringify(data.default_headers || {}),
data.default_method || "GET",
data.default_body || null,
data.auth_type,
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
data.timeout || 30000,
@ -214,8 +230,19 @@ export class ExternalRestApiConnectionService {
data.company_code || "*",
data.is_active || "Y",
data.created_by || "system",
data.save_to_history || "N",
];
// 디버깅: 저장하려는 데이터 로깅
logger.info(`REST API 연결 생성 요청 데이터:`, {
connection_name: data.connection_name,
company_code: data.company_code,
default_method: data.default_method,
endpoint_path: data.endpoint_path,
base_url: data.base_url,
default_body: data.default_body ? "있음" : "없음",
});
const result: QueryResult<any> = await pool.query(query, params);
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
@ -301,6 +328,20 @@ export class ExternalRestApiConnectionService {
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) {
updateFields.push(`auth_type = $${paramIndex}`);
params.push(data.auth_type);
@ -337,6 +378,12 @@ export class ExternalRestApiConnectionService {
paramIndex++;
}
if (data.save_to_history !== undefined) {
updateFields.push(`save_to_history = $${paramIndex}`);
params.push(data.save_to_history);
paramIndex++;
}
if (data.updated_by !== undefined) {
updateFields.push(`updated_by = $${paramIndex}`);
params.push(data.updated_by);
@ -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 ( )
*/
static async testConnection(
testRequest: RestApiTestRequest
testRequest: RestApiTestRequest,
userCompanyCode?: string
): Promise<RestApiTestResult> {
const startTime = Date.now();
try {
// 헤더 구성
const headers = { ...testRequest.headers };
let headers = { ...testRequest.headers };
// 인증 헤더 추가
if (
testRequest.auth_type === "bearer" &&
testRequest.auth_config?.token
) {
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
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;
}
}
// 인증 헤더 생성 및 병합
const authHeaders = await this.getAuthHeaders(
testRequest.auth_type,
testRequest.auth_config,
userCompanyCode
);
headers = { ...headers, ...authHeaders };
// URL 구성
let url = testRequest.base_url;
@ -493,25 +627,84 @@ export class ExternalRestApiConnectionService {
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
);
// HTTP 요청 실행
const response = await fetch(url, {
method: testRequest.method || "GET",
headers,
signal: AbortSignal.timeout(testRequest.timeout || 30000),
});
// Body 처리
let body: any = undefined;
if (testRequest.body) {
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
if (typeof testRequest.body === "string") {
body = testRequest.body;
} else {
body = JSON.stringify(testRequest.body);
}
const responseTime = Date.now() - startTime;
let responseData = null;
try {
responseData = await response.json();
} catch {
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type"
);
if (!hasContentType) {
headers["Content-Type"] = "application/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 {
success: response.ok,
message: response.ok
success: response.status >= 200 && response.status < 300,
message:
response.status >= 200 && response.status < 300
? "연결 성공"
: `연결 실패 (${response.status} ${response.statusText})`,
response_time: responseTime,
@ -552,17 +745,27 @@ export class ExternalRestApiConnectionService {
const connection = connectionResult.data;
// 리스트에서 endpoint를 넘기지 않으면,
// 저장된 endpoint_path를 기본 엔드포인트로 사용
const effectiveEndpoint =
endpoint || connection.endpoint_path || undefined;
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint,
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,
};
const result = await this.testConnection(testRequest);
const result = await this.testConnection(
testRequest,
connection.company_code
);
// 테스트 결과 저장
await pool.query(
@ -580,11 +783,34 @@ export class ExternalRestApiConnectionService {
return result;
} catch (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 {
success: false,
message: "연결 테스트에 실패했습니다.",
error_details:
error instanceof Error ? error.message : "알 수 없는 오류",
error_details: errorMessage,
};
}
}
@ -683,6 +909,166 @@ export class ExternalRestApiConnectionService {
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",
"basic",
"oauth2",
"db-token",
];
if (!validAuthTypes.includes(data.auth_type)) {
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 : "알 수 없는 오류",
},
};
}
}
}

View File

@ -72,6 +72,11 @@ export class FlowDataMoveService {
// 내부 DB 처리 (기존 로직)
return await db.transaction(async (client) => {
try {
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId || "system",
]);
// 1. 단계 정보 조회
const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId);
@ -684,6 +689,14 @@ export class FlowDataMoveService {
dbConnectionId,
async (externalClient, dbType) => {
try {
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
if (dbType.toLowerCase() === "postgresql") {
await externalClient.query(
"SELECT set_config('app.user_id', $1, true)",
[userId || "system"]
);
}
// 1. 단계 정보 조회 (내부 DB에서)
const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId);

View File

@ -27,13 +27,21 @@ export class FlowDefinitionService {
tableName: request.tableName,
dbSourceType: request.dbSourceType,
dbConnectionId: request.dbConnectionId,
restApiConnectionId: request.restApiConnectionId,
restApiEndpoint: request.restApiEndpoint,
restApiJsonPath: request.restApiJsonPath,
restApiConnections: request.restApiConnections,
companyCode,
userId,
});
const query = `
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO flow_definition (
name, description, table_name, db_source_type, db_connection_id,
rest_api_connection_id, rest_api_endpoint, rest_api_json_path,
rest_api_connections, company_code, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
@ -43,6 +51,10 @@ export class FlowDefinitionService {
request.tableName || null,
request.dbSourceType || "internal",
request.dbConnectionId || null,
request.restApiConnectionId || null,
request.restApiEndpoint || null,
request.restApiJsonPath || "response",
request.restApiConnections ? JSON.stringify(request.restApiConnections) : null,
companyCode,
userId,
];
@ -199,6 +211,19 @@ export class FlowDefinitionService {
* DB FlowDefinition
*/
private mapToFlowDefinition(row: any): FlowDefinition {
// rest_api_connections 파싱 (JSONB → 배열)
let restApiConnections = undefined;
if (row.rest_api_connections) {
try {
restApiConnections = typeof row.rest_api_connections === 'string'
? JSON.parse(row.rest_api_connections)
: row.rest_api_connections;
} catch (e) {
console.warn("Failed to parse rest_api_connections:", e);
restApiConnections = [];
}
}
return {
id: row.id,
name: row.name,
@ -206,6 +231,12 @@ export class FlowDefinitionService {
tableName: row.table_name,
dbSourceType: row.db_source_type || "internal",
dbConnectionId: row.db_connection_id,
// REST API 관련 필드 (단일)
restApiConnectionId: row.rest_api_connection_id,
restApiEndpoint: row.rest_api_endpoint,
restApiJsonPath: row.rest_api_json_path,
// 다중 REST API 관련 필드
restApiConnections: restApiConnections,
companyCode: row.company_code || "*",
isActive: row.is_active,
createdBy: row.created_by,

View File

@ -263,4 +263,139 @@ export class FlowExecutionService {
tableName: result[0].table_name,
};
}
/**
* ( )
* .
*/
async updateStepData(
flowId: number,
stepId: number,
recordId: string,
updateData: Record<string, any>,
userId: string,
companyCode?: string
): Promise<{ success: boolean }> {
try {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 스텝 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
// 3. 테이블명 결정
const tableName = step.tableName || flowDef.tableName;
if (!tableName) {
throw new Error("Table name not found");
}
// 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id";
console.log(
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
);
// 5. SET 절 생성
const updateColumns = Object.keys(updateData);
if (updateColumns.length === 0) {
throw new Error("No columns to update");
}
// 6. 외부 DB vs 내부 DB 구분
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 업데이트
console.log(
"✅ [updateStepData] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
// 외부 DB 연결 정보 조회
const connectionResult = await db.query(
"SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId]
);
if (connectionResult.length === 0) {
throw new Error(
`External DB connection not found: ${flowDef.dbConnectionId}`
);
}
const connection = connectionResult[0];
const dbType = connection.db_type?.toLowerCase();
// DB 타입에 따른 placeholder 및 쿼리 생성
let setClause: string;
let params: any[];
if (dbType === "mysql" || dbType === "mariadb") {
// MySQL/MariaDB: ? placeholder
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
params = [...Object.values(updateData), recordId];
} else if (dbType === "mssql") {
// MSSQL: @p1, @p2 placeholder
setClause = updateColumns
.map((col, idx) => `[${col}] = @p${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
} else {
// PostgreSQL: $1, $2 placeholder
setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
params = [...Object.values(updateData), recordId];
}
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
} else {
// 내부 DB 업데이트
console.log("✅ [updateStepData] Using INTERNAL DB");
const setClause = updateColumns
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const params = [...Object.values(updateData), recordId];
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
// (트리거에서 changed_by를 기록하기 위함)
await db.transaction(async (client) => {
// 안전한 파라미터 바인딩 방식 사용
await client.query("SELECT set_config('app.user_id', $1, true)", [
userId,
]);
await client.query(updateQuery, params);
});
}
console.log(
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
{
updatedFields: updateColumns,
userId,
}
);
return { success: true };
} catch (error: any) {
console.error("❌ [updateStepData] Error:", error);
throw error;
}
}
}

View File

@ -334,9 +334,12 @@ class MailSendSimpleService {
if (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 제거)
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>`;
break;
case 'image':
@ -348,6 +351,89 @@ class MailSendSimpleService {
case 'spacer':
html += `<div style="height: ${component.height || '20px'};"></div>`;
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;
}
});

View File

@ -4,13 +4,35 @@ import path from "path";
// MailComponent 인터페이스 정의
export interface MailComponent {
id: string;
type: "text" | "button" | "image" | "spacer";
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
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 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
@ -236,6 +258,89 @@ class MailTemplateFileService {
case "spacer":
html += `<div style="height: ${comp.height || 20}px;"></div>`;
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

View File

@ -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
*

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