diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -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; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. 그룹화 및 그룹 소계 + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. 총계 요약 (Total Summary) + +- 숫자 컬럼의 합계, 평균, 개수 표시 +- 테이블 하단에 요약 행 렌더링 + +--- + +## 캐싱 전략 + +```typescript +// 테이블 컬럼 캐시 +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 + +// API 호출 디바운싱 +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + 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([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 관련 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 관련 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// 컬럼 관련 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// 선택 관련 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// 정렬 관련 +const [sortBy, setSortBy] = useState(""); +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 && ( + +)} + +// 셀 배경색 +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` - 스티키 헤더 테이블 diff --git a/PLAN.MD b/PLAN.MD index 787bef69..507695c6 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -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단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 - +- [완료] 모든 단계 구현 완료 diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json deleted file mode 100644 index 683ad20c..00000000 --- a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json +++ /dev/null @@ -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
\r\n

전달히야야양


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
전달된 메일:

보낸사람: \"이희진\"
날짜: 2025. 10. 22. 오후 12:58:15
제목: ㄴ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json b/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json deleted file mode 100644 index eccdc063..00000000 --- a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json +++ /dev/null @@ -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\n\n\n \n \n\n\n \n \n \n \n
\n

ㄴㅇㄹㄴㅇㄹ

\n \"\"\n

ㄴㅇㄹ

ㄴㅇㄹ

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹ

\r\n
\r\n \n
\n \n\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json b/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json deleted file mode 100644 index a6fed281..00000000 --- a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json +++ /dev/null @@ -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
\r\n

ㅓㅏㅣ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json deleted file mode 100644 index 5090fdd2..00000000 --- a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json b/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json deleted file mode 100644 index 46b0b1b8..00000000 --- a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "34f7f149-ac97-442e-b595-02c990082f86", - "sentAt": "2025-10-13T01:04:08.560Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n\n
\n \r\n
\r\n

선택메시지 영역

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317447824-27488793.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1d7caa77-12f1-a791-a230-162826cf03ea@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json b/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json deleted file mode 100644 index d70b6897..00000000 --- a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json +++ /dev/null @@ -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
\r\n

asd

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json b/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json deleted file mode 100644 index 05eb18c2..00000000 --- a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "3f72cbab-b60e-45e7-ac8d-7e441bc2b900", - "sentAt": "2025-10-13T01:34:19.363Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용22", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

안녕안녕하세요 이건 테스트용 템플릿입니다용22

\n \"\"\n

안녕하세용 [222]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[222] 안에 넣어도 돼요

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "blender study.docx", - "originalName": "blender study.docx", - "size": 0, - "path": "/app/uploads/mail-attachments/1760319257947-827879690.docx", - "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - } - ], - "status": "success", - "messageId": "<5b3d9f82-8531-f427-c7f7-9446b4f19da4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json new file mode 100644 index 00000000..ea3b568f --- /dev/null +++ b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json @@ -0,0 +1,20 @@ +{ + "id": "43466fc8-56e8-44a0-875c-dec2c3c8eb78", + "sentAt": "2025-11-28T02:34:02.239Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "임의로 설정한 제목", + "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n\n
\n \n \n \n \n \n
\n \n (주)웨이스\n \n 2025. 11. 28.\n
\n
\n
\n naver\n
\n \"\"\n
\n
\n
(주)웨이스
\n \n
\n 대표: 이희진\n \n \n
\n \n
주소주소
\n \n
\n Tel: 전화번호 01010101011010\n | \n Email: 이메일이메일\n
\n \n
© 2025 All rights reserved.
\n
\n \n
\n \n \n \n \n \n \n \n \n
항목내용
\n
\n \n
\n
안내
\n
안내를 합시다 합시다 합시다
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. 두번째항목
  3. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n\n\n", + "templateId": "template-1764296982213", + "templateName": "제목 있음", + "status": "success", + "messageId": "<78b63521-2648-f6eb-eeba-efdeebce8459@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json b/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json deleted file mode 100644 index 29ec634e..00000000 --- a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "449d9951-51e8-4e81-ada4-e73aed8ff60e", - "sentAt": "2025-10-13T01:29:25.975Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n
안녕안녕하세요 이건 테스트용 템플릿입니다용
\n \"\"\n

안녕하세용 [뭘 넣은 결과 입니당]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[안에 뭘 넣은 결과입니다.] 안에 넣어도 돼요

\n
\n\n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "status": "success", - "messageId": "<5d52accb-777b-b6c2-aab7-1a2f7b7754ab@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json b/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json deleted file mode 100644 index ee094c49..00000000 --- a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "6dd3673a-f510-4ba9-9634-0b391f925230", - "sentAt": "2025-10-13T01:01:55.097Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트용입니당.", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n
\n\n
\n \r\n
\r\n

이건 저장이 안되는군

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글-분석.txt", - "originalName": "한글-분석.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317313641-761345104.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json deleted file mode 100644 index 37317a6a..00000000 --- a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json +++ /dev/null @@ -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": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

야야야야야야야야ㅑㅇ야ㅑㅇ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json deleted file mode 100644 index 4ac647c7..00000000 --- a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json +++ /dev/null @@ -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
\r\n






---------- 전달된 메시지 ----------


보낸 사람: \"기상청 API허브\"


날짜: 2025. 10. 13. 오후 4:26:45


제목: 기상청 API허브 회원가입 인증번호




undefined

\r\n
\r\n ", - "status": "success", - "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json b/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json deleted file mode 100644 index ed2e4b14..00000000 --- a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json +++ /dev/null @@ -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": "

텍스트를 입력하세요...

\n 버튼\n
\n \"\"\n

텍스트를 입력하세요...

텍스트를 입력하세요...

\n
\n \r\n
\r\n

어덯게 나오는지 봅시다 추가메시지 영역이빈다.

\r\n
\r\n \n
\n
", - "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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json b/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json deleted file mode 100644 index 31492a08..00000000 --- a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json b/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json deleted file mode 100644 index 1435f837..00000000 --- a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json deleted file mode 100644 index 5cf165c3..00000000 --- a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json +++ /dev/null @@ -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
\r\n

ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ

\r\n
\r\n ", - "status": "success", - "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json b/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json deleted file mode 100644 index 8f8d5059..00000000 --- a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json b/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json deleted file mode 100644 index dbec91a5..00000000 --- a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json b/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json deleted file mode 100644 index d2d4c424..00000000 --- a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json +++ /dev/null @@ -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
\r\n

2

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json b/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json deleted file mode 100644 index 1a388699..00000000 --- a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "e2801ec2-6219-4c3c-83b4-8a6834569488", - "sentAt": "2025-10-13T00:59:46.729Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n \r\n
\r\n

추가메시지 영역

\r\n
\r\n \n
\n
", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317184642-745285906.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1e0abffb-a6cc-8312-d8b4-31c33cb72aa7@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json deleted file mode 100644 index 74c8212f..00000000 --- a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json +++ /dev/null @@ -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": [ - "\"권은아\" " - ], - "subject": "Re: 매우 졸린 오후예요", - "htmlContent": "\r\n
\r\n

호홋 답장 기능을 구현했다죵
얼른 퇴근하고 싪네여

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"권은아\"

\r\n

날짜: 2025. 10. 22. 오후 1:10:37

\r\n

제목: 매우 졸린 오후예요

\r\n
\r\n undefined\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json b/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json deleted file mode 100644 index 45c6a1eb..00000000 --- a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json b/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json deleted file mode 100644 index f64daf8c..00000000 --- a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json deleted file mode 100644 index efd9a0c0..00000000 --- a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json +++ /dev/null @@ -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\" " - ], - "subject": "Re: 안녕하세여", - "htmlContent": "\r\n
\r\n

어떻게 가는지 궁금한데 이따가 화면 보여주세영

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"DHS\"

\r\n

날짜: 2025. 10. 22. 오후 1:09:49

\r\n

제목: 안녕하세여

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "ddhhss0603@gmail.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fc69cdb1..652677ca 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 521f5250..d1328bcd 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -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 { 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("�")) + ) { + 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 호출 오류", }); } diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index da0ea772..a28712c1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -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( `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 => { + 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 = { + 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 => { + 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(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(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 }, + }); + } +} diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 374015ee..6f72eb10 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -384,4 +384,69 @@ export class AuthController { }); } } + + /** + * POST /api/auth/signup + * 공차중계 회원가입 API + */ + static async signup(req: Request, res: Response): Promise { + 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 : "알 수 없는 오류", + }, + }); + } + } } diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 8a29e5bf..009e30a8 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -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); diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts index 84608731..1b0166ae 100644 --- a/backend-node/src/controllers/batchExecutionLogController.ts +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -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) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index d1be2311..bdd9e869 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -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: "인증 서비스 목록 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/controllers/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts new file mode 100644 index 00000000..4a2fa61f --- /dev/null +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -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 => { + 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 = {}; + 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, + }); + } +}; diff --git a/backend-node/src/controllers/cascadingConditionController.ts b/backend-node/src/controllers/cascadingConditionController.ts new file mode 100644 index 00000000..6cc89319 --- /dev/null +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -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; + } +} diff --git a/backend-node/src/controllers/cascadingHierarchyController.ts b/backend-node/src/controllers/cascadingHierarchyController.ts new file mode 100644 index 00000000..e57efa09 --- /dev/null +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -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 => { + 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, + }); + } +}; diff --git a/backend-node/src/controllers/cascadingMutualExclusionController.ts b/backend-node/src/controllers/cascadingMutualExclusionController.ts new file mode 100644 index 00000000..b1cbeaa6 --- /dev/null +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -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 => { + 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 = {}; + 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, + }); + } +}; diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts new file mode 100644 index 00000000..27f03c71 --- /dev/null +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -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, + }); + } +}; diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 80cb8ccd..e80a44dc 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -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( - `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 => { +export const getHierarchyData = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, hierarchyConfig } = req.body; @@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise ({ 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 => { +export const getChildrenData = async ( + req: Request, + res: Response +): Promise => { 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 => { +export const getWarehouses = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { +export const getAreas = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, warehouseKey, tableName } = req.query; @@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise = }); } - 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 = }; // 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 -export const getLocations = async (req: Request, res: Response): Promise => { +export const getLocations = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, areaKey, tableName } = req.query; @@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise => { +export const getMaterials = async ( + req: Request, + res: Response +): Promise => { 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 => { +export const getMaterialCounts = async ( + req: Request, + res: Response +): Promise => { try { const { externalDbConnectionId, locationKeys, tableName } = req.body; @@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise `'${key}'`).join(","); diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts index d7ecbae1..f95ed0e2 100644 --- a/backend-node/src/controllers/digitalTwinLayoutController.ts +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -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({ diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts index 882d8e62..4ea80ef9 100644 --- a/backend-node/src/controllers/digitalTwinTemplateController.ts +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -161,3 +161,4 @@ export const createMappingTemplate = async ( + diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts new file mode 100644 index 00000000..dda101e5 --- /dev/null +++ b/backend-node/src/controllers/driverController.ts @@ -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 { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 사용자 정보 조회 + const userResult = await query( + `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( + `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 { + 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( + `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( + `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 { + 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( + `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 { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `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 { + 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( + `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( + `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 { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `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: "회원 탈퇴 처리 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 9b8ef6fc..98606f51 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -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 => { + 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 => { + 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 => { + 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 || "위치 이력 조회에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 66e20ccd..00727f1d 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -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, // 🆕 제외 필터 전달 } ); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 880c54fc..4d911c57 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -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} diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index b1e31e3b..d4e8d0cf 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -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( + `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( + `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: "파일이 삭제되었습니다.", diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 85ad2259..393b33cc 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -32,8 +32,17 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { 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 => { + 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", + }); + } + }; } diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts deleted file mode 100644 index 82043964..00000000 --- a/backend-node/src/controllers/orderController.ts +++ /dev/null @@ -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 { - 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, - }); - } -} diff --git a/backend-node/src/controllers/screenEmbeddingController.ts b/backend-node/src/controllers/screenEmbeddingController.ts new file mode 100644 index 00000000..497d99db --- /dev/null +++ b/backend-node/src/controllers/screenEmbeddingController.ts @@ -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(); + } +} diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 0ff80988..5605031e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -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, diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index c25b4127..75e225e6 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -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레벨 메뉴 목록 조회 * diff --git a/backend-node/src/controllers/tableHistoryController.ts b/backend-node/src/controllers/tableHistoryController.ts index a32f31ad..8a506626 100644 --- a/backend-node/src/controllers/tableHistoryController.ts +++ b/backend-node/src/controllers/tableHistoryController.ts @@ -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} `; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index f552124f..66c70a77 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -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, + * subTables: Array<{ + * tableName: string, + * linkColumn: { mainField: string, subColumn: string }, + * items: Record[], + * 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 { + 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 = { + [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(); + } +} + diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts new file mode 100644 index 00000000..5b7f4436 --- /dev/null +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 || "통계 조회 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/controllers/vehicleReportController.ts b/backend-node/src/controllers/vehicleReportController.ts new file mode 100644 index 00000000..db17dd24 --- /dev/null +++ b/backend-node/src/controllers/vehicleReportController.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 || "구간별 통계 조회에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/controllers/vehicleTripController.ts b/backend-node/src/controllers/vehicleTripController.ts new file mode 100644 index 00000000..d1604ede --- /dev/null +++ b/backend-node/src/controllers/vehicleTripController.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 || "운행 취소에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 2c2965aa..9fd68fe7 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -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 = { + "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 { - 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 { diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index a54c64c6..6d8c7bda 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -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 || "토큰 검증에 실패했습니다.", }, }); } diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index 611e5d08..54d8f0a2 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -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); } } diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 188e5580..b9964962 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -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 중복 체크 diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 29bc7944..adba86e6 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -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; diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index d6adf4c5..50ee1ea0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -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; diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts new file mode 100644 index 00000000..de4eb913 --- /dev/null +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -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; + + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts new file mode 100644 index 00000000..c2f12782 --- /dev/null +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -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; + + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts new file mode 100644 index 00000000..71e6c418 --- /dev/null +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -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; + + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts new file mode 100644 index 00000000..d92d7d72 --- /dev/null +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -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; + + diff --git a/backend-node/src/routes/cascadingRelationRoutes.ts b/backend-node/src/routes/cascadingRelationRoutes.ts new file mode 100644 index 00000000..28e66387 --- /dev/null +++ b/backend-node/src/routes/cascadingRelationRoutes.ts @@ -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; + diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index f13d65cf..6de84866 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -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; diff --git a/backend-node/src/routes/driverRoutes.ts b/backend-node/src/routes/driverRoutes.ts new file mode 100644 index 00000000..b46cca1b --- /dev/null +++ b/backend-node/src/routes/driverRoutes.ts @@ -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; + diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 5514fb54..cec78990 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -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; diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index 9f577e52..14fd17d0 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -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; diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 5816fb8e..e33afac2 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -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); diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts deleted file mode 100644 index a59b5f43..00000000 --- a/backend-node/src/routes/orderRoutes.ts +++ /dev/null @@ -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; - diff --git a/backend-node/src/routes/screenEmbeddingRoutes.ts b/backend-node/src/routes/screenEmbeddingRoutes.ts new file mode 100644 index 00000000..6b604c15 --- /dev/null +++ b/backend-node/src/routes/screenEmbeddingRoutes.ts @@ -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; + diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4207c719..67263277 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -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); // 단일 화면 복사 (하위 호환용) diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index c4afe66e..e59d9b9d 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -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; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5ea98489..d0716d59 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -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; diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts new file mode 100644 index 00000000..1a4bc6f0 --- /dev/null +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -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; + diff --git a/backend-node/src/routes/vehicleTripRoutes.ts b/backend-node/src/routes/vehicleTripRoutes.ts new file mode 100644 index 00000000..c70a7394 --- /dev/null +++ b/backend-node/src/routes/vehicleTripRoutes.ts @@ -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; + diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index b75034c2..0d96b285 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -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( diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts index d4818b3a..f63e929f 100644 --- a/backend-node/src/services/DigitalTwinTemplateService.ts +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -170,3 +170,4 @@ export class DigitalTwinTemplateService { + diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index c6ab17c6..5ca6b392 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -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( ` @@ -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}`; diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 11e34576..e5d6aa97 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -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( + `SELECT user_id FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (existingUser.length > 0) { + return { + success: false, + message: "이미 존재하는 아이디입니다.", + }; + } + + // 2. 중복 차량번호 확인 + const existingVehicle = await query( + `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 || "회원가입 중 오류가 발생했습니다.", + }; + } + } } diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts index f2fc583c..3561f43f 100644 --- a/backend-node/src/services/batchExecutionLogService.ts +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -130,13 +130,14 @@ export class BatchExecutionLogService { try { const log = await queryOne( `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, diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 75d7ea67..303c2d7a 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -77,45 +77,47 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( - connectionType: "internal" | "external", - connectionId?: number + static async getTables( + connectionId: number ): Promise> { try { - let tables: TableInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name`, - [] - ); - - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 테이블 조회 - const tablesResult = await this.getExternalTables(connectionId); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); + if (!connection) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } + // 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, + connectionId + ); + + // 연결 + await connector.connect(); + + // 테이블 목록 조회 + const tables = await connector.getTables(); + + // 연결 종료 + await connector.disconnect(); + return { success: true, data: tables, message: `${tables.length}개의 테이블을 조회했습니다.`, }; } catch (error) { - console.error("배치관리 테이블 목록 조회 실패:", error); + console.error("테이블 목록 조회 실패:", error); return { success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", @@ -125,562 +127,282 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( - connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string - ): Promise> { - try { - console.log(`[BatchExternalDbService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}` - ); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await this.getExternalTableColumns( - connectionId, - tableName - ); - - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, - columnsResult - ); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - } - - console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); - return { - success: true, - data: columns, - message: `${columns.length}개의 컬럼을 조회했습니다.`, - }; - } catch (error) { - console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); - return { - success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 목록 조회 (내부 구현) - */ - private static async getExternalTables( - connectionId: number - ): Promise> { - try { - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - - if (!connection) { - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; - } - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - if (!decryptedPassword) { - return { - success: false, - message: "비밀번호 복호화에 실패했습니다.", - }; - } - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - // DatabaseConnectorFactory를 통한 테이블 목록 조회 - const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type, - config, - connectionId - ); - const tables = await connector.getTables(); - - return { - success: true, - message: "테이블 목록을 조회했습니다.", - data: tables, - }; - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - return { - success: false, - message: "테이블 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) - */ - private static async getExternalTableColumns( + static async getColumns( connectionId: number, tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}` - ); - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + const connection = await this.getConnectionById(connectionId); if (!connection) { - console.log( - `[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}` - ); - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { - id: connection.id, - connection_name: connection.connection_name, - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - }); - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - console.log( - `[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}` - ); - - // 데이터베이스 타입에 따른 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, - config, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - console.log( - `[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}` - ); + // 연결 + await connector.connect(); - // 컬럼 정보 조회 - console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + // 컬럼 목록 조회 const columns = await connector.getColumns(tableName); - console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); - console.log( - `[BatchExternalDbService] 원본 컬럼 개수:`, - columns ? columns.length : "null/undefined" - ); + // 연결 종료 + await connector.disconnect(); - // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 - const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { - console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); - - // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) - if (col.name && col.dataType !== undefined) { - const result = { - column_name: col.name, - data_type: col.dataType, - is_nullable: col.isNullable ? "YES" : "NO", - column_default: col.defaultValue || null, - }; - console.log( - `[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, - result - ); - return result; - } - // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} - else { - const result = { - column_name: col.column_name || col.COLUMN_NAME, - data_type: col.data_type || col.DATA_TYPE, - is_nullable: - col.is_nullable || - col.IS_NULLABLE || - (col.nullable === "Y" ? "YES" : "NO"), - column_default: col.column_default || col.COLUMN_DEFAULT || null, - }; - console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); - return result; - } - }); - - console.log( - `[BatchExternalDbService] 표준화된 컬럼 목록:`, - standardizedColumns - ); - - // 빈 배열인 경우 경고 로그 - if (!standardizedColumns || standardizedColumns.length === 0) { - console.warn( - `[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}` - ); - console.warn(`[BatchExternalDbService] 연결 정보:`, { - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - username: connection.username, - }); - - // 테이블 존재 여부 확인 - console.warn( - `[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도` - ); - try { - const tables = await connector.getTables(); - console.warn( - `[BatchExternalDbService] 사용 가능한 테이블 목록:`, - tables.map((t) => t.table_name) - ); - - // 테이블명이 정확한지 확인 - const tableExists = tables.some( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - console.warn( - `[BatchExternalDbService] 테이블 존재 여부: ${tableExists}` - ); - - // 정확한 테이블명 찾기 - const exactTable = tables.find( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - if (exactTable) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}` - ); - } - - // 모든 테이블명 출력 - console.warn( - `[BatchExternalDbService] 모든 테이블명:`, - tables.map((t) => `"${t.table_name}"`) - ); - - // 테이블명 비교 - console.warn( - `[BatchExternalDbService] 요청된 테이블명: "${tableName}"` - ); - console.warn( - `[BatchExternalDbService] 테이블명 비교 결과:`, - tables.map((t) => ({ - table_name: t.table_name, - matches: t.table_name.toLowerCase() === tableName.toLowerCase(), - exact_match: t.table_name === tableName, - })) - ); - - // 정확한 테이블명으로 다시 시도 - if (exactTable && exactTable.table_name !== tableName) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}` - ); - try { - const correctColumns = await connector.getColumns( - exactTable.table_name - ); - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, - correctColumns - ); - } catch (correctError) { - console.error( - `[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, - correctError - ); - } - } - } catch (tableError) { - console.error( - `[BatchExternalDbService] 테이블 목록 조회 실패:`, - tableError - ); - } - } + // BatchColumnInfo 형식으로 변환 + const batchColumns: ColumnInfo[] = columns.map((col) => ({ + column_name: col.column_name, + data_type: col.data_type, + is_nullable: col.is_nullable, + column_default: col.column_default, + })); return { success: true, - data: standardizedColumns, - message: "컬럼 정보를 조회했습니다.", + data: batchColumns, + message: `${batchColumns.length}개의 컬럼을 조회했습니다.`, }; } catch (error) { - console.error( - "[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", - error - ); - console.error( - "[BatchExternalDbService] 오류 스택:", - error instanceof Error ? error.stack : "No stack trace" - ); + console.error("컬럼 목록 조회 실패:", error); return { success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", + message: "컬럼 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 데이터 조회 + * 연결 정보 조회 (내부 메서드) + */ + private static async getConnectionById(id: number) { + const connections = await query( + `SELECT * FROM external_db_connections WHERE id = $1`, + [id] + ); + + if (connections.length === 0) { + return null; + } + + const connection = connections[0]; + + // 비밀번호 복호화 + if (connection.password) { + try { + connection.password = PasswordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + // 복호화 실패 시 원본 사용 (또는 에러 처리) + } + } + + return connection; + } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + // 👇 body 파라미터 추가 + body?: string + ): Promise> { + try { + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 10000, // 미리보기는 짧은 타임아웃 + }); + + // 파라미터 적용 + let finalEndpoint = endpoint; + if ( + paramInfo && + paramInfo.paramName && + paramInfo.paramValue && + paramInfo.paramSource === "static" + ) { + if (paramInfo.paramType === "url") { + finalEndpoint = endpoint.replace( + `{${paramInfo.paramName}}`, + paramInfo.paramValue + ); + } else if (paramInfo.paramType === "query") { + const separator = endpoint.includes("?") ? "&" : "?"; + finalEndpoint = `${endpoint}${separator}${paramInfo.paramName}=${paramInfo.paramValue}`; + } + } + + // JSON body 파싱 + let requestData; + if (body) { + try { + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + + // 데이터 조회 (직접 RestApiConnector 메서드 호출) + // 타입 단언을 사용하여 private/protected 메서드 우회 또는 인터페이스 확장 필요 + // 여기서는 executeRequest가 public이라고 가정 + const result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData + ); + + return { + success: true, + data: result.data || result, // 데이터가 없으면 전체 결과 반환 + message: "데이터 미리보기 성공", + }; + } catch (error) { + return { + success: false, + message: "데이터 미리보기 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 테이블 데이터 조회 */ static async getDataFromTable( connectionId: number, - tableName: string, - limit: number = 100 + tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT * FROM ${tableName} LIMIT ${limit}`; - } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT * FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 특정 컬럼들만 조회 + * 외부 DB 테이블 데이터 조회 (컬럼 지정) */ static async getDataFromTableWithColumns( connectionId: number, tableName: string, - columns: string[], - limit: number = 100 + columns: string[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(", ")}]` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; - const columnList = columns.join(", "); + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; - } + // 컬럼 목록 쿼리 구성 + const columnString = columns.join(", "); - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에 데이터 삽입 + * 테이블에 데이터 삽입 */ static async insertDataToTable( connectionId: number, @@ -688,147 +410,79 @@ export class BatchExternalDbService { data: any[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드` - ); - - if (!data || data.length === 0) { - return { - success: true, - data: { successCount: 0, failedCount: 0 }, - }; - } - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); + // 연결 + await connector.connect(); + let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) - for (const record of data) { - try { - const columns = Object.keys(record); - const values = Object.values(record); + // 트랜잭션 시작 (지원하는 경우) + // await connector.beginTransaction(); - // 값들을 SQL 문자열로 변환 (타입별 처리) - const formattedValues = values - .map((value) => { - if (value === null || value === undefined) { - return "NULL"; - } else if (value instanceof Date) { - // Date 객체를 MySQL/MariaDB 형식으로 변환 - return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`; - } else if (typeof value === "string") { - // 문자열이 날짜 형식인지 확인 - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - // JavaScript Date 문자열을 MySQL 형식으로 변환 - const date = new Date(value); - return `'${date.toISOString().slice(0, 19).replace("T", " ")}'`; - } else { - return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 - } - } else if (typeof value === "number") { - return String(value); - } else if (typeof value === "boolean") { - return value ? "1" : "0"; - } else { - // 기타 객체는 문자열로 변환 - return `'${String(value).replace(/'/g, "''")}'`; - } - }) - .join(", "); + try { + // 각 레코드를 개별적으로 삽입 + for (const record of data) { + try { + // 쿼리 빌더 사용 (간단한 구현) + const columns = Object.keys(record); + const values = Object.values(record); + const placeholders = values + .map((_, i) => (connection.db_type === "postgresql" ? `$${i + 1}` : "?")) + .join(", "); - // Primary Key 컬럼 추정 - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; + const query = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - - let query: string; - const dbType = connection.db_type?.toLowerCase() || "mysql"; - - if (dbType === "mysql" || dbType === "mariadb") { - // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 - if (updateColumns.length > 0) { - const updateSet = updateColumns - .map((col) => `${col} = VALUES(${col})`) - .join(", "); - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues}) - ON DUPLICATE KEY UPDATE ${updateSet}`; - } else { - // Primary Key만 있는 경우 IGNORE 사용 - query = `INSERT IGNORE INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; - } - } else { - // 다른 DB는 기본 INSERT 사용 - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; + // 파라미터 매핑 (PostgreSQL은 $1, $2..., MySQL은 ?) + await connector.executeQuery(query, values); + successCount++; + } catch (insertError) { + console.error("레코드 삽입 실패:", insertError); + failedCount++; } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - console.log(`[BatchExternalDbService] 삽입할 데이터:`, record); - - await connector.executeQuery(query); - successCount++; - } catch (error) { - console.error(`외부 DB 레코드 UPSERT 실패:`, error); - failedCount++; } - } - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); + // 트랜잭션 커밋 + // await connector.commit(); + } catch (txError) { + // 트랜잭션 롤백 + // await connector.rollback(); + throw txError; + } finally { + // 연결 종료 + await connector.disconnect(); + } return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("데이터 삽입 실패:", error); return { success: false, - message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + message: "데이터 삽입 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -848,7 +502,9 @@ export class BatchExternalDbService { paramType?: "url" | "query", paramName?: string, paramValue?: string, - paramSource?: "static" | "dynamic" + paramSource?: "static" | "dynamic", + // 👇 body 파라미터 추가 + body?: string ): Promise> { try { console.log( @@ -895,47 +551,49 @@ export class BatchExternalDbService { ); } + // 👇 Body 파싱 (POST/PUT 요청 시) + let requestData; + if (body && (method === 'POST' || method === 'PUT')) { + try { + // 템플릿 변수가 있을 수 있으므로 여기서는 원본 문자열을 사용하거나 + // 정적 값만 파싱. 여기서는 일단 정적 JSON으로 가정하고 파싱 시도. + // (BatchScheduler에서 템플릿 처리 후 전달하는 것이 이상적이나, + // 현재 구조상 여기서 파싱 시도하고 실패하면 문자열 그대로 전송) + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + // 데이터 조회 (REST API는 executeRequest 사용) let result; if ((connector as any).executeRequest) { - result = await (connector as any).executeRequest(finalEndpoint, method); + // executeRequest(endpoint, method, data) + result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData // body 전달 + ); } else { + // Fallback (GET only) result = await connector.executeQuery(finalEndpoint); } - let data = result.rows; - // 컬럼 필터링 (지정된 컬럼만 추출) - if (columns && columns.length > 0) { - data = data.map((row: any) => { - const filteredRow: any = {}; - columns.forEach((col) => { - if (row.hasOwnProperty(col)) { - filteredRow[col] = row[col]; - } - }); - return filteredRow; - }); + let data = result.rows || result.data || result; + + // 👇 단일 객체 응답(토큰 등)인 경우 배열로 래핑하여 리스트처럼 처리 + if (!Array.isArray(data)) { + data = [data]; } - // 제한 개수 적용 - if (limit > 0) { - data = data.slice(0, limit); - } - - logger.info( - `[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드` - ); - logger.info(`[BatchExternalDbService] 조회된 데이터`, { data }); - return { success: true, data: data, + message: `${data.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, - error - ); + console.error("REST API 데이터 조회 실패:", error); return { success: false, message: "REST API 데이터 조회 중 오류가 발생했습니다.", @@ -1035,16 +693,15 @@ export class BatchExternalDbService { urlPathColumn && record[urlPathColumn] ) { - // /api/users → /api/users/user123 - finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + // endpoint 마지막에 ID 추가 (예: /api/users -> /api/users/123) + // 이미 /로 끝나는지 확인 + const separator = finalEndpoint.endsWith("/") ? "" : "/"; + finalEndpoint = `${finalEndpoint}${separator}${record[urlPathColumn]}`; + + console.log(`[BatchExternalDbService] 동적 엔드포인트: ${finalEndpoint}`); } - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); - - // REST API는 executeRequest 사용 + // 데이터 전송 (REST API는 executeRequest 사용) if ((connector as any).executeRequest) { await (connector as any).executeRequest( finalEndpoint, @@ -1052,101 +709,32 @@ export class BatchExternalDbService { requestData ); } else { - await connector.executeQuery(finalEndpoint); + // Fallback + // @ts-ignore + await connector.httpClient.request({ + method: method, + url: finalEndpoint, + data: requestData + }); } + successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); + } catch (sendError) { + console.error("데이터 전송 실패:", sendError); failedCount++; } } - console.log( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, - error - ); + console.error("데이터 전송 실패:", error); return { success: false, - message: `REST API 데이터 전송 실패: ${error}`, - data: { successCount: 0, failedCount: 0 }, - }; - } - } - - /** - * REST API로 데이터 전송 (기존 메서드) - */ - static async sendDataToRestApi( - apiUrl: string, - apiKey: string, - endpoint: string, - method: "POST" | "PUT" = "POST", - data: any[] - ): Promise> { - try { - console.log( - `[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드` - ); - - // REST API 커넥터 생성 - const connector = new RestApiConnector({ - baseUrl: apiUrl, - apiKey: apiKey, - timeout: 30000, - }); - - // 연결 테스트 - await connector.connect(); - - let successCount = 0; - let failedCount = 0; - - // 각 레코드를 개별적으로 전송 - for (const record of data) { - try { - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, record); - - // REST API는 executeRequest 사용 - if ((connector as any).executeRequest) { - await (connector as any).executeRequest(endpoint, method, record); - } else { - await connector.executeQuery(endpoint); - } - successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); - failedCount++; - } - } - - console.log( - `[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - - return { - success: true, - data: { successCount, failedCount }, - }; - } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, - error - ); - return { - success: false, - message: "REST API 데이터 전송 중 오류가 발생했습니다.", + message: "데이터 전송 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 77863904..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -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 = new Map(); - private static isInitialized = false; - private static executingBatches: Set = new Set(); // 실행 중인 배치 추적 + private static scheduledTasks: Map = 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( - `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( - `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(); + 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( - `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); } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 247b1ab8..31ee2001 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -16,7 +16,6 @@ import { UpdateBatchConfigRequest, } from "../types/batchTypes"; import { BatchExternalDbService } from "./batchExternalDbService"; -import { DbConnectionManager } from "./dbConnectionManager"; export class BatchService { /** @@ -65,62 +64,43 @@ export class BatchService { const limit = filter.limit || 10; const offset = (page - 1) * limit; - // 배치 설정 조회 (매핑 포함 - 서브쿼리 사용) - const batchConfigs = await query( - `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - 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, - '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, - 'mapping_order', bm.mapping_order - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings + // 전체 카운트 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM batch_configs bc ${whereClause}`, + values + ); + const total = parseInt(countResult[0].count); + const totalPages = Math.ceil(total / limit); + + // 목록 조회 + const configs = await query( + `SELECT bc.* FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id ${whereClause} - GROUP BY bc.id - ORDER BY bc.is_active DESC, bc.batch_name ASC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + ORDER BY bc.created_date DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...values, limit, offset] ); - // 전체 개수 조회 - const countResult = await queryOne<{ count: string }>( - `SELECT COUNT(DISTINCT bc.id) as count - FROM batch_configs bc - ${whereClause}`, - values - ); - - const total = parseInt(countResult?.count || "0"); + // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) + // 하지만 목록에서도 간단한 정보는 필요할 수 있음 return { success: true, - data: batchConfigs as BatchConfig[], + data: configs as BatchConfig[], pagination: { page, limit, total, - totalPages: Math.ceil(total / limit), + totalPages, }, + message: `${configs.length}개의 배치 설정을 조회했습니다.`, }; } catch (error) { console.error("배치 설정 목록 조회 오류:", error); return { success: false, + data: [], message: "배치 설정 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -128,70 +108,56 @@ export class BatchService { } /** - * 특정 배치 설정 조회 (회사별) + * 특정 배치 설정 조회 (별칭) + */ + static async getBatchConfig(id: number): Promise { + const result = await this.getBatchConfigById(id); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * 배치 설정 상세 조회 */ static async getBatchConfigById( - id: number, - userCompanyCode?: string + id: number ): Promise> { try { - let query = `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - 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 - ) - ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC - ) 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`; + // 배치 설정 조회 + const config = await queryOne( + `SELECT * FROM batch_configs WHERE id = $1`, + [id] + ); - const params: any[] = [id]; - let paramIndex = 2; - - // 회사별 필터링 (최고 관리자가 아닌 경우) - if (userCompanyCode && userCompanyCode !== "*") { - query += ` AND bc.company_code = $${paramIndex}`; - params.push(userCompanyCode); - } - - query += ` GROUP BY bc.id`; - - const batchConfig = await queryOne(query, params); - - if (!batchConfig) { + if (!config) { return { success: false, - message: "배치 설정을 찾을 수 없거나 권한이 없습니다.", + message: "배치 설정을 찾을 수 없습니다.", }; } + // 매핑 정보 조회 + const mappings = await query( + `SELECT * FROM batch_mappings WHERE batch_config_id = $1 ORDER BY mapping_order ASC`, + [id] + ); + + const batchConfig: BatchConfig = { + ...config, + batch_mappings: mappings, + } as BatchConfig; + return { success: true, - data: batchConfig as BatchConfig, + data: batchConfig, }; } catch (error) { - console.error("배치 설정 조회 오류:", error); + console.error("배치 설정 상세 조회 오류:", error); return { success: false, - message: "배치 설정 조회에 실패했습니다.", + message: "배치 설정 상세 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -210,10 +176,21 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, - [data.batchName, data.description, data.cronSchedule, userId, userId] + [ + data.batchName, + data.description, + data.cronSchedule, + data.isActive || "Y", + data.companyCode, + data.saveMode || "INSERT", + data.conflictKey || null, + data.authServiceName || null, + data.dataArrayPath || null, + userId, + ] ); const batchConfig = batchConfigResult.rows[0]; @@ -224,15 +201,16 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, - to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW()) RETURNING *`, [ batchConfig.id, + data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 mapping.from_connection_type, mapping.from_connection_id, mapping.from_table_name, @@ -245,6 +223,7 @@ export class BatchService { mapping.from_api_param_name, mapping.from_api_param_value, mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body mapping.to_connection_type, mapping.to_connection_id, mapping.to_table_name, @@ -255,6 +234,7 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed userId, ] ); @@ -292,35 +272,22 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return existing; + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." + ); } + const existingConfig = existingResult.data; - const existingConfig = await queryOne( - `SELECT bc.*, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id - ) - ) 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`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; + // 권한 체크 (회사 코드가 다르면 수정 불가) + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("수정 권한이 없습니다."); } // 트랜잭션으로 업데이트 @@ -349,6 +316,22 @@ export class BatchService { updateFields.push(`is_active = $${paramIndex++}`); updateValues.push(data.isActive); } + if (data.saveMode !== undefined) { + updateFields.push(`save_mode = $${paramIndex++}`); + updateValues.push(data.saveMode); + } + if (data.conflictKey !== undefined) { + updateFields.push(`conflict_key = $${paramIndex++}`); + updateValues.push(data.conflictKey || null); + } + if (data.authServiceName !== undefined) { + updateFields.push(`auth_service_name = $${paramIndex++}`); + updateValues.push(data.authServiceName || null); + } + if (data.dataArrayPath !== undefined) { + updateFields.push(`data_array_path = $${paramIndex++}`); + updateValues.push(data.dataArrayPath || null); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( @@ -373,15 +356,16 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, - to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW()) RETURNING *`, [ id, + existingConfig.company_code, // 기존 설정의 company_code 유지 mapping.from_connection_type, mapping.from_connection_id, mapping.from_table_name, @@ -394,6 +378,7 @@ export class BatchService { mapping.from_api_param_name, mapping.from_api_param_value, mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body mapping.to_connection_type, mapping.to_connection_id, mapping.to_table_name, @@ -404,6 +389,7 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed userId, ] ); @@ -446,38 +432,26 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return { - success: false, - message: existing.message, - }; - } - - const existingConfig = await queryOne( - `SELECT * FROM batch_configs WHERE id = $1`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; - } - - // 트랜잭션으로 삭제 - await transaction(async (client) => { - // 배치 매핑 먼저 삭제 (외래키 제약) - await client.query( - `DELETE FROM batch_mappings WHERE batch_config_id = $1`, - [id] + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." ); + } + const existingConfig = existingResult.data; - // 배치 설정 삭제 - await client.query(`DELETE FROM batch_configs WHERE id = $1`, [id]); - }); + // 권한 체크 + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("삭제 권한이 없습니다."); + } + + // 물리 삭제 (CASCADE 설정에 따라 매핑도 삭제됨) + await query(`DELETE FROM batch_configs WHERE id = $1`, [id]); return { success: true, @@ -494,93 +468,57 @@ export class BatchService { } /** - * 사용 가능한 커넥션 목록 조회 + * DB 연결 정보 조회 */ - static async getAvailableConnections(): Promise< - ApiResponse - > { + static async getConnections(): Promise> { try { - const connections: ConnectionInfo[] = []; - - // 내부 DB 추가 - connections.push({ - type: "internal", - name: "Internal Database", - db_type: "postgresql", - }); - - // 외부 DB 연결 조회 - const externalConnections = - await BatchExternalDbService.getAvailableConnections(); - - if (externalConnections.success && externalConnections.data) { - externalConnections.data.forEach((conn) => { - connections.push({ - type: "external", - id: conn.id, - name: conn.name, - db_type: conn.db_type, - }); - }); - } - - return { - success: true, - data: connections, - }; + // BatchExternalDbService 사용 + const result = await BatchExternalDbService.getAvailableConnections(); + return result; } catch (error) { - console.error("커넥션 목록 조회 오류:", error); + console.error("DB 연결 목록 조회 오류:", error); return { success: false, - message: "커넥션 목록 조회에 실패했습니다.", + data: [], + message: "DB 연결 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 특정 커넥션의 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( + static async getTables( connectionType: "internal" | "external", connectionId?: number ): Promise> { try { - let tables: TableInfo[] = []; - if (connectionType === "internal") { // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' + const tables = await query( + `SELECT table_name, table_type, table_schema + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name` ); - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 테이블 조회 - const tablesResult = - await BatchExternalDbService.getTablesFromConnection( - connectionType, - connectionId - ); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + return await BatchExternalDbService.getTables(connectionId); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: tables, - }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { success: false, + data: [], message: "테이블 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -588,185 +526,139 @@ export class BatchService { } /** - * 특정 테이블의 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( + static async getColumns( + tableName: string, connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string + connectionId?: number ): Promise> { try { - console.log(`[BatchService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - if (connectionType === "internal") { // 내부 DB 컬럼 조회 - console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 + const columns = await query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, [tableName] ); - - console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 컬럼 조회 - console.log( - `[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await BatchExternalDbService.getTableColumns( - connectionType, - connectionId, - tableName - ); - - console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - - console.log(`[BatchService] 외부 DB 컬럼:`, columns); + return await BatchExternalDbService.getColumns(connectionId, tableName); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: columns, - }; } catch (error) { - console.error("컬럼 정보 조회 오류:", error); + console.error("컬럼 목록 조회 오류:", error); return { success: false, - message: "컬럼 정보 조회에 실패했습니다.", + data: [], + message: "컬럼 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 배치 실행 로그 생성 + * 데이터 미리보기 */ - static async createExecutionLog(data: { - batch_config_id: number; - execution_status: string; - start_time: Date; - total_records: number; - success_records: number; - failed_records: number; - }): Promise { + static async previewData( + tableName: string, + connectionType: "internal" | "external", + connectionId?: number + ): Promise> { try { - const executionLog = await queryOne( - `INSERT INTO batch_execution_logs - (batch_config_id, execution_status, start_time, total_records, success_records, failed_records) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [ - data.batch_config_id, - data.execution_status, - data.start_time, - data.total_records, - data.success_records, - data.failed_records, - ] - ); - - return executionLog; + if (connectionType === "internal") { + // 내부 DB 데이터 조회 + const data = await query(`SELECT * FROM ${tableName} LIMIT 10`); + return { + success: true, + data, + message: "데이터 미리보기 성공", + }; + } else if (connectionId) { + // 외부 DB 데이터 조회 + return await BatchExternalDbService.getDataFromTable( + connectionId, + tableName + ); + } else { + throw new Error("외부 연결 ID가 필요합니다."); + } } catch (error) { - console.error("배치 실행 로그 생성 오류:", error); - throw error; + console.error("데이터 미리보기 오류:", error); + return { + success: false, + data: [], + message: "데이터 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } /** - * 배치 실행 로그 업데이트 + * REST API 데이터 미리보기 */ - static async updateExecutionLog( - id: number, - data: { - execution_status?: string; - end_time?: Date; - duration_ms?: number; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string; - } - ): Promise { + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + body?: string + ): Promise> { try { - // 동적 UPDATE 쿼리 생성 - const updateFields: string[] = []; - const values: any[] = []; - let paramIndex = 1; - - if (data.execution_status !== undefined) { - updateFields.push(`execution_status = $${paramIndex++}`); - values.push(data.execution_status); - } - if (data.end_time !== undefined) { - updateFields.push(`end_time = $${paramIndex++}`); - values.push(data.end_time); - } - if (data.duration_ms !== undefined) { - updateFields.push(`duration_ms = $${paramIndex++}`); - values.push(data.duration_ms); - } - if (data.total_records !== undefined) { - updateFields.push(`total_records = $${paramIndex++}`); - values.push(data.total_records); - } - if (data.success_records !== undefined) { - updateFields.push(`success_records = $${paramIndex++}`); - values.push(data.success_records); - } - if (data.failed_records !== undefined) { - updateFields.push(`failed_records = $${paramIndex++}`); - values.push(data.failed_records); - } - if (data.error_message !== undefined) { - updateFields.push(`error_message = $${paramIndex++}`); - values.push(data.error_message); - } - - if (updateFields.length > 0) { - await query( - `UPDATE batch_execution_logs - SET ${updateFields.join(", ")} - WHERE id = $${paramIndex}`, - [...values, id] - ); - } + return await BatchExternalDbService.previewRestApiData( + apiUrl, + apiKey, + endpoint, + method, + paramInfo, + body + ); } catch (error) { - console.error("배치 실행 로그 업데이트 오류:", error); - throw error; + console.error("REST API 미리보기 오류:", error); + return { + success: false, + message: "REST API 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } + /** + * 배치 유효성 검사 + */ + static async validateBatch( + config: Partial + ): Promise { + const errors: string[] = []; + + if (!config.batchName) errors.push("배치 작업명이 필요합니다."); + if (!config.cronSchedule) errors.push("Cron 스케줄이 필요합니다."); + if (!config.mappings || config.mappings.length === 0) { + errors.push("최소 하나 이상의 매핑이 필요합니다."); + } + + // 추가 유효성 검사 로직... + + return { + isValid: errors.length === 0, + errors, + }; + } + /** * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) */ @@ -824,61 +716,60 @@ export class BatchService { ): Promise { try { console.log( - `[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(", ")}) (${connectionType}${connectionId ? `:${connectionId}` : ""})` + `[BatchService] 테이블에서 컬럼 지정 데이터 조회: ${tableName} (${connectionType})` ); if (connectionType === "internal") { - // 내부 DB에서 특정 컬럼만 조회 (주의: SQL 인젝션 위험 - 실제 프로덕션에서는 테이블명/컬럼명 검증 필요) - const columnList = columns.join(", "); + // 내부 DB + const columnString = columns.join(", "); const result = await query( - `SELECT ${columnList} FROM ${tableName} LIMIT 100` - ); - console.log( - `[BatchService] 내부 DB 특정 컬럼 조회 결과: ${result.length}개 레코드` + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); return result; } else if (connectionType === "external" && connectionId) { - // 외부 DB에서 특정 컬럼만 조회 + // 외부 DB const result = await BatchExternalDbService.getDataFromTableWithColumns( connectionId, tableName, columns ); if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드` - ); return result.data; } else { - console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); - return []; + throw new Error(result.message || "외부 DB 조회 실패"); } } else { - throw new Error( - `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` - ); + throw new Error("잘못된 연결 설정입니다."); } } catch (error) { - console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + console.error(`데이터 조회 오류 (${tableName}):`, error); throw error; } } /** * 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분) + * @param tableName 테이블명 + * @param data 삽입할 데이터 배열 + * @param connectionType 연결 타입 (internal/external) + * @param connectionId 외부 연결 ID + * @param saveMode 저장 모드 (INSERT/UPSERT) + * @param conflictKey UPSERT 시 충돌 기준 컬럼명 */ static async insertDataToTable( tableName: string, data: any[], connectionType: "internal" | "external" = "internal", - connectionId?: number + connectionId?: number, + saveMode: "INSERT" | "UPSERT" = "INSERT", + conflictKey?: string ): Promise<{ successCount: number; failedCount: number; }> { try { console.log( - `[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드` + `[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}` ); if (!data || data.length === 0) { @@ -890,229 +781,90 @@ export class BatchService { let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + // 각 레코드를 개별적으로 삽입 for (const record of data) { try { - // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) const columns = Object.keys(record); - const values = Object.values(record).map((value) => { - // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) - if (value instanceof Date) { - return value.toISOString(); - } - // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 - if (typeof value === "string") { - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - return new Date(value).toISOString(); - } - // ISO 날짜 문자열 형식 체크 (2025-09-24T06:29:01.351Z) - const isoDateRegex = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; - if (isoDateRegex.test(value)) { - return new Date(value).toISOString(); - } - } - return value; - }); + const values = Object.values(record); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); - // PostgreSQL 타입 캐스팅을 위한 placeholder 생성 - const placeholders = columns - .map((col, index) => { - // 날짜/시간 관련 컬럼명 패턴 체크 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `$${index + 1}::timestamp`; - } - return `$${index + 1}`; - }) - .join(", "); + let queryStr: string; - // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; + if (saveMode === "UPSERT" && conflictKey) { + // UPSERT 모드: ON CONFLICT DO UPDATE + // 충돌 키를 제외한 컬럼들만 UPDATE + const updateColumns = columns.filter( + (col) => col !== conflictKey + ); - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - const updateSet = updateColumns - .map((col) => `${col} = EXCLUDED.${col}`) - .join(", "); - - // 트랜잭션 내에서 처리하여 연결 관리 최적화 - const result = await transaction(async (client) => { - // 먼저 해당 레코드가 존재하는지 확인 - const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`; - const existsResult = await client.query(checkQuery, [ - record[primaryKeyColumn], - ]); - const exists = parseInt(existsResult.rows[0]?.count || "0") > 0; - - let operationResult = "no_change"; - - if (exists && updateSet) { - // 기존 레코드가 있으면 UPDATE (값이 다른 경우에만) - const whereConditions = updateColumns - .map((col, index) => { - // 날짜/시간 컬럼에 대한 타입 캐스팅 처리 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `${col} IS DISTINCT FROM $${index + 2}::timestamp`; - } - return `${col} IS DISTINCT FROM $${index + 2}`; - }) - .join(" OR "); - - const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, "")} - WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`; - - // 파라미터: [primaryKeyValue, ...updateValues] - const updateValues = [ - record[primaryKeyColumn], - ...updateColumns.map((col) => record[col]), - ]; - const updateResult = await client.query(query, updateValues); - - if (updateResult.rowCount && updateResult.rowCount > 0) { - console.log( - `[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "updated"; - } else { - console.log( - `[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - } else if (!exists) { - // 새 레코드 삽입 - const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; - await client.query(query, values); - console.log( - `[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "inserted"; + // 업데이트할 컬럼이 없으면 DO NOTHING 사용 + if (updateColumns.length === 0) { + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictKey}) + DO NOTHING`; } else { - console.log( - `[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; + const updateSet = updateColumns + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + // updated_date 컬럼이 있으면 현재 시간으로 업데이트 + const hasUpdatedDate = columns.includes("updated_date"); + const finalUpdateSet = hasUpdatedDate + ? `${updateSet}, updated_date = NOW()` + : updateSet; + + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictKey}) + DO UPDATE SET ${finalUpdateSet}`; } + } else { + // INSERT 모드: 기존 방식 + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; + } - return operationResult; - }); - + await query(queryStr, values); successCount++; - } catch (error) { - console.error(`레코드 UPSERT 실패:`, error); + } catch (insertError) { + console.error( + `내부 DB 데이터 ${saveMode} 실패 (${tableName}):`, + insertError + ); failedCount++; } } - console.log( - `[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { - // 외부 DB에 데이터 삽입 + // 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원) + if (saveMode === "UPSERT") { + console.warn( + `[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.` + ); + } + const result = await BatchExternalDbService.insertDataToTable( connectionId, tableName, data ); + if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개` - ); return result.data; } else { console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + // 실패 시 전체 실패로 간주하지 않고 0/전체 로 반환 return { successCount: 0, failedCount: data.length }; } } else { - console.log(`[BatchService] 연결 정보 디버그:`, { - connectionType, - connectionId, - }); throw new Error( `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` ); } } catch (error) { - console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); - throw error; + console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error); + return { successCount: 0, failedCount: data ? data.length : 0 }; } } - - /** - * 배치 매핑 유효성 검사 - */ - private static async validateBatchMappings( - mappings: BatchMapping[] - ): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (!mappings || mappings.length === 0) { - errors.push("최소 하나 이상의 매핑이 필요합니다."); - return { isValid: false, errors, warnings }; - } - - // n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지) - const toMappings = new Map(); - - mappings.forEach((mapping, index) => { - const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`; - - if (toMappings.has(toKey)) { - errors.push( - `매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.` - ); - } else { - toMappings.set(toKey, index); - } - }); - - // 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑) - const fromMappings = new Map(); - - mappings.forEach((mapping, index) => { - const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}:${mapping.from_column_name}`; - - if (!fromMappings.has(fromKey)) { - fromMappings.set(fromKey, []); - } - fromMappings.get(fromKey)!.push(index); - }); - - fromMappings.forEach((indices, fromKey) => { - if (indices.length > 1) { - const [, , tableName, columnName] = fromKey.split(":"); - warnings.push( - `FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)` - ); - } - }); - - return { - isValid: errors.length === 0, - errors, - warnings, - }; - } } diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index fd85248d..a1a494f2 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1,12 +1,12 @@ /** * 동적 데이터 서비스 - * + * * 주요 특징: * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 - * + * * 보안: * - 테이블명은 영문, 숫자, 언더스코어만 허용 * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 @@ -70,11 +70,11 @@ class DataService { // 그룹별로 데이터 분류 const groups: Record = {}; - + 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> = {}; - + 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(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>, userCompany?: string, userId?: string - ): Promise> { + ): 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 = {}; 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 = { - ...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]}`); } } diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index c40037bb..7ec95626 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,4 +1,4 @@ -import { query, queryOne, transaction } from "../database/db"; +import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -103,12 +103,16 @@ export class DynamicFormService { if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { // DATE 타입이면 문자열 그대로 유지 if (lowerDataType === "date") { - console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`); + console.log( + `📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)` + ); return value; // 문자열 그대로 반환 } // TIMESTAMP 타입이면 Date 객체로 변환 else { - console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`); + console.log( + `📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)` + ); return new Date(value + "T00:00:00"); } } @@ -250,7 +254,8 @@ export class DynamicFormService { if (tableColumns.includes("regdate") && !dataToInsert.regdate) { dataToInsert.regdate = new Date(); } - if (tableColumns.includes("created_date") && !dataToInsert.created_date) { + // created_date는 항상 현재 시간으로 설정 (기존 값 무시) + if (tableColumns.includes("created_date")) { dataToInsert.created_date = new Date(); } if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) { @@ -313,7 +318,9 @@ export class DynamicFormService { } // YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장) else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) { - console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`); + console.log( + `📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)` + ); // dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식) } } @@ -346,35 +353,37 @@ export class DynamicFormService { ) { try { parsedArray = JSON.parse(value); - console.log( + console.log( `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` - ); + ); } catch (parseError) { console.log(`⚠️ JSON 파싱 실패: ${key}`); } } // 파싱된 배열이 있으면 처리 - if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { - // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) - // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 - let targetTable: string | undefined; - let actualData = parsedArray; + if ( + parsedArray && + Array.isArray(parsedArray) && + parsedArray.length > 0 + ) { + // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) + // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 + let targetTable: string | undefined; + let actualData = parsedArray; - // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) - if (parsedArray[0] && parsedArray[0]._targetTable) { - targetTable = parsedArray[0]._targetTable; - actualData = parsedArray.map( - ({ _targetTable, ...item }) => item - ); - } + // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) + if (parsedArray[0] && parsedArray[0]._targetTable) { + targetTable = parsedArray[0]._targetTable; + actualData = parsedArray.map(({ _targetTable, ...item }) => item); + } - repeaterData.push({ - data: actualData, - targetTable, - componentId: key, - }); - delete dataToInsert[key]; // 원본 배열 데이터는 제거 + repeaterData.push({ + data: actualData, + targetTable, + componentId: key, + }); + delete dataToInsert[key]; // 원본 배열 데이터는 제거 console.log(`✅ Repeater 데이터 추가: ${key}`, { targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", @@ -387,8 +396,8 @@ export class DynamicFormService { // 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장 const separateRepeaterData: typeof repeaterData = []; const mergedRepeaterData: typeof repeaterData = []; - - repeaterData.forEach(repeater => { + + repeaterData.forEach((repeater) => { if (repeater.targetTable && repeater.targetTable !== tableName) { // 다른 테이블: 나중에 별도 저장 separateRepeaterData.push(repeater); @@ -397,10 +406,10 @@ export class DynamicFormService { mergedRepeaterData.push(repeater); } }); - + console.log(`🔄 Repeater 데이터 분류:`, { separate: separateRepeaterData.length, // 별도 테이블 - merged: mergedRepeaterData.length, // 메인 테이블과 병합 + merged: mergedRepeaterData.length, // 메인 테이블과 병합 }); // 존재하지 않는 컬럼 제거 @@ -494,44 +503,75 @@ export class DynamicFormService { const clientIp = ipAddress || "unknown"; let result: any[]; - + // 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT if (mergedRepeaterData.length > 0) { - console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`); - + console.log( + `🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장` + ); + result = []; - + for (const repeater of mergedRepeaterData) { for (const item of repeater.data) { // 헤더 + 품목을 병합 - const rawMergedData = { ...dataToInsert, ...item }; - + // item에서 created_date 제거 (dataToInsert의 현재 시간 유지) + const { created_date: _, ...itemWithoutCreatedDate } = item; + const rawMergedData = { + ...dataToInsert, + ...itemWithoutCreatedDate, + }; + + // 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함 + // _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE) + // 그 외의 경우는 모두 새 레코드로 처리 (INSERT) + const isExistingRecord = rawMergedData._existingRecord === true; + + if (!isExistingRecord) { + // 새 레코드: id 제거하여 새 UUID 자동 생성 + const oldId = rawMergedData.id; + delete rawMergedData.id; + console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`); + } else { + console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`); + } + + // 메타 플래그 제거 + delete rawMergedData._isNewItem; + delete rawMergedData._existingRecord; + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) const validColumnNames = columnInfo.map((col) => col.column_name); const mergedData: Record = {}; - + Object.keys(rawMergedData).forEach((columnName) => { // 실제 테이블 컬럼인지 확인 if (validColumnNames.includes(columnName)) { - const column = columnInfo.find((col) => col.column_name === columnName); - if (column) { - // 타입 변환 - mergedData[columnName] = this.convertValueForPostgreSQL( - rawMergedData[columnName], - column.data_type + const column = columnInfo.find( + (col) => col.column_name === columnName ); + if (column) { + // 타입 변환 + mergedData[columnName] = this.convertValueForPostgreSQL( + rawMergedData[columnName], + column.data_type + ); } else { mergedData[columnName] = rawMergedData[columnName]; } } else { - console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); + console.log( + `⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})` + ); } }); - + const mergedColumns = Object.keys(mergedData); const mergedValues: any[] = Object.values(mergedData); - const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", "); - + const mergedPlaceholders = mergedValues + .map((_, index) => `$${index + 1}`) + .join(", "); + let mergedUpsertQuery: string; if (primaryKeys.length > 0) { const conflictColumns = primaryKeys.join(", "); @@ -539,7 +579,7 @@ export class DynamicFormService { .filter((col) => !primaryKeys.includes(col)) .map((col) => `${col} = EXCLUDED.${col}`) .join(", "); - + mergedUpsertQuery = updateSet ? `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) VALUES (${mergedPlaceholders}) @@ -556,20 +596,20 @@ export class DynamicFormService { VALUES (${mergedPlaceholders}) RETURNING *`; } - + console.log(`📝 병합 INSERT:`, { mergedData }); - + const itemResult = await transaction(async (client) => { await client.query(`SET LOCAL app.user_id = '${userId}'`); await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); const res = await client.query(mergedUpsertQuery, mergedValues); return res.rows[0]; }); - + result.push(itemResult); } } - + console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`); } else { // 일반 모드: 헤더만 저장 @@ -579,7 +619,7 @@ export class DynamicFormService { const res = await client.query(upsertQuery, values); return res.rows; }); - + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); } @@ -714,12 +754,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (새로 추가) try { + // savedData 또는 insertedRecord에서 company_code 추출 + const recordCompanyCode = + (insertedRecord as Record)?.company_code || + dataToInsert.company_code || + "*"; + await this.executeDataflowControlIfConfigured( screenId, tableName, insertedRecord as Record, "insert", - created_by || "system" + created_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -746,7 +793,7 @@ export class DynamicFormService { * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) */ async updateFormDataPartial( - id: number, + id: string | number, // 🔧 UUID 문자열도 지원 tableName: string, originalData: Record, newData: Record @@ -825,10 +872,10 @@ export class DynamicFormService { FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' `; - const columnTypesResult = await query<{ column_name: string; data_type: string }>( - columnTypesQuery, - [tableName] - ); + const columnTypesResult = await query<{ + column_name: string; + data_type: string; + }>(columnTypesQuery, [tableName]); const columnTypes: Record = {}; columnTypesResult.forEach((row) => { columnTypes[row.column_name] = row.data_type; @@ -841,12 +888,24 @@ export class DynamicFormService { .map((key, index) => { const dataType = columnTypes[key]; // 숫자 타입인 경우 명시적 캐스팅 - if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { + if ( + dataType === "integer" || + dataType === "bigint" || + dataType === "smallint" + ) { return `${key} = $${index + 1}::integer`; - } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { + } else if ( + dataType === "numeric" || + dataType === "decimal" || + dataType === "real" || + dataType === "double precision" + ) { return `${key} = $${index + 1}::numeric`; - } else if (dataType === 'boolean') { + } else if (dataType === "boolean") { return `${key} = $${index + 1}::boolean`; + } else if (dataType === "jsonb" || dataType === "json") { + // 🆕 JSONB/JSON 타입은 명시적 캐스팅 + return `${key} = $${index + 1}::jsonb`; } else { // 문자열 타입은 캐스팅 불필요 return `${key} = $${index + 1}`; @@ -854,18 +913,36 @@ export class DynamicFormService { }) .join(", "); - const values: any[] = Object.values(changedFields); + // 🆕 JSONB 타입 값은 JSON 문자열로 변환 + const values: any[] = Object.keys(changedFields).map((key) => { + const value = changedFields[key]; + const dataType = columnTypes[key]; + + // JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환 + if ( + (dataType === "jsonb" || dataType === "json") && + (Array.isArray(value) || + (typeof value === "object" && value !== null)) + ) { + return JSON.stringify(value); + } + return value; + }); values.push(id); // WHERE 조건용 ID 추가 // 🔑 Primary Key 타입에 맞게 캐스팅 const pkDataType = columnTypes[primaryKeyColumn]; - let pkCast = ''; - if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') { - pkCast = '::integer'; - } else if (pkDataType === 'numeric' || pkDataType === 'decimal') { - pkCast = '::numeric'; - } else if (pkDataType === 'uuid') { - pkCast = '::uuid'; + let pkCast = ""; + if ( + pkDataType === "integer" || + pkDataType === "bigint" || + pkDataType === "smallint" + ) { + pkCast = "::integer"; + } else if (pkDataType === "numeric" || pkDataType === "decimal") { + pkCast = "::numeric"; + } else if (pkDataType === "uuid") { + pkCast = "::uuid"; } // text, varchar 등은 캐스팅 불필요 @@ -1054,12 +1131,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (UPDATE 트리거) try { + // updatedRecord에서 company_code 추출 + const recordCompanyCode = + (updatedRecord as Record)?.company_code || + company_code || + "*"; + await this.executeDataflowControlIfConfigured( 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, "update", - updated_by || "system" + updated_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1160,7 +1244,15 @@ export class DynamicFormService { console.log("📝 실행할 DELETE SQL:", deleteQuery); console.log("📊 SQL 파라미터:", [id]); - const result = await query(deleteQuery, [id]); + // 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용) + const result = await transaction(async (client) => { + // 이력 트리거에서 사용할 사용자 정보 설정 + if (userId) { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + } + const res = await client.query(deleteQuery, [id]); + return res.rows; + }); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); @@ -1190,12 +1282,17 @@ export class DynamicFormService { try { if (result && Array.isArray(result) && result.length > 0) { const deletedRecord = result[0] as Record; + // deletedRecord에서 company_code 추출 + const recordCompanyCode = + deletedRecord?.company_code || companyCode || "*"; + await this.executeDataflowControlIfConfigured( 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, "delete", - userId || "system" + userId || "system", + recordCompanyCode ); } } catch (controlError) { @@ -1495,13 +1592,15 @@ export class DynamicFormService { /** * 제어관리 실행 (화면에 설정된 경우) + * 다중 제어를 순서대로 순차 실행 지원 */ private async executeDataflowControlIfConfigured( screenId: number, tableName: string, savedData: Record, triggerType: "insert" | "update" | "delete", - userId: string = "system" + userId: string = "system", + companyCode: string = "*" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); @@ -1530,99 +1629,72 @@ export class DynamicFormService { componentId: layout.component_id, componentType: properties?.componentType, actionType: properties?.componentConfig?.action?.type, - enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl, + enableDataflowControl: + properties?.webTypeConfig?.enableDataflowControl, hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, - hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, + hasDiagramId: + !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, + hasFlowControls: + !!properties?.webTypeConfig?.dataflowConfig?.flowControls, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 if ( properties?.componentType === "button-primary" && properties?.componentConfig?.action?.type === "save" && - properties?.webTypeConfig?.enableDataflowControl === true && - properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId + properties?.webTypeConfig?.enableDataflowControl === true ) { - controlConfigFound = true; - const diagramId = - properties.webTypeConfig.dataflowConfig.selectedDiagramId; - const relationshipId = - properties.webTypeConfig.dataflowConfig.selectedRelationshipId; + const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; - console.log(`🎯 제어관리 설정 발견:`, { - componentId: layout.component_id, - diagramId, - relationshipId, - triggerType, - }); + // 다중 제어 설정 확인 (flowControls 배열) + const flowControls = dataflowConfig?.flowControls || []; - // 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) - let controlResult: any; - - if (!relationshipId) { - // 노드 플로우 실행 - console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); - const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); - - const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, { - sourceData: [savedData], - dataSourceType: "formData", - buttonId: "save-button", - screenId: screenId, - userId: userId, - formData: savedData, - }); - - controlResult = { - success: executionResult.success, - message: executionResult.message, - executedActions: executionResult.nodes?.map((node) => ({ - nodeId: node.nodeId, - status: node.status, - duration: node.duration, - })), - errors: executionResult.nodes - ?.filter((node) => node.status === "failed") - .map((node) => node.error || "실행 실패"), - }; - } else { - // 관계 기반 제어관리 실행 - console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`); - controlResult = await this.dataflowControlService.executeDataflowControl( + // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행 + if (flowControls.length > 0) { + controlConfigFound = true; + console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`); + + // 순서대로 정렬 + const sortedControls = [...flowControls].sort( + (a: any, b: any) => (a.order || 0) - (b.order || 0) + ); + + // 다중 제어 순차 실행 + await this.executeMultipleFlowControls( + sortedControls, + savedData, + screenId, + tableName, + triggerType, + userId, + companyCode + ); + } else if (dataflowConfig?.selectedDiagramId) { + // 기존 단일 제어 실행 (하위 호환성) + controlConfigFound = true; + const diagramId = dataflowConfig.selectedDiagramId; + const relationshipId = dataflowConfig.selectedRelationshipId; + + console.log(`🎯 단일 제어관리 설정 발견:`, { + componentId: layout.component_id, diagramId, relationshipId, triggerType, + }); + + await this.executeSingleFlowControl( + diagramId, + relationshipId, savedData, + screenId, tableName, - userId + triggerType, + userId, + companyCode ); } - console.log(`🎯 제어관리 실행 결과:`, controlResult); - - if (controlResult.success) { - console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); - if ( - controlResult.executedActions && - controlResult.executedActions.length > 0 - ) { - console.log(`📊 실행된 액션들:`, controlResult.executedActions); - } - - // 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패) - if (controlResult.errors && controlResult.errors.length > 0) { - console.warn( - `⚠️ 제어관리 실행 중 일부 오류 발생:`, - controlResult.errors - ); - // 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능 - // 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행 - } - } else { - console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); - // 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음 - } - - // 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우) + // 첫 번째 설정된 버튼의 제어관리만 실행 break; } } @@ -1635,6 +1707,502 @@ export class DynamicFormService { // 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해 } } + + /** + * 다중 제어 순차 실행 + */ + private async executeMultipleFlowControls( + flowControls: Array<{ + id: string; + flowId: number; + flowName: string; + executionTiming: string; + order: number; + }>, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}개`); + + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const results: Array<{ + order: number; + flowId: number; + flowName: string; + success: boolean; + message: string; + duration: number; + }> = []; + + for (let i = 0; i < flowControls.length; i++) { + const control = flowControls[i]; + const startTime = Date.now(); + + console.log( + `\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})` + ); + + try { + // 유효하지 않은 flowId 스킵 + if (!control.flowId || control.flowId <= 0) { + console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`); + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: "유효하지 않은 flowId", + duration: 0, + }); + continue; + } + + const executionResult = await NodeFlowExecutionService.executeFlow( + control.flowId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + const duration = Date.now() - startTime; + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: executionResult.success, + message: executionResult.message, + duration, + }); + + if (executionResult.success) { + console.log( + `✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)` + ); + } else { + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}` + ); + // 이전 제어 실패 시 다음 제어 실행 중단 + console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`); + break; + } + } catch (error: any) { + const duration = Date.now() - startTime; + console.error( + `❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`, + error + ); + + results.push({ + order: control.order, + flowId: control.flowId, + flowName: control.flowName, + success: false, + message: error.message || "실행 오류", + duration, + }); + + // 오류 발생 시 다음 제어 실행 중단 + console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`); + break; + } + } + + // 실행 결과 요약 + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); + + console.log(`\n📊 다중 제어 실행 완료:`, { + total: flowControls.length, + executed: results.length, + success: successCount, + failed: failCount, + totalDuration: `${totalDuration}ms`, + }); + } + + /** + * 단일 제어 실행 (기존 로직, 하위 호환성) + */ + private async executeSingleFlowControl( + diagramId: number, + relationshipId: string | null, + savedData: Record, + screenId: number, + tableName: string, + triggerType: "insert" | "update" | "delete", + userId: string, + companyCode: string + ): Promise { + let controlResult: any; + + if (!relationshipId) { + // 노드 플로우 실행 + console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const executionResult = await NodeFlowExecutionService.executeFlow( + diagramId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + + controlResult = { + success: executionResult.success, + message: executionResult.message, + executedActions: executionResult.nodes?.map((node) => ({ + nodeId: node.nodeId, + status: node.status, + duration: node.duration, + })), + errors: executionResult.nodes + ?.filter((node) => node.status === "failed") + .map((node) => node.error || "실행 실패"), + }; + } else { + // 관계 기반 제어관리 실행 + console.log( + `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` + ); + controlResult = await this.dataflowControlService.executeDataflowControl( + diagramId, + relationshipId, + triggerType, + savedData, + tableName, + userId + ); + } + + console.log(`🎯 제어관리 실행 결과:`, controlResult); + + if (controlResult.success) { + console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`); + if ( + controlResult.executedActions && + controlResult.executedActions.length > 0 + ) { + console.log(`📊 실행된 액션들:`, controlResult.executedActions); + } + + if (controlResult.errors && controlResult.errors.length > 0) { + console.warn( + `⚠️ 제어관리 실행 중 일부 오류 발생:`, + controlResult.errors + ); + } + } else { + console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`); + } + } + + /** + * 특정 테이블의 특정 필드 값만 업데이트 + * (다른 테이블의 레코드 업데이트 지원) + */ + async updateFieldValue( + tableName: string, + keyField: string, + keyValue: any, + updateField: string, + updateValue: any, + companyCode: string, + userId: string + ): Promise<{ affectedRows: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("🔄 [updateFieldValue] 업데이트 실행:", { + tableName, + keyField, + keyValue, + updateField, + updateValue, + companyCode, + }); + + // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인) + const columnQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') + `; + const columnResult = await client.query(columnQuery, [tableName]); + const existingColumns = columnResult.rows.map( + (row: any) => row.column_name + ); + + const hasUpdatedBy = existingColumns.includes("updated_by"); + const hasUpdatedAt = existingColumns.includes("updated_at"); + const hasCompanyCode = existingColumns.includes("company_code"); + + console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { + hasUpdatedBy, + hasUpdatedAt, + hasCompanyCode, + }); + + // 동적 SET 절 구성 + let setClause = `"${updateField}" = $1`; + const params: any[] = [updateValue]; + let paramIndex = 2; + + if (hasUpdatedBy) { + setClause += `, updated_by = $${paramIndex}`; + params.push(userId); + paramIndex++; + } + + if (hasUpdatedAt) { + setClause += `, updated_at = NOW()`; + } + + // WHERE 절 구성 + let whereClause = `"${keyField}" = $${paramIndex}`; + params.push(keyValue); + paramIndex++; + + // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만) + if (hasCompanyCode && companyCode && companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + const sqlQuery = ` + UPDATE "${tableName}" + SET ${setClause} + WHERE ${whereClause} + `; + + console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery); + console.log("🔍 [updateFieldValue] 파라미터:", params); + + const result = await client.query(sqlQuery, params); + + console.log("✅ [updateFieldValue] 결과:", { + affectedRows: result.rowCount, + }); + + return { affectedRows: result.rowCount || 0 }; + } catch (error) { + console.error("❌ [updateFieldValue] 오류:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 위치 이력 저장 (연속 위치 추적용) + */ + async saveLocationHistory(data: { + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + altitude?: number; + speed?: number; + heading?: number; + tripId?: string; + tripStatus?: string; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + recordedAt?: string; + vehicleId?: number; + }): Promise<{ id: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [saveLocationHistory] 저장 시작:", data); + + const sqlQuery = ` + INSERT INTO vehicle_location_history ( + user_id, + company_code, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + vehicle_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING id + `; + + const params = [ + data.userId, + data.companyCode, + data.latitude, + data.longitude, + data.accuracy || null, + data.altitude || null, + data.speed || null, + data.heading || null, + data.tripId || null, + data.tripStatus || "active", + data.departure || null, + data.arrival || null, + data.departureName || null, + data.destinationName || null, + data.recordedAt ? new Date(data.recordedAt) : new Date(), + data.vehicleId || null, + ]; + + const result = await client.query(sqlQuery, params); + + console.log("✅ [saveLocationHistory] 저장 완료:", { + id: result.rows[0]?.id, + }); + + return { id: result.rows[0]?.id }; + } catch (error) { + console.error("❌ [saveLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 위치 이력 조회 (경로 조회용) + */ + async getLocationHistory(params: { + companyCode: string; + tripId?: string; + userId?: string; + startDate?: string; + endDate?: string; + limit?: number; + }): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [getLocationHistory] 조회 시작:", params); + + const conditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 멀티테넌시: company_code 필터 + if (params.companyCode && params.companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + queryParams.push(params.companyCode); + paramIndex++; + } + + // trip_id 필터 + if (params.tripId) { + conditions.push(`trip_id = $${paramIndex}`); + queryParams.push(params.tripId); + paramIndex++; + } + + // user_id 필터 + if (params.userId) { + conditions.push(`user_id = $${paramIndex}`); + queryParams.push(params.userId); + paramIndex++; + } + + // 날짜 범위 필터 + if (params.startDate) { + conditions.push(`recorded_at >= $${paramIndex}`); + queryParams.push(new Date(params.startDate)); + paramIndex++; + } + + if (params.endDate) { + conditions.push(`recorded_at <= $${paramIndex}`); + queryParams.push(new Date(params.endDate)); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; + + const sqlQuery = ` + SELECT + id, + user_id, + vehicle_id, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + created_at, + company_code + FROM vehicle_location_history + ${whereClause} + ORDER BY recorded_at ASC + ${limitClause} + `; + + console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery); + console.log("🔍 [getLocationHistory] 파라미터:", queryParams); + + const result = await client.query(sqlQuery, queryParams); + + console.log("✅ [getLocationHistory] 조회 완료:", { + count: result.rowCount, + }); + + return result.rows; + } catch (error) { + console.error("❌ [getLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } } // 싱글톤 인스턴스 생성 및 export diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index a8f6c482..5557d8b5 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -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"); diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 940787c3..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -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 { 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 { + 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 { 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 { - 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; } diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 99164ae1..410e8daf 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -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( - `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 ); diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 28eac869..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -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 = 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> { + const headers: Record = {}; + + 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 = 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 { 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> { + 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; + 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 : "알 수 없는 오류", + }, + }; + } + } } diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 39ab6013..09058502 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -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); diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 759178c1..80c920ad 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -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, diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 966842b8..bbabb935 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -263,4 +263,139 @@ export class FlowExecutionService { tableName: result[0].table_name, }; } + + /** + * 스텝 데이터 업데이트 (인라인 편집) + * 원본 테이블의 데이터를 직접 업데이트합니다. + */ + async updateStepData( + flowId: number, + stepId: number, + recordId: string, + updateData: Record, + 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; + } + } } diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index b4dce503..4e44006a 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -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 += ``; break; case 'image': @@ -348,6 +351,89 @@ class MailSendSimpleService { case 'spacer': html += `
`; break; + case 'header': + html += ` +
+ + + + + +
+ ${component.logoSrc ? `로고` : ''} + ${component.brandName || ''} + + ${component.sendDate || ''} +
+
+ `; + break; + case 'infoTable': + html += ` +
+ ${component.tableTitle ? `
${component.tableTitle}
` : ''} + + ${(component.rows || []).map((row: any, i: number) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case 'alertBox': + const alertColors: Record = { + 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 += ` +
+ ${component.alertTitle ? `
${component.alertTitle}
` : ''} +
${component.content || ''}
+
+ `; + break; + case 'divider': + html += `
`; + break; + case 'footer': + html += ` +
+ ${component.companyName ? `
${component.companyName}
` : ''} + ${(component.ceoName || component.businessNumber) ? ` +
+ ${component.ceoName ? `대표: ${component.ceoName}` : ''} + ${component.ceoName && component.businessNumber ? ' | ' : ''} + ${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''} +
+ ` : ''} + ${component.address ? `
${component.address}
` : ''} + ${(component.phone || component.email) ? ` +
+ ${component.phone ? `Tel: ${component.phone}` : ''} + ${component.phone && component.email ? ' | ' : ''} + ${component.email ? `Email: ${component.email}` : ''} +
+ ` : ''} + ${component.copyright ? `
${component.copyright}
` : ''} +
+ `; + break; + case 'numberedList': + html += ` +
+ ${component.listTitle ? `
${component.listTitle}
` : ''} +
    + ${(component.listItems || []).map((item: string) => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index adb72fff..bd82a7d2 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -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; + // 헤더 컴포넌트용 + 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 += `
`; break; + case "header": + html += ` +
+ + + + + +
+ ${comp.logoSrc ? `로고` : ''} + ${comp.brandName || ''} + + ${comp.sendDate || ''} +
+
+ `; + break; + case "infoTable": + html += ` +
+ ${comp.tableTitle ? `
${comp.tableTitle}
` : ''} + + ${(comp.rows || []).map((row, i) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case "alertBox": + const alertColors: Record = { + 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 += ` +
+ ${comp.alertTitle ? `
${comp.alertTitle}
` : ''} +
${comp.content || ''}
+
+ `; + break; + case "divider": + html += `
`; + break; + case "footer": + html += ` +
+ ${comp.companyName ? `
${comp.companyName}
` : ''} + ${(comp.ceoName || comp.businessNumber) ? ` +
+ ${comp.ceoName ? `대표: ${comp.ceoName}` : ''} + ${comp.ceoName && comp.businessNumber ? ' | ' : ''} + ${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''} +
+ ` : ''} + ${comp.address ? `
${comp.address}
` : ''} + ${(comp.phone || comp.email) ? ` +
+ ${comp.phone ? `Tel: ${comp.phone}` : ''} + ${comp.phone && comp.email ? ' | ' : ''} + ${comp.email ? `Email: ${comp.email}` : ''} +
+ ` : ''} + ${comp.copyright ? `
${comp.copyright}
` : ''} +
+ `; + break; + case "numberedList": + html += ` +
+ ${comp.listTitle ? `
${comp.listTitle}
` : ''} +
    + ${(comp.listItems || []).map(item => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 7d969b06..a0e707c1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,10 +10,6 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; - copiedCategories: number; - copiedCodes: number; - copiedCategorySettings: number; - copiedNumberingRules: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -57,6 +53,7 @@ interface ScreenDefinition { layout_metadata: any; db_source_type: string | null; db_connection_id: number | null; + source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** @@ -129,35 +126,6 @@ interface FlowStepConnection { label: string | null; } -/** - * 코드 카테고리 - */ -interface CodeCategory { - category_code: string; - category_name: string; - category_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - -/** - * 코드 정보 - */ -interface CodeInfo { - code_category: string; - code_value: string; - code_name: string; - code_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - /** * 메뉴 복사 서비스 */ @@ -249,6 +217,45 @@ export class MenuCopyService { } } } + + // 3) 탭 컴포넌트 (tabs 배열 내부의 screenId) + if ( + props?.componentConfig?.tabs && + Array.isArray(props.componentConfig.tabs) + ) { + for (const tab of props.componentConfig.tabs) { + if (tab.screenId) { + const screenId = tab.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + } + } + } + } + + // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) + if (props?.componentConfig?.leftScreenId) { + const leftScreenId = props.componentConfig.leftScreenId; + const numId = + typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.rightScreenId) { + const rightScreenId = props.componentConfig.rightScreenId; + const numId = + typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); + } + } } return referenced; @@ -355,127 +362,6 @@ export class MenuCopyService { return flowIds; } - /** - * 코드 수집 - */ - private async collectCodes( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { - logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); - - const categories: CodeCategory[] = []; - const codes: CodeInfo[] = []; - - for (const menuObjid of menuObjids) { - // 코드 카테고리 - const catsResult = await client.query( - `SELECT * FROM code_category - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - categories.push(...catsResult.rows); - - // 각 카테고리의 코드 정보 - for (const cat of catsResult.rows) { - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, - [cat.category_code, menuObjid, sourceCompanyCode] - ); - codes.push(...codesResult.rows); - } - } - - logger.info( - `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` - ); - return { categories, codes }; - } - - /** - * 카테고리 설정 수집 - */ - private async collectCategorySettings( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - columnMappings: any[]; - categoryValues: any[]; - }> { - logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`); - - const columnMappings: any[] = []; - const categoryValues: any[] = []; - - // 카테고리 컬럼 매핑 (메뉴별 + 공통) - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - columnMappings.push(...mappingsResult.rows); - - // 테이블 컬럼 카테고리 값 (메뉴별 + 공통) - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - categoryValues.push(...valuesResult.rows); - - logger.info( - `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)` - ); - return { columnMappings, categoryValues }; - } - - /** - * 채번 규칙 수집 - */ - private async collectNumberingRules( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - rules: any[]; - parts: any[]; - }> { - logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`); - - const rules: any[] = []; - const parts: any[] = []; - - for (const menuObjid of menuObjids) { - // 채번 규칙 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - rules.push(...rulesResult.rows); - - // 각 규칙의 파트 - for (const rule of rulesResult.rows) { - const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts - WHERE rule_id = $1 AND company_code = $2`, - [rule.rule_id, sourceCompanyCode] - ); - parts.push(...partsResult.rows); - } - } - - logger.info( - `✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개` - ); - return { rules, parts }; - } - /** * 다음 메뉴 objid 생성 */ @@ -567,14 +453,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || - key === "targetScreenId" + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 @@ -709,42 +597,8 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 채번 규칙 파트 삭제 - await client.query( - `DELETE FROM numbering_rule_parts - WHERE rule_id IN ( - SELECT rule_id FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2 - )`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 파트 삭제 완료`); - - // 5-6. 채번 규칙 삭제 - await client.query( - `DELETE FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 삭제 완료`); - - // 5-7. 테이블 컬럼 카테고리 값 삭제 - await client.query( - `DELETE FROM table_column_category_values - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 값 삭제 완료`); - - // 5-8. 카테고리 컬럼 매핑 삭제 - await client.query( - `DELETE FROM category_column_mapping - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 매핑 삭제 완료`); - - // 5-9. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ existingMenus[i].objid, @@ -801,33 +655,11 @@ export class MenuCopyService { const flowIds = await this.collectFlows(screenIds, client); - const codes = await this.collectCodes( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const categorySettings = await this.collectCategorySettings( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const numberingRules = await this.collectNumberingRules( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - logger.info(` 📊 수집 완료: - 메뉴: ${menus.length}개 - 화면: ${screenIds.size}개 - 플로우: ${flowIds.size}개 - - 코드 카테고리: ${codes.categories.length}개 - - 코드: ${codes.codes.length}개 - - 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개 - - 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개 `); // === 2단계: 플로우 복사 === @@ -871,30 +703,6 @@ export class MenuCopyService { client ); - // === 6단계: 코드 복사 === - logger.info("\n📋 [6단계] 코드 복사"); - await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); - - // === 7단계: 카테고리 설정 복사 === - logger.info("\n📂 [7단계] 카테고리 설정 복사"); - await this.copyCategorySettings( - categorySettings, - menuIdMap, - targetCompanyCode, - userId, - client - ); - - // === 8단계: 채번 규칙 복사 === - logger.info("\n📋 [8단계] 채번 규칙 복사"); - await this.copyNumberingRules( - numberingRules, - menuIdMap, - targetCompanyCode, - userId, - client - ); - // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -904,13 +712,6 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, - copiedCategories: codes.categories.length, - copiedCodes: codes.codes.length, - copiedCategorySettings: - categorySettings.columnMappings.length + - categorySettings.categoryValues.length, - copiedNumberingRules: - numberingRules.rules.length + numberingRules.parts.length, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -923,10 +724,8 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - 코드 카테고리: ${result.copiedCategories}개 - - 코드: ${result.copiedCodes}개 - - 카테고리 설정: ${result.copiedCategorySettings}개 - - 채번 규칙: ${result.copiedNumberingRules}개 + + ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. ============================================ `); @@ -1081,7 +880,10 @@ export class MenuCopyService { } /** - * 화면 복사 + * 화면 복사 (업데이트 또는 신규 생성) + * - source_screen_id로 기존 복사본 찾기 + * - 변경된 내용이 있으면 업데이트 + * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, @@ -1101,18 +903,19 @@ export class MenuCopyService { return screenIdMap; } - logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); - // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; - newScreenId: number; + targetScreenId: number; screenDef: ScreenDefinition; + isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; for (const originalScreenId of screenIds) { try { - // 1) screen_definitions 조회 + // 1) 원본 screen_definitions 조회 const screenDefResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1`, [originalScreenId] @@ -1125,104 +928,198 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 새 screen_code 생성 - const newScreenCode = await this.generateUniqueScreenCode( - targetCompanyCode, - client + // 2) 기존 복사본 찾기: source_screen_id로 검색 + const existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [originalScreenId, targetCompanyCode] ); - // 2-1) 화면명 변환 적용 + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { - // 1. 제거할 텍스트 제거 if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); - transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + transformedScreenName = transformedScreenName.trim(); } - - // 2. 접두사 추가 if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } - // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) - const newScreenResult = await client.query<{ screen_id: number }>( - `INSERT INTO screen_definitions ( - screen_name, screen_code, table_name, company_code, - description, is_active, layout_metadata, - db_source_type, db_connection_id, created_by, - deleted_date, deleted_by, delete_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING screen_id`, - [ - transformedScreenName, // 변환된 화면명 - newScreenCode, // 새 화면 코드 - screenDef.table_name, - targetCompanyCode, // 새 회사 코드 - screenDef.description, - screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 - screenDef.layout_metadata, - screenDef.db_source_type, - screenDef.db_connection_id, - userId, - null, // deleted_date: NULL (새 화면은 삭제되지 않음) - null, // deleted_by: NULL - null, // delete_reason: NULL - ] - ); + if (existingCopyResult.rows.length > 0) { + // === 기존 복사본이 있는 경우: 업데이트 === + const existingScreen = existingCopyResult.rows[0]; + const existingScreenId = existingScreen.screen_id; - const newScreenId = newScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, newScreenId); + // 원본 레이아웃 조회 + const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); - logger.info( - ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` - ); + // 대상 레이아웃 조회 + const targetLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [existingScreenId] + ); - // 저장해서 2단계에서 처리 - screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) + const hasChanges = this.hasLayoutChanges( + sourceLayoutsResult.rows, + targetLayoutsResult.rows + ); + + if (hasChanges) { + // 변경 사항이 있으면 업데이트 + logger.info( + ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + + // screen_definitions 업데이트 + await client.query( + `UPDATE screen_definitions SET + screen_name = $1, + table_name = $2, + description = $3, + is_active = $4, + layout_metadata = $5, + db_source_type = $6, + db_connection_id = $7, + updated_by = $8, + updated_date = NOW() + WHERE screen_id = $9`, + [ + transformedScreenName, + screenDef.table_name, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + existingScreenId, + ] + ); + + screenIdMap.set(originalScreenId, existingScreenId); + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: existingScreenId, + screenDef, + isUpdate: true, + }); + } else { + // 변경 사항이 없으면 스킵 + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + } + } else { + // === 기존 복사본이 없는 경우: 신규 생성 === + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason, source_screen_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING screen_id`, + [ + transformedScreenName, + newScreenCode, + screenDef.table_name, + targetCompanyCode, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, + null, + null, + originalScreenId, // source_screen_id 저장 + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: newScreenId, + screenDef, + isUpdate: false, + }); + } } catch (error: any) { logger.error( - `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, - newScreenId, + targetScreenId, screenDef, + isUpdate, } of screenDefsToProcess) { try { - // screen_layouts 복사 + // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); - // 1단계: component_id 매핑 생성 (원본 → 새 ID) + if (isUpdate) { + // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = $1`, + [targetScreenId] + ); + logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); + } + + // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); for (const layout of layoutsResult.rows) { const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; componentIdMap.set(layout.component_id, newComponentId); } - // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_id)!; - // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) const newParentId = layout.parent_id ? componentIdMap.get(layout.parent_id) || layout.parent_id : null; @@ -1230,7 +1127,6 @@ export class MenuCopyService { ? componentIdMap.get(layout.zone_id) || layout.zone_id : null; - // properties 내부 참조 업데이트 const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, @@ -1244,38 +1140,94 @@ export class MenuCopyService { display_order, layout_type, layout_config, zones_config, zone_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ - newScreenId, // 새 화면 ID + targetScreenId, layout.component_type, - newComponentId, // 새 컴포넌트 ID - newParentId, // 매핑된 parent_id + newComponentId, + newParentId, layout.position_x, layout.position_y, layout.width, layout.height, - updatedProperties, // 업데이트된 속성 + updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, // 매핑된 zone_id + newZoneId, ] ); } - logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( - `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + // 통계 출력 + const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; + const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; + const skipCount = screenIds.size - screenDefsToProcess.length; + + logger.info(` +✅ 화면 처리 완료: + - 신규 복사: ${newCount}개 + - 업데이트: ${updateCount}개 + - 스킵 (변경 없음): ${skipCount}개 + - 총 매핑: ${screenIdMap.size}개 + `); + return screenIdMap; } + /** + * 레이아웃 변경 여부 확인 + */ + private hasLayoutChanges( + sourceLayouts: ScreenLayout[], + targetLayouts: ScreenLayout[] + ): boolean { + // 1. 레이아웃 개수가 다르면 변경됨 + if (sourceLayouts.length !== targetLayouts.length) { + return true; + } + + // 2. 각 레이아웃의 주요 속성 비교 + for (let i = 0; i < sourceLayouts.length; i++) { + const source = sourceLayouts[i]; + const target = targetLayouts[i]; + + // component_type이 다르면 변경됨 + if (source.component_type !== target.component_type) { + return true; + } + + // 위치/크기가 다르면 변경됨 + if ( + source.position_x !== target.position_x || + source.position_y !== target.position_y || + source.width !== target.width || + source.height !== target.height + ) { + return true; + } + + // properties의 JSON 문자열 비교 (깊은 비교) + const sourceProps = JSON.stringify(source.properties || {}); + const targetProps = JSON.stringify(target.properties || {}); + if (sourceProps !== targetProps) { + return true; + } + } + + return false; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ @@ -1479,383 +1431,4 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } - /** - * 코드 카테고리 중복 체크 - */ - private async checkCodeCategoryExists( - categoryCode: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_category - WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 - ) as exists`, - [categoryCode, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 정보 중복 체크 - */ - private async checkCodeInfoExists( - categoryCode: string, - codeValue: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_info - WHERE code_category = $1 AND code_value = $2 - AND company_code = $3 AND menu_objid = $4 - ) as exists`, - [categoryCode, codeValue, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 복사 - */ - private async copyCodes( - codes: { categories: CodeCategory[]; codes: CodeInfo[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 코드 복사 중...`); - - let categoryCount = 0; - let codeCount = 0; - let skippedCategories = 0; - let skippedCodes = 0; - - // 1) 코드 카테고리 복사 (중복 체크) - for (const category of codes.categories) { - const newMenuObjid = menuIdMap.get(category.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeCategoryExists( - category.category_code, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCategories++; - logger.debug( - ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 카테고리 복사 - await client.query( - `INSERT INTO code_category ( - category_code, category_name, category_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - category.category_code, - category.category_name, - category.category_name_eng, - category.description, - category.sort_order, - category.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - categoryCount++; - } - - // 2) 코드 정보 복사 (중복 체크) - for (const code of codes.codes) { - const newMenuObjid = menuIdMap.get(code.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeInfoExists( - code.code_category, - code.code_value, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCodes++; - logger.debug( - ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 코드 복사 - await client.query( - `INSERT INTO code_info ( - code_category, code_value, code_name, code_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [ - code.code_category, - code.code_value, - code.code_name, - code.code_name_eng, - code.description, - code.sort_order, - code.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - codeCount++; - } - - logger.info( - `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` - ); - } - - /** - * 카테고리 설정 복사 - */ - private async copyCategorySettings( - settings: { columnMappings: any[]; categoryValues: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📂 카테고리 설정 복사 중...`); - - const valueIdMap = new Map(); // 원본 value_id → 새 value_id - let mappingCount = 0; - let valueCount = 0; - - // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) - for (const mapping of settings.columnMappings) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - mapping.menu_objid === 0 || - mapping.menu_objid === "0" || - mapping.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(mapping.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}` - ); - continue; - } - } - - // 기존 매핑 삭제 (덮어쓰기) - await client.query( - `DELETE FROM category_column_mapping - WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, - [mapping.table_name, mapping.physical_column_name, targetCompanyCode] - ); - - // 새 매핑 추가 - await client.query( - `INSERT INTO category_column_mapping ( - table_name, logical_column_name, physical_column_name, - menu_objid, company_code, description, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - mapping.table_name, - mapping.logical_column_name, - mapping.physical_column_name, - newMenuObjid, - targetCompanyCode, - mapping.description, - userId, - ] - ); - - mappingCount++; - } - - // 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지) - const sortedValues = settings.categoryValues.sort( - (a, b) => a.depth - b.depth - ); - - // 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위) - const uniqueTableColumns = new Set(); - for (const value of sortedValues) { - uniqueTableColumns.add(`${value.table_name}:${value.column_name}`); - } - - for (const tableColumn of uniqueTableColumns) { - const [tableName, columnName] = tableColumn.split(":"); - await client.query( - `DELETE FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, - [tableName, columnName, targetCompanyCode] - ); - logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`); - } - - // 새 값 추가 - for (const value of sortedValues) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - value.menu_objid === 0 || - value.menu_objid === "0" || - value.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(value.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}` - ); - continue; - } - } - - // 부모 ID 재매핑 - let newParentValueId = null; - if (value.parent_value_id) { - newParentValueId = valueIdMap.get(value.parent_value_id) || null; - } - - const result = await client.query( - `INSERT INTO table_column_category_values ( - table_name, column_name, value_code, value_label, - value_order, parent_value_id, depth, description, - color, icon, is_active, is_default, - company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING value_id`, - [ - value.table_name, - value.column_name, - value.value_code, - value.value_label, - value.value_order, - newParentValueId, - value.depth, - value.description, - value.color, - value.icon, - value.is_active, - value.is_default, - targetCompanyCode, - newMenuObjid, - userId, - ] - ); - - // ID 매핑 저장 - const newValueId = result.rows[0].value_id; - valueIdMap.set(value.value_id, newValueId); - - valueCount++; - } - - logger.info( - `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)` - ); - } - - /** - * 채번 규칙 복사 - */ - private async copyNumberingRules( - rules: { rules: any[]; parts: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 채번 규칙 복사 중...`); - - const ruleIdMap = new Map(); // 원본 rule_id → 새 rule_id - let ruleCount = 0; - let partCount = 0; - - // 1) 채번 규칙 복사 - for (const rule of rules.rules) { - const newMenuObjid = menuIdMap.get(rule.menu_objid); - if (!newMenuObjid) continue; - - // 새 rule_id 생성 (타임스탬프 기반) - const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - ruleIdMap.set(rule.rule_id, newRuleId); - - await client.query( - `INSERT INTO numbering_rules ( - rule_id, rule_name, description, separator, - reset_period, current_sequence, table_name, column_name, - company_code, menu_objid, created_by, scope_type - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, - [ - newRuleId, - rule.rule_name, - rule.description, - rule.separator, - rule.reset_period, - 1, // 시퀀스 초기화 - rule.table_name, - rule.column_name, - targetCompanyCode, - newMenuObjid, - userId, - rule.scope_type, - ] - ); - - ruleCount++; - } - - // 2) 채번 규칙 파트 복사 - for (const part of rules.parts) { - const newRuleId = ruleIdMap.get(part.rule_id); - if (!newRuleId) continue; - - await client.query( - `INSERT INTO numbering_rule_parts ( - rule_id, part_order, part_type, generation_method, - auto_config, manual_config, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - newRuleId, - part.part_order, - part.part_type, - part.generation_method, - part.auto_config, - part.manual_config, - targetCompanyCode, - ] - ); - - partCount++; - } - - logger.info( - `✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개` - ); - } } diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts index 86df579c..57bddabd 100644 --- a/backend-node/src/services/menuService.ts +++ b/backend-node/src/services/menuService.ts @@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise } } +/** + * 선택한 메뉴와 그 하위 메뉴들의 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 { + 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 합집합 조회 * diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9cdd85f3..6f481198 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -27,10 +27,15 @@ export type NodeType = | "restAPISource" | "condition" | "dataTransform" + | "aggregate" + | "formulaTransform" // 수식 변환 노드 | "insertAction" | "updateAction" | "deleteAction" | "upsertAction" + | "emailAction" // 이메일 발송 액션 + | "scriptAction" // 스크립트 실행 액션 + | "httpRequestAction" // HTTP 요청 액션 | "comment" | "log"; @@ -112,6 +117,18 @@ export class NodeFlowExecutionService { try { logger.info(`🚀 플로우 실행 시작: flowId=${flowId}`); + // 🔍 디버깅: contextData 상세 로그 + logger.info(`🔍 contextData 상세:`, { + directCompanyCode: contextData.companyCode, + nestedCompanyCode: contextData.context?.companyCode, + directUserId: contextData.userId, + nestedUserId: contextData.context?.userId, + contextKeys: Object.keys(contextData), + nestedContextKeys: contextData.context + ? Object.keys(contextData.context) + : "no nested context", + }); + // 1. 플로우 데이터 조회 const flow = await queryOne<{ flow_id: number; @@ -174,6 +191,12 @@ export class NodeFlowExecutionService { try { result = await transaction(async (client) => { + // 🔥 사용자 ID 세션 변수 설정 (트리거용) + const userId = context.buttonContext?.userId || "system"; + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId, + ]); + // 트랜잭션 내에서 레벨별 실행 for (const level of levels) { await this.executeLevel(level, nodes, edges, context, client); @@ -528,6 +551,12 @@ export class NodeFlowExecutionService { case "dataTransform": return this.executeDataTransform(node, inputData, context); + case "aggregate": + return this.executeAggregate(node, inputData, context); + + case "formulaTransform": + return this.executeFormulaTransform(node, inputData, context); + case "insertAction": return this.executeInsertAction(node, inputData, context, client); @@ -543,6 +572,15 @@ export class NodeFlowExecutionService { case "condition": return this.executeCondition(node, inputData, context); + case "emailAction": + return this.executeEmailAction(node, inputData, context); + + case "scriptAction": + return this.executeScriptAction(node, inputData, context); + + case "httpRequestAction": + return this.executeHttpRequestAction(node, inputData, context); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -830,12 +868,21 @@ export class NodeFlowExecutionService { const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; + logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`); + const result = await query(sql, whereResult.values); logger.info( `📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건` ); + // 디버깅: 조회된 데이터 샘플 출력 + if (result.length > 0) { + logger.info( + `📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}` + ); + } + return result; } @@ -939,19 +986,36 @@ export class NodeFlowExecutionService { }); // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) - const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); - const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + const hasWriterMapping = fieldMappings.some( + (m: any) => m.targetField === "writer" + ); + const hasCompanyCodeMapping = fieldMappings.some( + (m: any) => m.targetField === "company_code" + ); // 컨텍스트에서 사용자 정보 추출 const userId = context.buttonContext?.userId; const companyCode = context.buttonContext?.companyCode; + // 🔍 디버깅: 자동 추가 조건 확인 + console.log(` 🔍 INSERT 자동 추가 조건 확인:`, { + hasWriterMapping, + hasCompanyCodeMapping, + userId, + companyCode, + buttonContext: context.buttonContext, + }); + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) if (!hasWriterMapping && userId) { fields.push("writer"); values.push(userId); insertedData.writer = userId; console.log(` 🔧 자동 추가: writer = ${userId}`); + } else { + console.log( + ` ⚠️ writer 자동 추가 스킵: hasWriterMapping=${hasWriterMapping}, userId=${userId}` + ); } // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) @@ -960,6 +1024,10 @@ export class NodeFlowExecutionService { values.push(companyCode); insertedData.company_code = companyCode; console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } else { + console.log( + ` ⚠️ company_code 자동 추가 스킵: hasCompanyCodeMapping=${hasCompanyCodeMapping}, companyCode=${companyCode}` + ); } const sql = ` @@ -1355,57 +1423,68 @@ export class NodeFlowExecutionService { let updatedCount = 0; const updatedDataArray: any[] = []; - // 🆕 table-all 모드: 단일 SQL로 일괄 업데이트 + // 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영) if (context.currentNodeDataSourceType === "table-all") { - console.log("🚀 table-all 모드: 단일 SQL로 일괄 업데이트 시작"); - - // 첫 번째 데이터를 참조하여 SET 절 생성 - const firstData = dataArray[0]; - const setClauses: string[] = []; - const values: any[] = []; - let paramIndex = 1; - - console.log("🗺️ 필드 매핑 처리 중..."); - fieldMappings.forEach((mapping: any) => { - const value = - mapping.staticValue !== undefined - ? mapping.staticValue - : firstData[mapping.sourceField]; - - console.log( - ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` - ); - - if (mapping.targetField) { - setClauses.push(`${mapping.targetField} = $${paramIndex}`); - values.push(value); - paramIndex++; - } - }); - - // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) - const whereResult = this.buildWhereClause( - whereConditions, - firstData, - paramIndex + console.log( + "🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + + dataArray.length + + "개 그룹)" ); - values.push(...whereResult.values); + // 🔥 각 그룹(데이터)별로 UPDATE 실행 + for (let i = 0; i < dataArray.length; i++) { + const data = dataArray[i]; + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; - const sql = ` - UPDATE ${targetTable} - SET ${setClauses.join(", ")} - ${whereResult.clause} - `; + console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`); + console.log("🗺️ 필드 매핑 처리 중..."); - console.log("📝 실행할 SQL (일괄 처리):", sql); - console.log("📊 바인딩 값:", values); + fieldMappings.forEach((mapping: any) => { + const value = + mapping.staticValue !== undefined + ? mapping.staticValue + : data[mapping.sourceField]; - const result = await txClient.query(sql, values); - updatedCount = result.rowCount || 0; + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); + + if (mapping.targetField) { + setClauses.push(`${mapping.targetField} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + }); + + // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) + const whereResult = this.buildWhereClause( + whereConditions, + data, + paramIndex + ); + + values.push(...whereResult.values); + + const sql = ` + UPDATE ${targetTable} + SET ${setClauses.join(", ")} + ${whereResult.clause} + `; + + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); + + const result = await txClient.query(sql, values); + const rowCount = result.rowCount || 0; + updatedCount += rowCount; + + console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}건`); + } logger.info( - `✅ UPDATE 완료 (내부 DB, 일괄 처리): ${targetTable}, ${updatedCount}건` + `✅ UPDATE 완료 (내부 DB, 그룹별 처리): ${targetTable}, 총 ${updatedCount}건` ); // 업데이트된 데이터는 원본 배열 반환 (실제 DB에서 다시 조회하지 않음) @@ -1414,7 +1493,7 @@ export class NodeFlowExecutionService { // 🆕 context-data 모드: 개별 업데이트 (PK 자동 추가) console.log("🎯 context-data 모드: 개별 업데이트 시작"); - + for (const data of dataArray) { const setClauses: string[] = []; const values: any[] = []; @@ -1567,7 +1646,18 @@ export class NodeFlowExecutionService { // WHERE 조건 생성 const whereClauses: string[] = []; whereConditions?.forEach((condition: any) => { - const condValue = data[condition.field]; + // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴 + let condValue: any; + if (condition.sourceField) { + condValue = data[condition.sourceField]; + } else if ( + condition.staticValue !== undefined && + condition.staticValue !== "" + ) { + condValue = condition.staticValue; + } else { + condValue = data[condition.field]; + } if (condition.operator === "IS NULL") { whereClauses.push(`${condition.field} IS NULL`); @@ -1786,12 +1876,16 @@ export class NodeFlowExecutionService { // 🆕 table-all 모드: 단일 SQL로 일괄 삭제 if (context.currentNodeDataSourceType === "table-all") { console.log("🚀 table-all 모드: 단일 SQL로 일괄 삭제 시작"); - + // 첫 번째 데이터를 참조하여 WHERE 절 생성 const firstData = dataArray[0]; - + // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) - const whereResult = this.buildWhereClause(whereConditions, firstData, 1); + const whereResult = this.buildWhereClause( + whereConditions, + firstData, + 1 + ); const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`; @@ -1818,7 +1912,7 @@ export class NodeFlowExecutionService { for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - + // 🔑 Primary Key 자동 추가 (context-data 모드) console.log("🔑 context-data 모드: Primary Key 자동 추가"); const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( @@ -1826,8 +1920,12 @@ export class NodeFlowExecutionService { data, targetTable ); - - const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1); + + const whereResult = this.buildWhereClause( + enhancedWhereConditions, + data, + 1 + ); const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`; @@ -1900,7 +1998,18 @@ export class NodeFlowExecutionService { // WHERE 조건 생성 whereConditions?.forEach((condition: any) => { - const condValue = data[condition.field]; + // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴 + let condValue: any; + if (condition.sourceField) { + condValue = data[condition.sourceField]; + } else if ( + condition.staticValue !== undefined && + condition.staticValue !== "" + ) { + condValue = condition.staticValue; + } else { + condValue = data[condition.field]; + } if (condition.operator === "IS NULL") { whereClauses.push(`${condition.field} IS NULL`); @@ -2199,6 +2308,34 @@ export class NodeFlowExecutionService { values.push(value); }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some( + (m: any) => m.targetField === "writer" + ); + const hasCompanyCodeMapping = fieldMappings.some( + (m: any) => m.targetField === "company_code" + ); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + columns.push("writer"); + values.push(userId); + logger.info(` 🔧 UPSERT INSERT - 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + columns.push("company_code"); + values.push(companyCode); + logger.info( + ` 🔧 UPSERT INSERT - 자동 추가: company_code = ${companyCode}` + ); + } + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) @@ -2682,13 +2819,15 @@ export class NodeFlowExecutionService { try { const result = await query(sql, [fullTableName]); const pkColumns = result.map((row: any) => row.column_name); - + if (pkColumns.length > 0) { - console.log(`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`); + console.log( + `🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}` + ); } else { console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없습니다`); } - + return pkColumns; } catch (error) { console.error(`❌ Primary Key 조회 실패 (${tableName}):`, error); @@ -2698,7 +2837,7 @@ export class NodeFlowExecutionService { /** * WHERE 조건에 Primary Key 자동 추가 (컨텍스트 데이터 사용 시) - * + * * 테이블의 실제 Primary Key를 자동으로 감지하여 WHERE 조건에 추가 */ private static async enhanceWhereConditionsWithPK( @@ -2721,8 +2860,8 @@ export class NodeFlowExecutionService { } // 🔍 데이터에 모든 PK 컬럼이 있는지 확인 - const missingPKColumns = pkColumns.filter(col => - data[col] === undefined || data[col] === null + const missingPKColumns = pkColumns.filter( + (col) => data[col] === undefined || data[col] === null ); if (missingPKColumns.length > 0) { @@ -2736,8 +2875,9 @@ export class NodeFlowExecutionService { const existingFields = new Set( (whereConditions || []).map((cond: any) => cond.field) ); - const allPKsExist = pkColumns.every(col => - existingFields.has(col) || existingFields.has(`${tableName}.${col}`) + const allPKsExist = pkColumns.every( + (col) => + existingFields.has(col) || existingFields.has(`${tableName}.${col}`) ); if (allPKsExist) { @@ -2746,17 +2886,17 @@ export class NodeFlowExecutionService { } // 🔥 Primary Key 조건들을 맨 앞에 추가 - const pkConditions = pkColumns.map(col => ({ + const pkConditions = pkColumns.map((col) => ({ field: col, - operator: 'EQUALS', - value: data[col] + operator: "EQUALS", + value: data[col], })); const enhanced = [...pkConditions, ...(whereConditions || [])]; - - const pkValues = pkColumns.map(col => `${col} = ${data[col]}`).join(", "); + + const pkValues = pkColumns.map((col) => `${col} = ${data[col]}`).join(", "); console.log(`🔑 WHERE 조건에 Primary Key 자동 추가: ${pkValues}`); - + return enhanced; } @@ -2771,7 +2911,26 @@ export class NodeFlowExecutionService { const values: any[] = []; const clauses = conditions.map((condition, index) => { - const value = data ? data[condition.field] : condition.value; + // 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져오고, + // 없으면 staticValue 또는 기존 field 사용 + let value: any; + if (data) { + if (condition.sourceField) { + // sourceField가 있으면 소스 데이터에서 해당 필드의 값을 가져옴 + value = data[condition.sourceField]; + } else if ( + condition.staticValue !== undefined && + condition.staticValue !== "" + ) { + // staticValue가 있으면 사용 + value = condition.staticValue; + } else { + // 둘 다 없으면 기존 방식 (field로 값 조회) + value = data[condition.field]; + } + } else { + value = condition.value; + } values.push(value); // 연산자를 SQL 문법으로 변환 @@ -3197,4 +3356,1115 @@ export class NodeFlowExecutionService { "upsertAction", ].includes(nodeType); } + + /** + * 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등) + */ + private static async executeAggregate( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + groupByFields = [], + aggregations = [], + havingConditions = [], + } = node.data; + + logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`); + + // 입력 데이터가 없으면 빈 배열 반환 + if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { + logger.warn("⚠️ 집계할 입력 데이터가 없습니다."); + logger.warn( + `⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}` + ); + return []; + } + + logger.info(`📥 입력 데이터: ${inputData.length}건`); + logger.info( + `📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}` + ); + logger.info( + `📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}` + ); + logger.info(`📊 집계 연산: ${aggregations.length}개`); + + // 그룹화 수행 + const groups = new Map(); + + for (const row of inputData) { + // 그룹 키 생성 + const groupKey = + groupByFields.length > 0 + ? groupByFields + .map((f: any) => String(row[f.field] ?? "")) + .join("|||") + : "__ALL__"; + + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(row); + } + + logger.info(`📊 그룹 수: ${groups.size}개`); + + // 디버깅: 각 그룹의 데이터 출력 + for (const [groupKey, groupRows] of groups) { + logger.info( + `📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}` + ); + } + + // 각 그룹에 대해 집계 수행 + const results: any[] = []; + + for (const [groupKey, groupRows] of groups) { + const resultRow: any = {}; + + // 그룹 기준 필드값 추가 + if (groupByFields.length > 0) { + const keyValues = groupKey.split("|||"); + groupByFields.forEach((field: any, idx: number) => { + resultRow[field.field] = keyValues[idx]; + }); + } + + // 각 집계 연산 수행 + for (const agg of aggregations) { + const { sourceField, function: aggFunc, outputField } = agg; + + if (!outputField) continue; + + let aggregatedValue: any; + + switch (aggFunc) { + case "SUM": + aggregatedValue = groupRows.reduce((sum: number, row: any) => { + const val = parseFloat(row[sourceField]); + return sum + (isNaN(val) ? 0 : val); + }, 0); + break; + + case "COUNT": + aggregatedValue = groupRows.length; + break; + + case "AVG": + const sum = groupRows.reduce((acc: number, row: any) => { + const val = parseFloat(row[sourceField]); + return acc + (isNaN(val) ? 0 : val); + }, 0); + aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0; + break; + + case "MIN": + aggregatedValue = groupRows.reduce( + (min: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return min; + return min === null ? val : Math.min(min, val); + }, + null + ); + break; + + case "MAX": + aggregatedValue = groupRows.reduce( + (max: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return max; + return max === null ? val : Math.max(max, val); + }, + null + ); + break; + + case "FIRST": + aggregatedValue = + groupRows.length > 0 ? groupRows[0][sourceField] : null; + break; + + case "LAST": + aggregatedValue = + groupRows.length > 0 + ? groupRows[groupRows.length - 1][sourceField] + : null; + break; + + default: + logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`); + aggregatedValue = null; + } + + resultRow[outputField] = aggregatedValue; + logger.info( + ` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}` + ); + } + + results.push(resultRow); + } + + // HAVING 조건 적용 (집계 후 필터링) + let filteredResults = results; + if (havingConditions && havingConditions.length > 0) { + filteredResults = results.filter((row) => { + return havingConditions.every((condition: any) => { + const fieldValue = row[condition.field]; + const compareValue = parseFloat(condition.value); + + switch (condition.operator) { + case "=": + return fieldValue === compareValue; + case "!=": + return fieldValue !== compareValue; + case ">": + return fieldValue > compareValue; + case ">=": + return fieldValue >= compareValue; + case "<": + return fieldValue < compareValue; + case "<=": + return fieldValue <= compareValue; + default: + return true; + } + }); + }); + + logger.info( + `📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건` + ); + } + + logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`); + + // 결과 샘플 출력 + if (filteredResults.length > 0) { + logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2)); + } + + return filteredResults; + } + + // =================================================================== + // 외부 연동 액션 노드들 + // =================================================================== + + /** + * 이메일 발송 액션 노드 실행 + */ + private static async executeEmailAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + from, + to, + cc, + bcc, + subject, + body, + bodyType, + isHtml, // 레거시 지원 + accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID + smtpConfigId, // 레거시 지원 + attachments, + templateVariables, + } = node.data; + + logger.info( + `📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}` + ); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + // 동적 임포트로 순환 참조 방지 + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + const { mailAccountFileService } = await import("./mailAccountFileService"); + + // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 + let accountId = nodeAccountId || smtpConfigId; + if (!accountId) { + const accounts = await mailAccountFileService.getAllAccounts(); + const activeAccount = accounts.find( + (acc: any) => acc.status === "active" + ); + if (activeAccount) { + accountId = activeAccount.id; + logger.info( + `📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})` + ); + } else { + throw new Error( + "활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요." + ); + } + } + + // HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원) + const useHtml = bodyType === "html" || isHtml === true; + + for (const data of dataArray) { + try { + // 템플릿 변수 치환 + const processedSubject = this.replaceTemplateVariables( + subject || "", + data + ); + const processedBody = this.replaceTemplateVariables(body || "", data); + const processedTo = this.replaceTemplateVariables(to || "", data); + const processedCc = cc + ? this.replaceTemplateVariables(cc, data) + : undefined; + const processedBcc = bcc + ? this.replaceTemplateVariables(bcc, data) + : undefined; + + // 수신자 파싱 (쉼표로 구분) + const toList = processedTo + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email); + const ccList = processedCc + ? processedCc + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email) + : undefined; + const bccList = processedBcc + ? processedBcc + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email) + : undefined; + + if (toList.length === 0) { + throw new Error("수신자 이메일 주소가 지정되지 않았습니다."); + } + + // 메일 발송 요청 + const sendResult = await mailSendSimpleService.sendMail({ + accountId, + to: toList, + cc: ccList, + bcc: bccList, + subject: processedSubject, + customHtml: useHtml ? processedBody : `
${processedBody}
`, + attachments: attachments?.map((att: any) => ({ + filename: att.type === "dataField" ? data[att.value] : att.value, + path: att.type === "dataField" ? data[att.value] : att.value, + })), + }); + + if (sendResult.success) { + logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`); + results.push({ + success: true, + to: toList, + messageId: sendResult.messageId, + }); + } else { + logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`); + results.push({ + success: false, + to: toList, + error: sendResult.error, + }); + } + } catch (error: any) { + logger.error(`❌ 이메일 발송 오류:`, error); + results.push({ + success: false, + error: error.message, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "emailAction", + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 스크립트 실행 액션 노드 실행 + */ + private static async executeScriptAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + scriptType, + scriptPath, + arguments: scriptArgs, + workingDirectory, + environmentVariables, + timeout, + captureOutput, + } = node.data; + + logger.info( + `🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}` + ); + logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`); + + if (!scriptPath) { + throw new Error("스크립트 경로가 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + // child_process 모듈 동적 임포트 + const { spawn } = await import("child_process"); + const path = await import("path"); + + for (const data of dataArray) { + try { + // 인자 처리 + const processedArgs: string[] = []; + if (scriptArgs && Array.isArray(scriptArgs)) { + for (const arg of scriptArgs) { + if (arg.type === "dataField") { + // 데이터 필드 참조 + const value = this.replaceTemplateVariables(arg.value, data); + processedArgs.push(value); + } else { + processedArgs.push(arg.value); + } + } + } + + // 환경 변수 처리 + const env = { + ...process.env, + ...(environmentVariables || {}), + }; + + // 스크립트 타입에 따른 명령어 결정 + let command: string; + let args: string[]; + + switch (scriptType) { + case "python": + command = "python3"; + args = [scriptPath, ...processedArgs]; + break; + case "shell": + command = "bash"; + args = [scriptPath, ...processedArgs]; + break; + case "executable": + command = scriptPath; + args = processedArgs; + break; + default: + throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`); + } + + logger.info(` 실행 명령: ${command} ${args.join(" ")}`); + + // 스크립트 실행 (Promise로 래핑) + const result = await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd: workingDirectory || process.cwd(), + env, + timeout: timeout || 60000, // 기본 60초 + }); + + let stdout = ""; + let stderr = ""; + + if (captureOutput !== false) { + childProcess.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + } + + childProcess.on("close", (code) => { + resolve({ exitCode: code, stdout, stderr }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); + + if (result.exitCode === 0) { + logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`); + results.push({ + success: true, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } else { + logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`); + results.push({ + success: false, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } + } catch (error: any) { + logger.error(`❌ 스크립트 실행 오류:`, error); + results.push({ + success: false, + error: error.message, + data, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "scriptAction", + scriptType, + scriptPath, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * HTTP 요청 액션 노드 실행 + */ + private static async executeHttpRequestAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + url, + method, + headers, + bodyTemplate, + bodyType, + authentication, + timeout, + retryCount, + responseMapping, + } = node.data; + + logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 메서드: ${method}, URL: ${url}`); + + if (!url) { + throw new Error("HTTP 요청 URL이 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + for (const data of dataArray) { + let currentRetry = 0; + const maxRetries = retryCount || 0; + + while (currentRetry <= maxRetries) { + try { + // URL 템플릿 변수 치환 + const processedUrl = this.replaceTemplateVariables(url, data); + + // 헤더 처리 + const processedHeaders: Record = {}; + if (headers && Array.isArray(headers)) { + for (const header of headers) { + const headerValue = + header.valueType === "dataField" + ? this.replaceTemplateVariables(header.value, data) + : header.value; + processedHeaders[header.name] = headerValue; + } + } + + // 인증 헤더 추가 + if (authentication) { + switch (authentication.type) { + case "basic": + if (authentication.username && authentication.password) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + processedHeaders["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (authentication.token) { + processedHeaders["Authorization"] = + `Bearer ${authentication.token}`; + } + break; + case "apikey": + if (authentication.apiKey) { + if (authentication.apiKeyLocation === "query") { + // 쿼리 파라미터로 추가 (URL에 추가) + const paramName = + authentication.apiKeyQueryParam || "api_key"; + const separator = processedUrl.includes("?") ? "&" : "?"; + // URL은 이미 처리되었으므로 여기서는 결과에 포함 + } else { + // 헤더로 추가 + const headerName = + authentication.apiKeyHeader || "X-API-Key"; + processedHeaders[headerName] = authentication.apiKey; + } + } + break; + } + } + + // Content-Type 기본값 + if ( + !processedHeaders["Content-Type"] && + ["POST", "PUT", "PATCH"].includes(method) + ) { + processedHeaders["Content-Type"] = + bodyType === "json" ? "application/json" : "text/plain"; + } + + // 바디 처리 + let processedBody: string | undefined; + if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) { + processedBody = this.replaceTemplateVariables(bodyTemplate, data); + } + + logger.info(` 요청 URL: ${processedUrl}`); + logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`); + if (processedBody) { + logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`); + } + + // HTTP 요청 실행 + const response = await axios({ + method: method.toLowerCase() as any, + url: processedUrl, + headers: processedHeaders, + data: processedBody, + timeout: timeout || 30000, + validateStatus: () => true, // 모든 상태 코드 허용 + }); + + logger.info( + ` 응답 상태: ${response.status} ${response.statusText}` + ); + + // 응답 데이터 처리 + let responseData = response.data; + + // 응답 매핑 적용 + if (responseMapping && responseData) { + const paths = responseMapping.split("."); + for (const path of paths) { + if ( + responseData && + typeof responseData === "object" && + path in responseData + ) { + responseData = responseData[path]; + } else { + logger.warn( + `⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}` + ); + break; + } + } + } + + const isSuccess = response.status >= 200 && response.status < 300; + + if (isSuccess) { + logger.info(`✅ HTTP 요청 성공`); + results.push({ + success: true, + statusCode: response.status, + data: responseData, + inputData: data, + }); + break; // 성공 시 재시도 루프 종료 + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: any) { + currentRetry++; + if (currentRetry > maxRetries) { + logger.error( + `❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, + error.message + ); + results.push({ + success: false, + error: error.message, + inputData: data, + }); + } else { + logger.warn( + `⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}` + ); + // 재시도 전 잠시 대기 + await new Promise((resolve) => + setTimeout(resolve, 1000 * currentRetry) + ); + } + } + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "httpRequestAction", + method, + url, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 수식 변환 노드 실행 + * - 타겟 테이블에서 기존 값 조회 (targetLookup) + * - 산술 연산, 함수, 조건, 정적 값 계산 + */ + private static async executeFormulaTransform( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetLookup, transformations = [] } = node.data; + + logger.info(`🧮 수식 변환 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 변환 규칙: ${transformations.length}개`); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : []; + + if (dataArray.length === 0) { + logger.warn(`⚠️ 수식 변환 노드: 입력 데이터가 없습니다`); + return []; + } + + const results: any[] = []; + + for (const sourceRow of dataArray) { + let targetRow: any = null; + + // 타겟 테이블에서 기존 값 조회 + if (targetLookup?.tableName && targetLookup?.lookupKeys?.length > 0) { + try { + const whereConditions = targetLookup.lookupKeys + .map((key: any, idx: number) => `${key.targetField} = $${idx + 1}`) + .join(" AND "); + + const lookupValues = targetLookup.lookupKeys.map( + (key: any) => sourceRow[key.sourceField] + ); + + // company_code 필터링 추가 + const companyCode = + context.buttonContext?.companyCode || sourceRow.company_code; + let sql = `SELECT * FROM ${targetLookup.tableName} WHERE ${whereConditions}`; + const params = [...lookupValues]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $${params.length + 1}`; + params.push(companyCode); + } + + sql += " LIMIT 1"; + + logger.info(` 타겟 조회: ${targetLookup.tableName}`); + logger.info(` 조회 조건: ${whereConditions}`); + logger.info(` 조회 값: ${JSON.stringify(lookupValues)}`); + + targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(` ✅ 타겟 데이터 조회 성공`); + } else { + logger.info(` ℹ️ 타겟 데이터 없음 (신규)`); + } + } catch (error: any) { + logger.warn(` ⚠️ 타겟 조회 실패: ${error.message}`); + } + } + + // 결과 객체 (소스 데이터 복사) + const resultRow = { ...sourceRow }; + + // 중간 결과 저장소 (이전 변환 결과 참조용) + const resultValues: Record = {}; + + // 변환 규칙 순차 실행 + for (const trans of transformations) { + try { + const value = this.evaluateFormula( + trans, + sourceRow, + targetRow, + resultValues + ); + resultRow[trans.outputField] = value; + resultValues[trans.outputField] = value; + + logger.info( + ` ${trans.outputField} = ${JSON.stringify(value)} (${trans.formulaType})` + ); + } catch (error: any) { + logger.error( + ` ❌ 수식 계산 실패 [${trans.outputField}]: ${error.message}` + ); + resultRow[trans.outputField] = null; + } + } + + results.push(resultRow); + } + + logger.info(`✅ 수식 변환 완료: ${results.length}건`); + return results; + } + + /** + * 수식 계산 + */ + private static evaluateFormula( + trans: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + const { + formulaType, + arithmetic, + function: func, + condition, + staticValue, + } = trans; + + switch (formulaType) { + case "arithmetic": + return this.evaluateArithmetic( + arithmetic, + sourceRow, + targetRow, + resultValues + ); + + case "function": + return this.evaluateFunction(func, sourceRow, targetRow, resultValues); + + case "condition": + return this.evaluateCaseCondition( + condition, + sourceRow, + targetRow, + resultValues + ); + + case "static": + return this.parseStaticValue(staticValue); + + default: + throw new Error(`지원하지 않는 수식 타입: ${formulaType}`); + } + } + + /** + * 피연산자 값 가져오기 + */ + private static getOperandValue( + operand: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!operand) return null; + + switch (operand.type) { + case "source": + return sourceRow?.[operand.field] ?? null; + + case "target": + return targetRow?.[operand.field] ?? null; + + case "static": + return this.parseStaticValue(operand.value); + + case "result": + return resultValues?.[operand.resultField] ?? null; + + default: + return null; + } + } + + /** + * 정적 값 파싱 (숫자, 불린, 문자열) + */ + private static parseStaticValue(value: any): any { + if (value === null || value === undefined || value === "") return null; + + // 숫자 체크 + const numValue = Number(value); + if (!isNaN(numValue) && value !== "") return numValue; + + // 불린 체크 + if (value === "true") return true; + if (value === "false") return false; + + // 문자열 반환 + return value; + } + + /** + * 산술 연산 계산 + */ + private static evaluateArithmetic( + arithmetic: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): number | null { + if (!arithmetic) return null; + + const left = this.getOperandValue( + arithmetic.leftOperand, + sourceRow, + targetRow, + resultValues + ); + const right = this.getOperandValue( + arithmetic.rightOperand, + sourceRow, + targetRow, + resultValues + ); + + // COALESCE 처리: null이면 0으로 대체 + const leftNum = Number(left) || 0; + const rightNum = Number(right) || 0; + + switch (arithmetic.operator) { + case "+": + return leftNum + rightNum; + case "-": + return leftNum - rightNum; + case "*": + return leftNum * rightNum; + case "/": + if (rightNum === 0) { + logger.warn(`⚠️ 0으로 나누기 시도`); + return null; + } + return leftNum / rightNum; + case "%": + if (rightNum === 0) { + logger.warn(`⚠️ 0으로 나머지 연산 시도`); + return null; + } + return leftNum % rightNum; + default: + throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`); + } + } + + /** + * 함수 실행 + */ + private static evaluateFunction( + func: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!func) return null; + + const args = (func.arguments || []).map((arg: any) => + this.getOperandValue(arg, sourceRow, targetRow, resultValues) + ); + + switch (func.name) { + case "NOW": + return new Date().toISOString(); + + case "COALESCE": + // 첫 번째 non-null 값 반환 + for (const arg of args) { + if (arg !== null && arg !== undefined) return arg; + } + return null; + + case "CONCAT": + return args.filter((a: any) => a !== null && a !== undefined).join(""); + + case "UPPER": + return args[0] ? String(args[0]).toUpperCase() : null; + + case "LOWER": + return args[0] ? String(args[0]).toLowerCase() : null; + + case "TRIM": + return args[0] ? String(args[0]).trim() : null; + + case "ROUND": + return args[0] !== null ? Math.round(Number(args[0])) : null; + + case "ABS": + return args[0] !== null ? Math.abs(Number(args[0])) : null; + + case "SUBSTRING": + if (args[0] && args[1] !== undefined) { + const str = String(args[0]); + const start = Number(args[1]) || 0; + const length = args[2] !== undefined ? Number(args[2]) : undefined; + return length !== undefined + ? str.substring(start, start + length) + : str.substring(start); + } + return null; + + default: + throw new Error(`지원하지 않는 함수: ${func.name}`); + } + } + + /** + * 조건 평가 (CASE WHEN ... THEN ... ELSE) + */ + private static evaluateCaseCondition( + condition: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!condition) return null; + + const { when, then: thenValue, else: elseValue } = condition; + + // WHEN 조건 평가 + const leftValue = this.getOperandValue( + when.leftOperand, + sourceRow, + targetRow, + resultValues + ); + const rightValue = when.rightOperand + ? this.getOperandValue( + when.rightOperand, + sourceRow, + targetRow, + resultValues + ) + : null; + + let conditionResult = false; + + switch (when.operator) { + case "=": + conditionResult = leftValue == rightValue; + break; + case "!=": + conditionResult = leftValue != rightValue; + break; + case ">": + conditionResult = Number(leftValue) > Number(rightValue); + break; + case "<": + conditionResult = Number(leftValue) < Number(rightValue); + break; + case ">=": + conditionResult = Number(leftValue) >= Number(rightValue); + break; + case "<=": + conditionResult = Number(leftValue) <= Number(rightValue); + break; + case "IS_NULL": + conditionResult = leftValue === null || leftValue === undefined; + break; + case "IS_NOT_NULL": + conditionResult = leftValue !== null && leftValue !== undefined; + break; + default: + throw new Error(`지원하지 않는 조건 연산자: ${when.operator}`); + } + + // THEN 또는 ELSE 값 반환 + if (conditionResult) { + return this.getOperandValue( + thenValue, + sourceRow, + targetRow, + resultValues + ); + } else { + return this.getOperandValue( + elseValue, + sourceRow, + targetRow, + resultValues + ); + } + } } diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cb405b33..8208ecc5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,7 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { getSiblingMenuObjids } from "./menuService"; +import { getMenuAndChildObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -161,7 +161,7 @@ class NumberingRuleService { companyCode: string, menuObjid?: number ): Promise { - let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 + let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { @@ -171,14 +171,14 @@ class NumberingRuleService { const pool = getPool(); - // 1. 형제 메뉴 OBJID 조회 + // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { - siblingObjids = await getSiblingMenuObjids(menuObjid); - logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + menuAndChildObjids = await getMenuAndChildObjids(menuObjid); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); } // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid || siblingObjids.length === 0) { + if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; @@ -280,7 +280,7 @@ class NumberingRuleService { let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) + // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -301,8 +301,7 @@ class NumberingRuleService { WHERE scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($1)) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -311,10 +310,10 @@ class NumberingRuleService { END, created_at DESC `; - params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); + params = [menuAndChildObjids]; + logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) + // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -336,8 +335,7 @@ class NumberingRuleService { AND ( scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($2)) ) ORDER BY CASE @@ -347,8 +345,8 @@ class NumberingRuleService { END, created_at DESC `; - params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); + params = [companyCode, menuAndChildObjids]; + logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { @@ -420,7 +418,7 @@ class NumberingRuleService { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - siblingCount: siblingObjids.length, + menuAndChildCount: menuAndChildObjids.length, count: result.rowCount, }); @@ -432,7 +430,7 @@ class NumberingRuleService { errorStack: error.stack, companyCode, menuObjid, - siblingObjids: siblingObjids || [], + menuAndChildObjids: menuAndChildObjids || [], }); throw error; } @@ -609,7 +607,9 @@ class NumberingRuleService { } const result = await pool.query(query, params); - if (result.rowCount === 0) return null; + if (result.rowCount === 0) { + return null; + } const rule = result.rows[0]; @@ -898,14 +898,15 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (현재 순번으로 미리보기, 증가 안 함) - const length = autoConfig.sequenceLength || 4; - return String(rule.currentSequence || 1).padStart(length, "0"); + // 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시) + const length = autoConfig.sequenceLength || 3; + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } @@ -958,14 +959,15 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (자동 증가 숫자) - const length = autoConfig.sequenceLength || 4; - return String(rule.currentSequence || 1).padStart(length, "0"); + // 순번 (자동 증가 숫자 - 다음 번호 사용) + const length = autoConfig.sequenceLength || 3; + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index f3561bbe..03a3fdf1 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -47,9 +47,24 @@ export class RiskAlertService { console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); - // 텍스트 응답 파싱 (EUC-KR 인코딩) + // 텍스트 응답 파싱 (인코딩 자동 감지) const iconv = require('iconv-lite'); - const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); + const buffer = Buffer.from(warningResponse.data); + + // UTF-8 먼저 시도, 실패하면 EUC-KR 시도 + let responseText: string; + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지) + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + responseText = utf8Text; + console.log('📝 UTF-8 인코딩으로 디코딩'); + } else { + // EUC-KR로 디코딩 + responseText = iconv.decode(buffer, 'EUC-KR'); + console.log('📝 EUC-KR 인코딩으로 디코딩'); + } if (typeof responseText === 'string' && responseText.includes('#START7777')) { const lines = responseText.split('\n'); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a7445637..9fc0f079 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -70,12 +70,13 @@ export class ScreenManagementService { throw new Error("이미 존재하는 화면 코드입니다."); } - // 화면 생성 (Raw Query) + // 화면 생성 (Raw Query) - REST API 지원 추가 const [screen] = await query( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, description, created_by, - db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + db_source_type, db_connection_id, data_source_type, rest_api_connection_id, + rest_api_endpoint, rest_api_json_path + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ screenData.screenName, @@ -86,6 +87,10 @@ export class ScreenManagementService { screenData.createdBy, screenData.dbSourceType || "internal", screenData.dbConnectionId || null, + (screenData as any).dataSourceType || "database", + (screenData as any).restApiConnectionId || null, + (screenData as any).restApiEndpoint || null, + (screenData as any).restApiJsonPath || "data", ] ); @@ -321,7 +326,19 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, + updateData: { + screenName: string; + tableName?: string; + description?: string; + isActive: string; + // REST API 관련 필드 추가 + dataSourceType?: string; + dbSourceType?: string; + dbConnectionId?: number; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; + }, userCompanyCode: string ): Promise { // 권한 확인 @@ -343,24 +360,43 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 (tableName 포함) + // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, - updated_date = $5 - WHERE screen_id = $6`, + updated_date = $5, + data_source_type = $6, + db_source_type = $7, + db_connection_id = $8, + rest_api_connection_id = $9, + rest_api_endpoint = $10, + rest_api_json_path = $11 + WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), + updateData.dataSourceType || "database", + updateData.dbSourceType || "internal", + updateData.dbConnectionId || null, + updateData.restApiConnectionId || null, + updateData.restApiEndpoint || null, + updateData.restApiJsonPath || null, screenId, ] ); + + console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { + dataSourceType: updateData.dataSourceType, + restApiConnectionId: updateData.restApiConnectionId, + restApiEndpoint: updateData.restApiEndpoint, + restApiJsonPath: updateData.restApiJsonPath, + }); } /** @@ -856,6 +892,134 @@ export class ScreenManagementService { }; } + /** + * 활성 화면 일괄 삭제 (휴지통으로 이동) + */ + async bulkDeleteScreens( + screenIds: number[], + userCompanyCode: string, + deletedBy: string, + deleteReason?: string, + force: boolean = false + ): Promise<{ + deletedCount: number; + skippedCount: number; + errors: Array<{ screenId: number; error: string }>; + }> { + if (screenIds.length === 0) { + throw new Error("삭제할 화면을 선택해주세요."); + } + + let deletedCount = 0; + let skippedCount = 0; + const errors: Array<{ screenId: number; error: string }> = []; + + // 각 화면을 개별적으로 삭제 처리 + for (const screenId of screenIds) { + try { + // 권한 확인 (Raw Query) + const existingResult = await query<{ + company_code: string | null; + is_active: string; + screen_name: string; + }>( + `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + skippedCount++; + errors.push({ + screenId, + error: "화면을 찾을 수 없습니다.", + }); + continue; + } + + const existingScreen = existingResult[0]; + + // 권한 확인 + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + skippedCount++; + errors.push({ + screenId, + error: "이 화면을 삭제할 권한이 없습니다.", + }); + continue; + } + + // 이미 삭제된 화면인지 확인 + if (existingScreen.is_active === "D") { + skippedCount++; + errors.push({ + screenId, + error: "이미 삭제된 화면입니다.", + }); + continue; + } + + // 강제 삭제가 아닌 경우 의존성 체크 + if (!force) { + const dependencyCheck = await this.checkScreenDependencies( + screenId, + userCompanyCode + ); + if (dependencyCheck.hasDependencies) { + skippedCount++; + errors.push({ + screenId, + error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, + }); + continue; + } + } + + // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 + await transaction(async (client) => { + const now = new Date(); + + // 소프트 삭제 (휴지통으로 이동) + await client.query( + `UPDATE screen_definitions + SET is_active = 'D', + deleted_date = $1, + deleted_by = $2, + delete_reason = $3, + updated_date = $4, + updated_by = $5 + WHERE screen_id = $6`, + [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + ); + + // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) + await client.query( + `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, + [screenId] + ); + }); + + deletedCount++; + logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + } catch (error) { + skippedCount++; + errors.push({ + screenId, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + logger.error(`화면 삭제 실패: ${screenId}`, error); + } + } + + logger.info( + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + ); + + return { deletedCount, skippedCount, errors }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ @@ -1481,11 +1645,23 @@ export class ScreenManagementService { }; } + // 🔥 최신 inputType 정보 조회 (table_type_columns에서) + const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; + + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 + const tableName = properties?.tableName; + const columnName = properties?.columnName; + const latestTypeInfo = tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + const component = { id: layout.component_id, - type: layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 + type: latestTypeInfo?.componentType || layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, @@ -1494,6 +1670,17 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 + ...(latestTypeInfo && { + widgetType: latestTypeInfo.inputType, + inputType: latestTypeInfo.inputType, + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1503,6 +1690,9 @@ export class ScreenManagementService { size: component.size, parentId: component.parentId, title: (component as any).title, + widgetType: (component as any).widgetType, + componentType: (component as any).componentType, + latestTypeInfo, }); return component; @@ -1522,6 +1712,112 @@ export class ScreenManagementService { }; } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컴포넌트들의 최신 inputType 정보 조회 + * @param layouts - 레이아웃 목록 + * @param companyCode - 회사 코드 + * @returns Map<"tableName.columnName", { inputType, componentType }> + */ + private async getLatestInputTypes( + layouts: any[], + companyCode: string + ): Promise> { + const inputTypeMap = new Map(); + + // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 + const tableColumnPairs = new Set(); + for (const layout of layouts) { + const properties = layout.properties as any; + if (properties?.tableName && properties?.columnName) { + tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + } + } + + if (tableColumnPairs.size === 0) { + return inputTypeMap; + } + + // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 + const pairs = Array.from(tableColumnPairs).map(pair => { + const [tableName, columnName] = pair.split('|'); + return { tableName, columnName }; + }); + + // 배치 쿼리로 한 번에 조회 + const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); + const params = pairs.flatMap(p => [p.tableName, p.columnName]); + + try { + const results = await query<{ table_name: string; column_name: string; input_type: string }>( + `SELECT table_name, column_name, input_type + FROM table_type_columns + WHERE (table_name, column_name) IN (${placeholders}) + AND company_code = $${params.length + 1}`, + [...params, companyCode] + ); + + for (const row of results) { + const componentType = this.getComponentIdFromInputType(row.input_type); + inputTypeMap.set(`${row.table_name}.${row.column_name}`, { + inputType: row.input_type, + componentType: componentType, + }); + } + + console.log(`최신 inputType 조회 완료: ${results.length}개`); + } catch (error) { + console.warn(`최신 inputType 조회 실패 (무시됨):`, error); + } + + return inputTypeMap; + } + // ======================================== // 템플릿 관리 // ======================================== @@ -1977,6 +2273,11 @@ export class ScreenManagementService { updatedBy: data.updated_by, dbSourceType: data.db_source_type || "internal", dbConnectionId: data.db_connection_id || undefined, + // REST API 관련 필드 + dataSourceType: data.data_source_type || "database", + restApiConnectionId: data.rest_api_connection_id || undefined, + restApiEndpoint: data.rest_api_endpoint || undefined, + restApiJsonPath: data.rest_api_json_path || "data", }; } @@ -2006,37 +2307,40 @@ export class ScreenManagementService { // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 해당 회사의 기존 화면 코드들 조회 + // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) + // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + WHERE screen_code LIKE $1 + ORDER BY screen_code DESC`, + [`${companyCode}_%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` ); + console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log(`🔍 패턴: ${pattern}`); + for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); + console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } - // 다음 순번으로 화면 코드 생성 (3자리 패딩) + // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; - const paddedNumber = nextNumber.toString().padStart(3, "0"); - - const newCode = `${companyCode}_${paddedNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`); + // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 + const newCode = `${companyCode}_${nextNumber}`; + console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 @@ -2056,30 +2360,33 @@ export class ScreenManagementService { const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0); await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 현재 최대 번호 조회 - const existingScreens = await client.query<{ screen_code: string }>( - `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + // 현재 최대 번호 조회 (숫자 추출 후 정렬) + // 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX + const existingScreens = await client.query<{ screen_code: string; num: number }>( + `SELECT screen_code, + COALESCE( + NULLIF( + regexp_replace(screen_code, $2, '\\1'), + screen_code + )::integer, + 0 + ) as num + FROM screen_definitions + WHERE company_code = $1 + AND screen_code ~ $2 + AND deleted_date IS NULL + ORDER BY num DESC + LIMIT 1`, + [companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`] ); let maxNumber = 0; - const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` - ); - - for (const screen of existingScreens.rows) { - const match = screen.screen_code.match(pattern); - if (match) { - const number = parseInt(match[1], 10); - if (number > maxNumber) { - maxNumber = number; - } - } + if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) { + maxNumber = existingScreens.rows[0].num; } + console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`); + // count개의 코드를 순차적으로 생성 const codes: string[] = []; for (let i = 0; i < count; i++) { diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2a379ae0..cdf1b838 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1066,6 +1066,66 @@ class TableCategoryValueService { } } + /** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + * + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param companyCode - 회사 코드 + * @returns 삭제된 매핑 수 + */ + async deleteColumnMappingsByColumn( + tableName: string, + columnName: string, + companyCode: string + ): Promise { + const pool = getPool(); + + try { + logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode }); + + // 멀티테넌시 적용 + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + `; + deleteParams = [tableName, columnName]; + } else { + // 일반 회사: 자신의 매핑만 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + AND company_code = $3 + `; + deleteParams = [tableName, columnName, companyCode]; + } + + const result = await pool.query(deleteQuery, deleteParams); + const deletedCount = result.rowCount || 0; + + logger.info("테이블+컬럼 기준 매핑 삭제 완료", { + tableName, + columnName, + companyCode, + deletedCount + }); + + return deletedCount; + } catch (error: any) { + logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); + throw error; + } + } + /** * 논리적 컬럼명을 물리적 컬럼명으로 변환 * @@ -1198,6 +1258,70 @@ class TableCategoryValueService { throw error; } } + + /** + * 카테고리 코드로 라벨 조회 + * + * @param valueCodes - 카테고리 코드 배열 + * @param companyCode - 회사 코드 + * @returns { [code]: label } 형태의 매핑 객체 + */ + async getCategoryLabelsByCodes( + valueCodes: string[], + companyCode: string + ): Promise> { + try { + if (!valueCodes || valueCodes.length === 0) { + return {}; + } + + logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode }); + + const pool = getPool(); + + // 동적으로 파라미터 플레이스홀더 생성 + const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + `; + params = valueCodes; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + `; + params = [...valueCodes, companyCode]; + } + + const result = await pool.query(query, params); + + // { [code]: label } 형태로 변환 + const labels: Record = {}; + for (const row of result.rows) { + labels[row.value_code] = row.value_label; + } + + logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); + + return labels; + } catch (error: any) { + logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error }); + throw error; + } + } } export default new TableCategoryValueService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 173de022..9a8623a0 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -797,6 +797,14 @@ export class TableManagementService { ] ); + // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 + await this.syncScreenLayoutsInputType( + tableName, + columnName, + inputType, + companyCode + ); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; cache.delete(cacheKeyPattern); @@ -816,6 +824,139 @@ export class TableManagementService { } } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화 + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param inputType - 새로운 입력 타입 + * @param companyCode - 회사 코드 + */ + private async syncScreenLayoutsInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string + ): Promise { + try { + // 해당 컬럼을 사용하는 화면 레이아웃 조회 + const affectedLayouts = await query<{ + layout_id: number; + screen_id: number; + component_id: string; + component_type: string; + properties: any; + }>( + `SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sl.properties->>'tableName' = $1 + AND sl.properties->>'columnName' = $2 + AND (sd.company_code = $3 OR $3 = '*')`, + [tableName, columnName, companyCode] + ); + + if (affectedLayouts.length === 0) { + logger.info( + `화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음` + ); + return; + } + + logger.info( + `화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견` + ); + + // 새로운 componentType 계산 + const newComponentType = this.getComponentIdFromInputType(inputType); + + // 각 레이아웃의 widgetType, componentType 업데이트 + for (const layout of affectedLayouts) { + const updatedProperties = { + ...layout.properties, + widgetType: inputType, + inputType: inputType, + // componentConfig 내부의 type도 업데이트 + componentConfig: { + ...layout.properties?.componentConfig, + type: newComponentType, + inputType: inputType, + }, + }; + + await query( + `UPDATE screen_layouts + SET properties = $1, component_type = $2 + WHERE layout_id = $3`, + [ + JSON.stringify(updatedProperties), + newComponentType, + layout.layout_id, + ] + ); + + logger.info( + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + ); + } + + logger.info( + `화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨` + ); + } catch (error) { + // 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + logger.warn( + `화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`, + error + ); + } + } + /** * 입력 타입별 기본 상세 설정 생성 */ @@ -1165,19 +1306,55 @@ export class TableManagementService { paramCount: number; } | null> { try { - // 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + + // 날짜 타입이면 날짜 범위로 처리 + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } + + // 그 외 타입이면 다중선택(IN 조건)으로 처리 + const multiValues = value + .split("|") + .filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const placeholders = multiValues + .map((_: string, idx: number) => `$${paramIndex + idx}`) + .join(", "); + logger.info( + `🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})` + ); + return { + whereClause: `${columnName}::text IN (${placeholders})`, + values: multiValues, + paramCount: multiValues.length, + }; + } } // 🔧 날짜 범위 객체 {from, to} 체크 - if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) { + if ( + typeof value === "object" && + value !== null && + ("from" in value || "to" in value) + ) { // 날짜 범위 객체는 그대로 전달 - const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { + const columnInfo = await this.getColumnWebTypeInfo( + tableName, + columnName + ); + if ( + columnInfo && + (columnInfo.webType === "date" || columnInfo.webType === "datetime") + ) { return this.buildDateRangeCondition(columnName, value, paramIndex); } } @@ -1210,9 +1387,10 @@ export class TableManagementService { // 컬럼 타입 정보 조회 const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); - logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, - `webType=${columnInfo?.webType || 'NULL'}`, - `inputType=${columnInfo?.inputType || 'NULL'}`, + logger.info( + `🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`, + `webType=${columnInfo?.webType || "NULL"}`, + `inputType=${columnInfo?.inputType || "NULL"}`, `actualValue=${JSON.stringify(actualValue)}`, `operator=${operator}` ); @@ -1318,16 +1496,20 @@ export class TableManagementService { // 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD") if (typeof value === "string" && value.includes("|")) { const [fromStr, toStr] = value.split("|"); - + if (fromStr && fromStr.trim() !== "") { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(fromStr.trim()); paramCount++; } if (toStr && toStr.trim() !== "") { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(toStr.trim()); paramCount++; } @@ -1336,17 +1518,21 @@ export class TableManagementService { else if (typeof value === "object" && value !== null) { if (value.from) { // VARCHAR 컬럼을 DATE로 캐스팅하여 비교 - conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date >= $${paramIndex + paramCount}::date` + ); values.push(value.from); paramCount++; } if (value.to) { // 종료일은 해당 날짜의 23:59:59까지 포함 - conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`); + conditions.push( + `${columnName}::date <= $${paramIndex + paramCount}::date` + ); values.push(value.to); paramCount++; } - } + } // 단일 날짜 검색 else if (typeof value === "string" && value.trim() !== "") { conditions.push(`${columnName}::date = $${paramIndex}::date`); @@ -1502,6 +1688,28 @@ export class TableManagementService { columnName ); + // 🆕 배열 처리: IN 절 사용 + if (Array.isArray(value)) { + if (value.length === 0) { + // 빈 배열이면 항상 false 조건 + return { + whereClause: `1 = 0`, + values: [], + paramCount: 0, + }; + } + + // IN 절로 여러 값 검색 + const placeholders = value + .map((_, idx) => `$${paramIndex + idx}`) + .join(", "); + return { + whereClause: `${columnName} IN (${placeholders})`, + values: value, + paramCount: value.length, + }; + } + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // 엔티티 타입이 아니면 기본 검색 return { @@ -1610,20 +1818,25 @@ export class TableManagementService { [tableName, columnName] ); - logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, { - found: !!result, - web_type: result?.web_type, - input_type: result?.input_type, - }); + logger.info( + `🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, + { + found: !!result, + web_type: result?.web_type, + input_type: result?.input_type, + } + ); if (!result) { - logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`); + logger.warn( + `⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}` + ); return null; } // web_type이 없으면 input_type을 사용 (레거시 호환) const webType = result.web_type || result.input_type || ""; - + const columnInfo = { webType: webType, inputType: result.input_type || "", @@ -1633,7 +1846,9 @@ export class TableManagementService { displayColumn: result.display_column || undefined, }; - logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`); + logger.info( + `✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}` + ); return columnInfo; } catch (error) { logger.error( @@ -1747,6 +1962,15 @@ export class TableManagementService { continue; } + // 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외 + // Entity 조인 조회에서만 처리됨 + if (column.includes(".")) { + logger.info( + `🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)` + ); + continue; + } + // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); @@ -2296,6 +2520,14 @@ export class TableManagementService { }>; screenEntityConfigs?: Record; // 화면별 엔티티 설정 dataFilter?: any; // 🆕 데이터 필터 + excludeFilter?: { + enabled: boolean; + referenceTable: string; + referenceColumn: string; + sourceColumn: string; + filterColumn?: string; + filterValue?: any; + }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) } ): Promise { const startTime = Date.now(); @@ -2550,6 +2782,48 @@ export class TableManagementService { } } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) + if (options.excludeFilter && options.excludeFilter.enabled) { + const { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + } = options.excludeFilter; + + if (referenceTable && referenceColumn && sourceColumn) { + // 서브쿼리로 이미 존재하는 데이터 제외 + let excludeSubquery = `main."${sourceColumn}" NOT IN ( + SELECT "${referenceColumn}" FROM "${referenceTable}" + WHERE "${referenceColumn}" IS NOT NULL`; + + // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외) + if ( + filterColumn && + filterValue !== undefined && + filterValue !== null + ) { + excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`; + } + + excludeSubquery += ")"; + + whereClause = whereClause + ? `${whereClause} AND ${excludeSubquery}` + : excludeSubquery; + + logger.info(`🚫 제외 필터 적용 (Entity 조인):`, { + referenceTable, + referenceColumn, + sourceColumn, + filterColumn, + filterValue, + excludeSubquery, + }); + } + } + // ORDER BY 절 구성 const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` @@ -2722,16 +2996,22 @@ export class TableManagementService { }), ]; + // 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식 + const hasJoinTableSearch = + options.search && + Object.keys(options.search).some((key) => key.includes(".")); + const hasEntitySearch = options.search && - Object.keys(options.search).some((key) => + (Object.keys(options.search).some((key) => allEntityColumns.includes(key) - ); + ) || + hasJoinTableSearch); if (hasEntitySearch) { const entitySearchKeys = options.search - ? Object.keys(options.search).filter((key) => - allEntityColumns.includes(key) + ? Object.keys(options.search).filter( + (key) => allEntityColumns.includes(key) || key.includes(".") ) : []; logger.info( @@ -2776,47 +3056,113 @@ export class TableManagementService { if (options.search) { for (const [key, value] of Object.entries(options.search)) { + // 검색값 추출 (객체 형태일 수 있음) + let searchValue = value; + if ( + typeof value === "object" && + value !== null && + "value" in value + ) { + searchValue = value.value; + } + + // 빈 값이면 스킵 + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null || + searchValue === undefined + ) { + continue; + } + + const safeValue = String(searchValue).replace(/'/g, "''"); + + // 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name) + if (key.includes(".")) { + const [refTable, refColumn] = key.split("."); + + // aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식) + let foundAlias: string | undefined; + for (const [aliasKey, alias] of aliasMap.entries()) { + if (aliasKey.startsWith(`${refTable}:`)) { + foundAlias = alias; + break; + } + } + + if (foundAlias) { + whereConditions.push( + `${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (${refTable}.${refColumn})`); + logger.info( + `🎯 조인 테이블 검색: ${key} → ${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})` + ); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음` + ); + } + continue; + } + const joinConfig = joinConfigs.find( (config) => config.aliasColumn === key ); if (joinConfig) { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 - const alias = aliasMap.get(joinConfig.referenceTable); + const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; + const alias = aliasMap.get(aliasKey); whereConditions.push( - `${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'` + `${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` ); entitySearchColumns.push( `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` ); logger.info( - `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})` + `🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` ); } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code에서 검색 - const userAlias = aliasMap.get("user_info"); - whereConditions.push( - `${userAlias}.dept_code ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (user_info.dept_code)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})` + const userAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("user_info:") ); + const userAlias = userAliasKey + ? aliasMap.get(userAliasKey) + : undefined; + if (userAlias) { + whereConditions.push( + `${userAlias}.dept_code ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (user_info.dept_code)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})` + ); + } } else if (key === "company_code_status") { // company_code_status: company_info.status에서 검색 - const companyAlias = aliasMap.get("company_info"); - whereConditions.push( - `${companyAlias}.status ILIKE '%${value}%'` - ); - entitySearchColumns.push(`${key} (company_info.status)`); - logger.info( - `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})` + const companyAliasKey = Array.from(aliasMap.keys()).find((k) => + k.startsWith("company_info:") ); + const companyAlias = companyAliasKey + ? aliasMap.get(companyAliasKey) + : undefined; + if (companyAlias) { + whereConditions.push( + `${companyAlias}.status ILIKE '%${safeValue}%'` + ); + entitySearchColumns.push(`${key} (company_info.status)`); + logger.info( + `🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})` + ); + } } else { // 일반 컬럼인 경우: 메인 테이블에서 검색 - whereConditions.push(`main.${key} ILIKE '%${value}%'`); + whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); logger.info( - `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'` + `🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` ); } } @@ -2956,6 +3302,59 @@ export class TableManagementService { } try { + // 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name) + if (columnName.includes(".")) { + const [refTable, refColumn] = columnName.split("."); + + // 검색값 추출 + let searchValue = value; + if (typeof value === "object" && value !== null && "value" in value) { + searchValue = value.value; + } + + if ( + searchValue === "__ALL__" || + searchValue === "" || + searchValue === null + ) { + continue; + } + + // 🔍 column_labels에서 해당 엔티티 설정 찾기 + // 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info) + const entityColumnResult = await query<{ + column_name: string; + reference_table: string; + reference_column: string; + }>( + `SELECT column_name, reference_table, reference_column + FROM column_labels + WHERE table_name = $1 + AND input_type = 'entity' + AND reference_table = $2 + LIMIT 1`, + [tableName, refTable] + ); + + if (entityColumnResult.length > 0) { + // 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자) + const joinAlias = refTable.substring(0, 3); + + // 조인 테이블 컬럼으로 검색 조건 생성 + const safeValue = String(searchValue).replace(/'/g, "''"); + const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`; + + logger.info(`🔍 조인 테이블 검색 조건: ${condition}`); + conditions.push(condition); + } else { + logger.warn( + `⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음` + ); + } + + continue; + } + // 고급 검색 조건 구성 const searchCondition = await this.buildAdvancedSearchCondition( tableName, @@ -4056,4 +4455,25 @@ export class TableManagementService { throw error; } } + + /** + * 테이블에 특정 컬럼이 존재하는지 확인 + */ + async hasColumn(tableName: string, columnName: string): Promise { + try { + const result = await query( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2`, + [tableName, columnName] + ); + return result.length > 0; + } catch (error) { + logger.error( + `컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, + error + ); + return false; + } + } } diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts new file mode 100644 index 00000000..73577bb0 --- /dev/null +++ b/backend-node/src/services/taxInvoiceService.ts @@ -0,0 +1,784 @@ +/** + * 세금계산서 서비스 + * 세금계산서 CRUD 및 비즈니스 로직 처리 + */ + +import { query, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// 비용 유형 타입 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + +// 세금계산서 타입 정의 +export interface TaxInvoice { + id: string; + company_code: string; + invoice_number: string; + invoice_type: "sales" | "purchase"; // 매출/매입 + invoice_status: "draft" | "issued" | "sent" | "cancelled"; + + // 공급자 정보 + supplier_business_no: string; + supplier_name: string; + supplier_ceo_name: string; + supplier_address: string; + supplier_business_type: string; + supplier_business_item: string; + + // 공급받는자 정보 + buyer_business_no: string; + buyer_name: string; + buyer_ceo_name: string; + buyer_address: string; + buyer_email: string; + + // 금액 정보 + supply_amount: number; + tax_amount: number; + total_amount: number; + + // 날짜 정보 + invoice_date: string; + issue_date: string | null; + + // 기타 + remarks: string; + order_id: string | null; + customer_id: string | null; + + // 첨부파일 (JSON 배열로 저장) + attachments: TaxInvoiceAttachment[] | null; + + // 비용 유형 (구매/설치/수리/유지보수/폐기/기타) + cost_type: CostType | null; + + created_date: string; + updated_date: string; + writer: string; +} + +// 첨부파일 타입 +export interface TaxInvoiceAttachment { + id: string; + file_name: string; + file_path: string; + file_size: number; + file_type: string; + uploaded_at: string; + uploaded_by: string; +} + +export interface TaxInvoiceItem { + id: string; + tax_invoice_id: string; + company_code: string; + item_seq: number; + item_date: string; + item_name: string; + item_spec: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks: string; +} + +export interface CreateTaxInvoiceDto { + invoice_type: "sales" | "purchase"; + supplier_business_no?: string; + supplier_name?: string; + supplier_ceo_name?: string; + supplier_address?: string; + supplier_business_type?: string; + supplier_business_item?: string; + buyer_business_no?: string; + buyer_name?: string; + buyer_ceo_name?: string; + buyer_address?: string; + buyer_email?: string; + supply_amount: number; + tax_amount: number; + total_amount: number; + invoice_date: string; + remarks?: string; + order_id?: string; + customer_id?: string; + items?: CreateTaxInvoiceItemDto[]; + attachments?: TaxInvoiceAttachment[]; // 첨부파일 + cost_type?: CostType; // 비용 유형 +} + +export interface CreateTaxInvoiceItemDto { + item_date?: string; + item_name: string; + item_spec?: string; + quantity: number; + unit_price: number; + supply_amount: number; + tax_amount: number; + remarks?: string; +} + +export interface TaxInvoiceListParams { + page?: number; + pageSize?: number; + invoice_type?: "sales" | "purchase"; + invoice_status?: string; + start_date?: string; + end_date?: string; + search?: string; + buyer_name?: string; + cost_type?: CostType; // 비용 유형 필터 +} + +export class TaxInvoiceService { + /** + * 세금계산서 번호 채번 + * 형식: YYYYMM-NNNNN (예: 202512-00001) + */ + static async generateInvoiceNumber(companyCode: string): Promise { + const now = new Date(); + const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`; + const prefix = `${yearMonth}-`; + + // 해당 월의 마지막 번호 조회 + const result = await query<{ max_num: string }>( + `SELECT invoice_number as max_num + FROM tax_invoice + WHERE company_code = $1 + AND invoice_number LIKE $2 + ORDER BY invoice_number DESC + LIMIT 1`, + [companyCode, `${prefix}%`] + ); + + let nextNum = 1; + if (result.length > 0 && result[0].max_num) { + const lastNum = parseInt(result[0].max_num.split("-")[1], 10); + nextNum = lastNum + 1; + } + + return `${prefix}${String(nextNum).padStart(5, "0")}`; + } + + /** + * 세금계산서 목록 조회 + */ + static async getList( + companyCode: string, + params: TaxInvoiceListParams + ): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> { + const { + page = 1, + pageSize = 20, + invoice_type, + invoice_status, + start_date, + end_date, + search, + buyer_name, + cost_type, + } = params; + + const offset = (page - 1) * pageSize; + const conditions: string[] = ["company_code = $1"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + if (invoice_type) { + conditions.push(`invoice_type = $${paramIndex}`); + values.push(invoice_type); + paramIndex++; + } + + if (invoice_status) { + conditions.push(`invoice_status = $${paramIndex}`); + values.push(invoice_status); + paramIndex++; + } + + if (start_date) { + conditions.push(`invoice_date >= $${paramIndex}`); + values.push(start_date); + paramIndex++; + } + + if (end_date) { + conditions.push(`invoice_date <= $${paramIndex}`); + values.push(end_date); + paramIndex++; + } + + if (search) { + conditions.push( + `(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})` + ); + values.push(`%${search}%`); + paramIndex++; + } + + if (buyer_name) { + conditions.push(`buyer_name ILIKE $${paramIndex}`); + values.push(`%${buyer_name}%`); + paramIndex++; + } + + if (cost_type) { + conditions.push(`cost_type = $${paramIndex}`); + values.push(cost_type); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 전체 개수 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`, + values + ); + const total = parseInt(countResult[0]?.count || "0", 10); + + // 데이터 조회 + values.push(pageSize, offset); + const data = await query( + `SELECT * FROM tax_invoice + WHERE ${whereClause} + ORDER BY created_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + values + ); + + return { data, total, page, pageSize }; + } + + /** + * 세금계산서 상세 조회 (품목 포함) + */ + static async getById( + id: string, + companyCode: string + ): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> { + const invoiceResult = await query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (invoiceResult.length === 0) { + return null; + } + + const items = await query( + `SELECT * FROM tax_invoice_item + WHERE tax_invoice_id = $1 AND company_code = $2 + ORDER BY item_seq`, + [id, companyCode] + ); + + return { invoice: invoiceResult[0], items }; + } + + /** + * 세금계산서 생성 + */ + static async create( + data: CreateTaxInvoiceDto, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 세금계산서 번호 채번 + const invoiceNumber = await this.generateInvoiceNumber(companyCode); + + // 세금계산서 생성 + const invoiceResult = await client.query( + `INSERT INTO tax_invoice ( + company_code, invoice_number, invoice_type, invoice_status, + supplier_business_no, supplier_name, supplier_ceo_name, supplier_address, + supplier_business_type, supplier_business_item, + buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email, + supply_amount, tax_amount, total_amount, invoice_date, + remarks, order_id, customer_id, attachments, cost_type, writer + ) VALUES ( + $1, $2, $3, 'draft', + $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20, $21, $22, $23, $24 + ) RETURNING *`, + [ + companyCode, + invoiceNumber, + data.invoice_type, + data.supplier_business_no || null, + data.supplier_name || null, + data.supplier_ceo_name || null, + data.supplier_address || null, + data.supplier_business_type || null, + data.supplier_business_item || null, + data.buyer_business_no || null, + data.buyer_name || null, + data.buyer_ceo_name || null, + data.buyer_address || null, + data.buyer_email || null, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks || null, + data.order_id || null, + data.customer_id || null, + data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type || null, + userId, + ] + ); + + const invoice = invoiceResult.rows[0]; + + // 품목 생성 + if (data.items && data.items.length > 0) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + invoice.id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 생성 완료", { + invoiceId: invoice.id, + invoiceNumber, + companyCode, + userId, + }); + + return invoice; + }); + } + + /** + * 세금계산서 수정 + */ + static async update( + id: string, + data: Partial, + companyCode: string, + userId: string + ): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return null; + } + + // 발행된 세금계산서는 수정 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 수정할 수 없습니다."); + } + + // 세금계산서 수정 + const updateResult = await client.query( + `UPDATE tax_invoice SET + supplier_business_no = COALESCE($3, supplier_business_no), + supplier_name = COALESCE($4, supplier_name), + supplier_ceo_name = COALESCE($5, supplier_ceo_name), + supplier_address = COALESCE($6, supplier_address), + supplier_business_type = COALESCE($7, supplier_business_type), + supplier_business_item = COALESCE($8, supplier_business_item), + buyer_business_no = COALESCE($9, buyer_business_no), + buyer_name = COALESCE($10, buyer_name), + buyer_ceo_name = COALESCE($11, buyer_ceo_name), + buyer_address = COALESCE($12, buyer_address), + buyer_email = COALESCE($13, buyer_email), + supply_amount = COALESCE($14, supply_amount), + tax_amount = COALESCE($15, tax_amount), + total_amount = COALESCE($16, total_amount), + invoice_date = COALESCE($17, invoice_date), + remarks = COALESCE($18, remarks), + attachments = $19, + cost_type = COALESCE($20, cost_type), + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING *`, + [ + id, + companyCode, + data.supplier_business_no, + data.supplier_name, + data.supplier_ceo_name, + data.supplier_address, + data.supplier_business_type, + data.supplier_business_item, + data.buyer_business_no, + data.buyer_name, + data.buyer_ceo_name, + data.buyer_address, + data.buyer_email, + data.supply_amount, + data.tax_amount, + data.total_amount, + data.invoice_date, + data.remarks, + data.attachments ? JSON.stringify(data.attachments) : null, + data.cost_type, + ] + ); + + // 품목 업데이트 (기존 삭제 후 재생성) + if (data.items) { + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + await client.query( + `INSERT INTO tax_invoice_item ( + tax_invoice_id, company_code, item_seq, + item_date, item_name, item_spec, quantity, unit_price, + supply_amount, tax_amount, remarks + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + companyCode, + i + 1, + item.item_date || null, + item.item_name, + item.item_spec || null, + item.quantity, + item.unit_price, + item.supply_amount, + item.tax_amount, + item.remarks || null, + ] + ); + } + } + + logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId }); + + return updateResult.rows[0]; + }); + } + + /** + * 세금계산서 삭제 + */ + static async delete(id: string, companyCode: string, userId: string): Promise { + return await transaction(async (client) => { + // 기존 세금계산서 확인 + const existing = await client.query( + `SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`, + [id, companyCode] + ); + + if (existing.rows.length === 0) { + return false; + } + + // 발행된 세금계산서는 삭제 불가 + if (existing.rows[0].invoice_status !== "draft") { + throw new Error("발행된 세금계산서는 삭제할 수 없습니다."); + } + + // 품목 삭제 + await client.query( + `DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`, + [id, companyCode] + ); + + // 세금계산서 삭제 + await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [ + id, + companyCode, + ]); + + logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId }); + + return true; + }); + } + + /** + * 세금계산서 발행 (상태 변경) + */ + static async issue(id: string, companyCode: string, userId: string): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'issued', + issue_date = NOW(), + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft' + RETURNING *`, + [id, companyCode] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId }); + + return result[0]; + } + + /** + * 세금계산서 취소 + */ + static async cancel( + id: string, + companyCode: string, + userId: string, + reason?: string + ): Promise { + const result = await query( + `UPDATE tax_invoice SET + invoice_status = 'cancelled', + remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued') + RETURNING *`, + [id, companyCode, reason || null] + ); + + if (result.length === 0) { + return null; + } + + logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason }); + + return result[0]; + } + + /** + * 월별 통계 조회 + */ + static async getMonthlyStats( + companyCode: string, + year: number, + month: number + ): Promise<{ + sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number }; + }> { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날 + + const result = await query<{ + invoice_type: string; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + invoice_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE company_code = $1 + AND invoice_date >= $2 + AND invoice_date <= $3 + AND invoice_status != 'cancelled' + GROUP BY invoice_type`, + [companyCode, startDate, endDate] + ); + + const stats = { + sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 }, + }; + + for (const row of result) { + const type = row.invoice_type as "sales" | "purchase"; + stats[type] = { + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + }; + } + + return stats; + } + + /** + * 비용 유형별 통계 조회 + */ + static async getCostTypeStats( + companyCode: string, + year?: number, + month?: number + ): Promise<{ + by_cost_type: Array<{ + cost_type: CostType | null; + count: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + }>; + by_month: Array<{ + year_month: string; + cost_type: CostType | null; + count: number; + total_amount: number; + }>; + summary: { + total_count: number; + total_amount: number; + purchase_amount: number; + installation_amount: number; + repair_amount: number; + maintenance_amount: number; + disposal_amount: number; + other_amount: number; + }; + }> { + const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"]; + const values: any[] = [companyCode]; + let paramIndex = 2; + + // 연도/월 필터 + if (year && month) { + const startDate = `${year}-${String(month).padStart(2, "0")}-01`; + const endDate = new Date(year, month, 0).toISOString().split("T")[0]; + conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`); + values.push(startDate, endDate); + paramIndex += 2; + } else if (year) { + conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`); + values.push(year); + paramIndex++; + } + + const whereClause = conditions.join(" AND "); + + // 비용 유형별 집계 + const byCostType = await query<{ + cost_type: CostType | null; + count: string; + supply_amount: string; + tax_amount: string; + total_amount: string; + }>( + `SELECT + cost_type, + COUNT(*) as count, + COALESCE(SUM(supply_amount), 0) as supply_amount, + COALESCE(SUM(tax_amount), 0) as tax_amount, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY cost_type + ORDER BY total_amount DESC`, + values + ); + + // 월별 비용 유형 집계 + const byMonth = await query<{ + year_month: string; + cost_type: CostType | null; + count: string; + total_amount: string; + }>( + `SELECT + TO_CHAR(invoice_date, 'YYYY-MM') as year_month, + cost_type, + COUNT(*) as count, + COALESCE(SUM(total_amount), 0) as total_amount + FROM tax_invoice + WHERE ${whereClause} + GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type + ORDER BY year_month DESC, cost_type`, + values + ); + + // 전체 요약 + const summaryResult = await query<{ + total_count: string; + total_amount: string; + purchase_amount: string; + installation_amount: string; + repair_amount: string; + maintenance_amount: string; + disposal_amount: string; + other_amount: string; + }>( + `SELECT + COUNT(*) as total_count, + COALESCE(SUM(total_amount), 0) as total_amount, + COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount, + COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount, + COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount, + COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount, + COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount, + COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount + FROM tax_invoice + WHERE ${whereClause}`, + values + ); + + const summary = summaryResult[0] || { + total_count: "0", + total_amount: "0", + purchase_amount: "0", + installation_amount: "0", + repair_amount: "0", + maintenance_amount: "0", + disposal_amount: "0", + other_amount: "0", + }; + + return { + by_cost_type: byCostType.map((row) => ({ + cost_type: row.cost_type, + count: parseInt(row.count, 10), + supply_amount: parseFloat(row.supply_amount), + tax_amount: parseFloat(row.tax_amount), + total_amount: parseFloat(row.total_amount), + })), + by_month: byMonth.map((row) => ({ + year_month: row.year_month, + cost_type: row.cost_type, + count: parseInt(row.count, 10), + total_amount: parseFloat(row.total_amount), + })), + summary: { + total_count: parseInt(summary.total_count, 10), + total_amount: parseFloat(summary.total_amount), + purchase_amount: parseFloat(summary.purchase_amount), + installation_amount: parseFloat(summary.installation_amount), + repair_amount: parseFloat(summary.repair_amount), + maintenance_amount: parseFloat(summary.maintenance_amount), + disposal_amount: parseFloat(summary.disposal_amount), + other_amount: parseFloat(summary.other_amount), + }, + }; + } +} + diff --git a/backend-node/src/services/vehicleReportService.ts b/backend-node/src/services/vehicleReportService.ts new file mode 100644 index 00000000..842dff19 --- /dev/null +++ b/backend-node/src/services/vehicleReportService.ts @@ -0,0 +1,403 @@ +/** + * 차량 운행 리포트 서비스 + */ +import { getPool } from "../database/db"; + +interface DailyReportFilters { + startDate?: string; + endDate?: string; + userId?: string; + vehicleId?: number; +} + +interface WeeklyReportFilters { + year: number; + month: number; + userId?: string; + vehicleId?: number; +} + +interface MonthlyReportFilters { + year: number; + userId?: string; + vehicleId?: number; +} + +interface DriverReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +interface RouteReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +class VehicleReportService { + private get pool() { + return getPool(); + } + + /** + * 일별 통계 조회 + */ + async getDailyReport(companyCode: string, filters: DailyReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + // 기본값: 최근 30일 + const endDate = filters.endDate || new Date().toISOString().split("T")[0]; + const startDate = + filters.startDate || + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(startDate); + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(endDate); + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + DATE(start_time) as date, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY DATE(start_time) + ORDER BY DATE(start_time) DESC + `; + + const result = await this.pool.query(query, params); + + return { + startDate, + endDate, + data: result.rows.map((row) => ({ + date: row.date, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })), + }; + } + + /** + * 주별 통계 조회 + */ + async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) { + const { year, month, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`); + params.push(month); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(WEEK FROM start_time) as week_number, + MIN(DATE(start_time)) as week_start, + MAX(DATE(start_time)) as week_end, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(WEEK FROM start_time) + ORDER BY week_number + `; + + const result = await this.pool.query(query, params); + + return { + year, + month, + data: result.rows.map((row) => ({ + weekNumber: parseInt(row.week_number), + weekStart: row.week_start, + weekEnd: row.week_end, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })), + }; + } + + /** + * 월별 통계 조회 + */ + async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) { + const { year, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(MONTH FROM start_time) as month, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as driver_count + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(MONTH FROM start_time) + ORDER BY month + `; + + const result = await this.pool.query(query, params); + + return { + year, + data: result.rows.map((row) => ({ + month: parseInt(row.month), + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + driverCount: parseInt(row.driver_count), + })), + }; + } + + /** + * 요약 통계 조회 (대시보드용) + */ + async getSummaryReport(companyCode: string, period: string) { + let dateCondition = ""; + + switch (period) { + case "today": + dateCondition = "DATE(start_time) = CURRENT_DATE"; + break; + case "week": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'"; + break; + case "month": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'"; + break; + case "year": + dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)"; + break; + default: + dateCondition = "DATE(start_time) = CURRENT_DATE"; + } + + const query = ` + SELECT + COUNT(*) as total_trips, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as active_drivers + FROM vehicle_trip_summary + WHERE company_code = $1 AND ${dateCondition} + `; + + const result = await this.pool.query(query, [companyCode]); + const row = result.rows[0]; + + // 완료율 계산 + const totalTrips = parseInt(row.total_trips) || 0; + const completedTrips = parseInt(row.completed_trips) || 0; + const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0; + + return { + period, + totalTrips, + completedTrips, + activeTrips: parseInt(row.active_trips) || 0, + cancelledTrips: parseInt(row.cancelled_trips) || 0, + completionRate: parseFloat(completionRate.toFixed(1)), + totalDistance: parseFloat(row.total_distance) || 0, + totalDuration: parseInt(row.total_duration) || 0, + avgDistance: parseFloat(row.avg_distance) || 0, + avgDuration: parseFloat(row.avg_duration) || 0, + activeDrivers: parseInt(row.active_drivers) || 0, + }; + } + + /** + * 운전자별 통계 조회 + */ + async getDriverReport(companyCode: string, filters: DriverReportFilters) { + const conditions: string[] = ["vts.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + vts.user_id, + ui.user_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + WHERE ${whereClause} + GROUP BY vts.user_id, ui.user_name + ORDER BY total_distance DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + userId: row.user_id, + userName: row.user_name || row.user_id, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })); + } + + /** + * 구간별 통계 조회 + */ + async getRouteReport(companyCode: string, filters: RouteReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + // 출발지/도착지가 있는 것만 + conditions.push("departure IS NOT NULL"); + conditions.push("arrival IS NOT NULL"); + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + departure, + arrival, + departure_name, + destination_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY departure, arrival, departure_name, destination_name + ORDER BY trip_count DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + departure: row.departure, + arrival: row.arrival, + departureName: row.departure_name || row.departure, + destinationName: row.destination_name || row.arrival, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })); + } +} + +export const vehicleReportService = new VehicleReportService(); + diff --git a/backend-node/src/services/vehicleTripService.ts b/backend-node/src/services/vehicleTripService.ts new file mode 100644 index 00000000..ee640e24 --- /dev/null +++ b/backend-node/src/services/vehicleTripService.ts @@ -0,0 +1,456 @@ +/** + * 차량 운행 이력 서비스 + */ +import { getPool } from "../database/db"; +import { v4 as uuidv4 } from "uuid"; +import { calculateDistance } from "../utils/geoUtils"; + +interface StartTripParams { + userId: string; + companyCode: string; + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +interface EndTripParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; +} + +interface AddLocationParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +class VehicleTripService { + private get pool() { + return getPool(); + } + + /** + * 운행 시작 + */ + async startTrip(params: StartTripParams) { + const { + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + } = params; + + const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`; + + // 1. vehicle_trip_summary에 운행 기록 생성 + const summaryQuery = ` + INSERT INTO vehicle_trip_summary ( + trip_id, user_id, vehicle_id, departure, arrival, + departure_name, destination_name, start_time, status, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8) + RETURNING * + `; + + const summaryResult = await this.pool.query(summaryQuery, [ + tripId, + userId, + vehicleId || null, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + // 2. 시작 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + vehicleId || null, + latitude, + longitude, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + return { + tripId, + summary: summaryResult.rows[0], + startLocation: { latitude, longitude }, + }; + } + + /** + * 운행 종료 + */ + async endTrip(params: EndTripParams) { + const { tripId, userId, companyCode, latitude, longitude } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 마지막 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 3. 총 거리 및 위치 수 계산 + const statsQuery = ` + SELECT + COUNT(*) as location_count, + MIN(recorded_at) as start_time, + MAX(recorded_at) as end_time + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + `; + const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]); + const stats = statsResult.rows[0]; + + // 4. 모든 위치 데이터로 총 거리 계산 + const locationsQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]); + + let totalDistance = 0; + const locations = locationsResult.rows; + for (let i = 1; i < locations.length; i++) { + const prev = locations[i - 1]; + const curr = locations[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + // 5. 운행 시간 계산 (분) + const startTime = new Date(stats.start_time); + const endTime = new Date(stats.end_time); + const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000); + + // 6. 운행 요약 업데이트 + const updateQuery = ` + UPDATE vehicle_trip_summary + SET + end_time = NOW(), + total_distance = $1, + duration_minutes = $2, + location_count = $3, + status = 'completed' + WHERE trip_id = $4 AND company_code = $5 + RETURNING * + `; + + const updateResult = await this.pool.query(updateQuery, [ + totalDistance.toFixed(3), + durationMinutes, + stats.location_count, + tripId, + companyCode, + ]); + + return { + tripId, + summary: updateResult.rows[0], + totalDistance: parseFloat(totalDistance.toFixed(3)), + durationMinutes, + locationCount: parseInt(stats.location_count), + }; + } + + /** + * 위치 기록 추가 (연속 추적) + */ + async addLocation(params: AddLocationParams) { + const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 이전 위치 조회 (거리 계산용) + const prevLocationQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at DESC + LIMIT 1 + `; + const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]); + + let distanceFromPrev = 0; + if (prevResult.rows.length > 0) { + const prev = prevResult.rows[0]; + distanceFromPrev = calculateDistance( + prev.latitude, + prev.longitude, + latitude, + longitude + ); + } + + // 3. 위치 기록 추가 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + accuracy, speed, distance_from_prev, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13) + RETURNING id + `; + + const result = await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + accuracy || null, + speed || null, + distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 4. 운행 요약의 위치 수 업데이트 + await this.pool.query( + `UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`, + [tripId] + ); + + return { + locationId: result.rows[0].id, + distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)), + }; + } + + /** + * 운행 이력 목록 조회 + */ + async getTripList(companyCode: string, filters: TripListFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`start_time >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`start_time <= $${paramIndex++}`); + params.push(filters.endDate + " 23:59:59"); + } + + if (filters.departure) { + conditions.push(`departure = $${paramIndex++}`); + params.push(filters.departure); + } + + if (filters.arrival) { + conditions.push(`arrival = $${paramIndex++}`); + params.push(filters.arrival); + } + + const whereClause = conditions.join(" AND "); + + // 총 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`; + const countResult = await this.pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 목록 조회 + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const listQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE ${whereClause} + ORDER BY vts.start_time DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `; + + params.push(limit, offset); + const listResult = await this.pool.query(listQuery, params); + + return { + data: listResult.rows, + total, + }; + } + + /** + * 운행 상세 조회 (경로 포함) + */ + async getTripDetail(tripId: string, companyCode: string) { + // 1. 운행 요약 조회 + const summaryQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE vts.trip_id = $1 AND vts.company_code = $2 + `; + const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]); + + if (summaryResult.rows.length === 0) { + return null; + } + + // 2. 경로 데이터 조회 + const routeQuery = ` + SELECT + id, latitude, longitude, accuracy, speed, + distance_from_prev, trip_status, recorded_at + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]); + + return { + summary: summaryResult.rows[0], + route: routeResult.rows, + }; + } + + /** + * 활성 운행 조회 + */ + async getActiveTrip(userId: string, companyCode: string) { + const query = ` + SELECT * FROM vehicle_trip_summary + WHERE user_id = $1 AND company_code = $2 AND status = 'active' + ORDER BY start_time DESC + LIMIT 1 + `; + const result = await this.pool.query(query, [userId, companyCode]); + return result.rows[0] || null; + } + + /** + * 운행 취소 + */ + async cancelTrip(tripId: string, companyCode: string) { + const query = ` + UPDATE vehicle_trip_summary + SET status = 'cancelled', end_time = NOW() + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + RETURNING * + `; + const result = await this.pool.query(query, [tripId, companyCode]); + return result.rows[0] || null; + } +} + +export const vehicleTripService = new VehicleTripService(); diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts index d966de7c..aa49fd4e 100644 --- a/backend-node/src/types/batchExecutionLogTypes.ts +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -4,6 +4,7 @@ export interface BatchExecutionLog { id?: number; batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time: Date; end_time?: Date | null; @@ -19,6 +20,7 @@ export interface BatchExecutionLog { export interface CreateBatchExecutionLogRequest { batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time?: Date; end_time?: Date | null; diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 24158a3d..a6404036 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,139 +1,8 @@ // 배치관리 타입 정의 // 작성일: 2024-12-24 -// 배치 타입 정의 -export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; - -export interface BatchTypeOption { - value: BatchType; - label: string; - description: string; -} - -export interface BatchConfig { - id?: number; - batch_name: string; - description?: string; - cron_schedule: string; - is_active?: string; - company_code?: string; - created_date?: Date; - created_by?: string; - updated_date?: Date; - updated_by?: string; - batch_mappings?: BatchMapping[]; -} - -export interface BatchMapping { - id?: number; - batch_config_id?: number; - - // FROM 정보 - from_connection_type: 'internal' | 'external' | 'restapi'; - from_connection_id?: number; - from_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - from_column_type?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - from_api_url?: string; // REST API 서버 URL - from_api_key?: string; // REST API 키 - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 - from_api_param_name?: string; // API 파라미터명 - from_api_param_value?: string; // API 파라미터 값 또는 템플릿 - from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - - // TO 정보 - to_connection_type: 'internal' | 'external' | 'restapi'; - to_connection_id?: number; - to_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - to_column_type?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - to_api_url?: string; // REST API 서버 URL - to_api_key?: string; // REST API 키 - to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) - - mapping_order?: number; - created_date?: Date; - created_by?: string; -} - -export interface BatchConfigFilter { - page?: number; - limit?: number; - batch_name?: string; - is_active?: string; - company_code?: string; - search?: string; -} - -export interface ConnectionInfo { - type: 'internal' | 'external'; - id?: number; - name: string; - db_type?: string; -} - -export interface TableInfo { - table_name: string; - columns: ColumnInfo[]; - description?: string | null; -} - -export interface ColumnInfo { - column_name: string; - data_type: string; - is_nullable?: string; - column_default?: string | null; -} - -export interface BatchMappingRequest { - from_connection_type: 'internal' | 'external' | 'restapi'; - from_connection_id?: number; - from_table_name: string; - from_column_name: string; - from_column_type?: string; - from_api_url?: string; - from_api_key?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 - from_api_param_name?: string; // API 파라미터명 - from_api_param_value?: string; // API 파라미터 값 또는 템플릿 - from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - to_connection_type: 'internal' | 'external' | 'restapi'; - to_connection_id?: number; - to_table_name: string; - to_column_name: string; - to_column_type?: string; - to_api_url?: string; - to_api_key?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) - mapping_order?: number; -} - -export interface CreateBatchConfigRequest { - batchName: string; - description?: string; - cronSchedule: string; - mappings: BatchMappingRequest[]; -} - -export interface UpdateBatchConfigRequest { - batchName?: string; - description?: string; - cronSchedule?: string; - mappings?: BatchMappingRequest[]; - isActive?: string; -} - -export interface BatchValidationResult { - isValid: boolean; - errors: string[]; - warnings?: string[]; -} - -export interface ApiResponse { +// 공통 API 응답 타입 +export interface ApiResponse { success: boolean; data?: T; message?: string; @@ -145,3 +14,158 @@ export interface ApiResponse { totalPages: number; }; } + +// 컬럼 정보 타입 +export interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +// 테이블 정보 타입 +export interface TableInfo { + table_name: string; + table_type?: string; + table_schema?: string; +} + +// 연결 정보 타입 +export interface ConnectionInfo { + type: "internal" | "external"; + id?: number; + name: string; + db_type?: string; +} + +// 배치 설정 필터 타입 +export interface BatchConfigFilter { + page?: number; + limit?: number; + search?: string; + is_active?: string; + company_code?: string; +} + +// 배치 매핑 타입 +export interface BatchMapping { + id?: number; + batch_config_id?: number; + company_code?: string; + from_connection_type: "internal" | "external" | "restapi"; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + from_api_url?: string; + from_api_key?: string; + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + from_api_param_type?: "url" | "query"; + from_api_param_name?: string; + from_api_param_value?: string; + from_api_param_source?: "static" | "dynamic"; + from_api_body?: string; + to_connection_type: "internal" | "external" | "restapi"; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + to_api_body?: string; + mapping_order?: number; + created_by?: string; + created_date?: Date; +} + +// 배치 설정 타입 +export interface BatchConfig { + id?: number; + batch_name: string; + description?: string; + cron_schedule: string; + is_active: "Y" | "N"; + company_code?: string; + save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT) + conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 + auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 + data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) + created_by?: string; + created_date?: Date; + updated_by?: string; + updated_date?: Date; + batch_mappings?: BatchMapping[]; +} + +export interface BatchConnectionInfo { + type: "internal" | "external"; + id?: number; + name: string; + db_type?: string; +} + +export interface BatchColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +export interface BatchMappingRequest { + from_connection_type: "internal" | "external" | "restapi" | "fixed"; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + from_api_url?: string; + from_api_key?: string; + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + from_api_param_type?: "url" | "query"; // API 파라미터 타입 + from_api_param_name?: string; // API 파라미터명 + from_api_param_value?: string; // API 파라미터 값 또는 템플릿 + from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입 + // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) + from_api_body?: string; + to_connection_type: "internal" | "external" | "restapi"; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) + mapping_order?: number; + mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값) +} + +export interface CreateBatchConfigRequest { + batchName: string; + description?: string; + cronSchedule: string; + isActive: "Y" | "N"; + companyCode: string; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + mappings: BatchMappingRequest[]; +} + +export interface UpdateBatchConfigRequest { + batchName?: string; + description?: string; + cronSchedule?: string; + isActive?: "Y" | "N"; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + mappings?: BatchMappingRequest[]; +} + +export interface BatchValidationResult { + isValid: boolean; + errors: string[]; +} diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 35877974..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -1,6 +1,12 @@ // 외부 REST API 연결 관리 타입 정의 -export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; +export type AuthType = + | "none" + | "api-key" + | "bearer" + | "basic" + | "oauth2" + | "db-token"; export interface ExternalRestApiConnection { id?: number; @@ -9,6 +15,11 @@ export interface ExternalRestApiConnection { base_url: string; endpoint_path?: string; default_headers: Record; + + // 기본 메서드 및 바디 추가 + default_method?: string; + default_body?: string; + auth_type: AuthType; auth_config?: { // API Key @@ -28,12 +39,23 @@ export interface ExternalRestApiConnection { clientSecret?: string; tokenUrl?: string; accessToken?: string; + + // DB 기반 토큰 모드 + dbTableName?: string; + dbValueColumn?: string; + dbWhereColumn?: string; + dbWhereValue?: string; + dbHeaderName?: string; + dbHeaderTemplate?: string; }; timeout?: number; retry_count?: number; retry_delay?: number; company_code: string; is_active: string; + + // 위치 이력 저장 설정 (지도 위젯용) + save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장 created_date?: Date; created_by?: string; updated_date?: Date; @@ -54,8 +76,9 @@ export interface RestApiTestRequest { id?: number; base_url: string; endpoint?: string; - method?: "GET" | "POST" | "PUT" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; headers?: Record; + body?: any; // 테스트 요청 바디 추가 auth_type?: AuthType; auth_config?: any; timeout?: number; @@ -76,4 +99,5 @@ export const AUTH_TYPE_OPTIONS = [ { value: "bearer", label: "Bearer Token" }, { value: "basic", label: "Basic Auth" }, { value: "oauth2", label: "OAuth 2.0" }, + { value: "db-token", label: "DB 토큰" }, ]; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c127eccc..9f105a49 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -2,14 +2,38 @@ * 플로우 관리 시스템 타입 정의 */ +// 다중 REST API 연결 설정 +export interface RestApiConnectionConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") +} + +// 다중 외부 DB 연결 설정 +export interface ExternalDbConnectionConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") +} + // 플로우 정의 export interface FlowDefinition { id: number; name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + // REST API 관련 필드 (단일) + restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -22,8 +46,14 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + // REST API 관련 필드 (단일) + restApiConnectionId?: number; // REST API 연결 ID + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index ca5a466f..8260f3c6 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -154,6 +154,11 @@ export interface ScreenDefinition { updatedBy?: string; dbSourceType?: "internal" | "external"; dbConnectionId?: number; + // REST API 관련 필드 + dataSourceType?: "database" | "restapi"; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; } // 화면 생성 요청 @@ -166,6 +171,11 @@ export interface CreateScreenRequest { createdBy?: string; dbSourceType?: "internal" | "external"; dbConnectionId?: number; + // REST API 관련 필드 + dataSourceType?: "database" | "restapi"; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; } // 화면 수정 요청 diff --git a/backend-node/src/utils/geoUtils.ts b/backend-node/src/utils/geoUtils.ts new file mode 100644 index 00000000..50f370ad --- /dev/null +++ b/backend-node/src/utils/geoUtils.ts @@ -0,0 +1,176 @@ +/** + * 지리 좌표 관련 유틸리티 함수 + */ + +/** + * Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km) + * + * @param lat1 - 첫 번째 지점의 위도 + * @param lon1 - 첫 번째 지점의 경도 + * @param lat2 - 두 번째 지점의 위도 + * @param lon2 - 두 번째 지점의 경도 + * @returns 두 지점 간의 거리 (km) + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // 지구 반경 (km) + + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * 각도를 라디안으로 변환 + */ +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} + +/** + * 라디안을 각도로 변환 + */ +export function toDegrees(radians: number): number { + return radians * (180 / Math.PI); +} + +/** + * 좌표 배열에서 총 거리 계산 + * + * @param coordinates - { latitude, longitude }[] 형태의 좌표 배열 + * @returns 총 거리 (km) + */ +export function calculateTotalDistance( + coordinates: Array<{ latitude: number; longitude: number }> +): number { + let totalDistance = 0; + + for (let i = 1; i < coordinates.length; i++) { + const prev = coordinates[i - 1]; + const curr = coordinates[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + return totalDistance; +} + +/** + * 좌표가 특정 반경 내에 있는지 확인 + * + * @param centerLat - 중심점 위도 + * @param centerLon - 중심점 경도 + * @param pointLat - 확인할 지점의 위도 + * @param pointLon - 확인할 지점의 경도 + * @param radiusKm - 반경 (km) + * @returns 반경 내에 있으면 true + */ +export function isWithinRadius( + centerLat: number, + centerLon: number, + pointLat: number, + pointLon: number, + radiusKm: number +): boolean { + const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon); + return distance <= radiusKm; +} + +/** + * 두 좌표 사이의 방위각(bearing) 계산 + * + * @param lat1 - 시작점 위도 + * @param lon1 - 시작점 경도 + * @param lat2 - 도착점 위도 + * @param lon2 - 도착점 경도 + * @returns 방위각 (0-360도) + */ +export function calculateBearing( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const dLon = toRadians(lon2 - lon1); + const lat1Rad = toRadians(lat1); + const lat2Rad = toRadians(lat2); + + const x = Math.sin(dLon) * Math.cos(lat2Rad); + const y = + Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + + let bearing = toDegrees(Math.atan2(x, y)); + bearing = (bearing + 360) % 360; // 0-360 범위로 정규화 + + return bearing; +} + +/** + * 좌표 배열의 경계 상자(bounding box) 계산 + * + * @param coordinates - 좌표 배열 + * @returns { minLat, maxLat, minLon, maxLon } + */ +export function getBoundingBox( + coordinates: Array<{ latitude: number; longitude: number }> +): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null { + if (coordinates.length === 0) return null; + + let minLat = coordinates[0].latitude; + let maxLat = coordinates[0].latitude; + let minLon = coordinates[0].longitude; + let maxLon = coordinates[0].longitude; + + for (const coord of coordinates) { + minLat = Math.min(minLat, coord.latitude); + maxLat = Math.max(maxLat, coord.latitude); + minLon = Math.min(minLon, coord.longitude); + maxLon = Math.max(maxLon, coord.longitude); + } + + return { minLat, maxLat, minLon, maxLon }; +} + +/** + * 좌표 배열의 중심점 계산 + * + * @param coordinates - 좌표 배열 + * @returns { latitude, longitude } 중심점 + */ +export function getCenterPoint( + coordinates: Array<{ latitude: number; longitude: number }> +): { latitude: number; longitude: number } | null { + if (coordinates.length === 0) return null; + + let sumLat = 0; + let sumLon = 0; + + for (const coord of coordinates) { + sumLat += coord.latitude; + sumLon += coord.longitude; + } + + return { + latitude: sumLat / coordinates.length, + longitude: sumLon / coordinates.length, + }; +} diff --git a/db/migrations/RUN_063_064_MIGRATION.md b/db/migrations/RUN_063_064_MIGRATION.md new file mode 100644 index 00000000..98ca3b90 --- /dev/null +++ b/db/migrations/RUN_063_064_MIGRATION.md @@ -0,0 +1,238 @@ +# 마이그레이션 063-064: 재고 관리 테이블 생성 + +## 목적 + +재고 현황 관리 및 입출고 이력 추적을 위한 테이블 생성 + +**테이블 타입관리 UI와 동일한 방식으로 생성됩니다.** + +### 생성되는 테이블 + +| 테이블명 | 설명 | 용도 | +|----------|------|------| +| `inventory_stock` | 재고 현황 | 품목+로트별 현재 재고 상태 | +| `inventory_history` | 재고 이력 | 입출고 트랜잭션 기록 | + +--- + +## 테이블 타입관리 UI 방식 특징 + +1. **기본 컬럼 자동 포함**: `id`, `created_date`, `updated_date`, `writer`, `company_code` +2. **데이터 타입 통일**: 날짜는 `TIMESTAMP`, 나머지는 `VARCHAR(500)` +3. **메타데이터 등록**: + - `table_labels`: 테이블 정보 + - `column_labels`: 컬럼 정보 (라벨, input_type, detail_settings) + - `table_type_columns`: 회사별 컬럼 타입 정보 + +--- + +## 테이블 구조 + +### 1. inventory_stock (재고 현황) + +| 컬럼명 | 타입 | input_type | 설명 | +|--------|------|------------|------| +| id | VARCHAR(500) | text | PK (자동생성) | +| created_date | TIMESTAMP | date | 생성일시 | +| updated_date | TIMESTAMP | date | 수정일시 | +| writer | VARCHAR(500) | text | 작성자 | +| company_code | VARCHAR(500) | text | 회사코드 | +| item_code | VARCHAR(500) | text | 품목코드 | +| lot_number | VARCHAR(500) | text | 로트번호 | +| warehouse_id | VARCHAR(500) | entity | 창고 (FK → warehouse_info) | +| location_code | VARCHAR(500) | text | 위치코드 | +| current_qty | VARCHAR(500) | number | 현재고량 | +| safety_qty | VARCHAR(500) | number | 안전재고 | +| last_in_date | TIMESTAMP | date | 최종입고일 | +| last_out_date | TIMESTAMP | date | 최종출고일 | + +### 2. inventory_history (재고 이력) + +| 컬럼명 | 타입 | input_type | 설명 | +|--------|------|------------|------| +| id | VARCHAR(500) | text | PK (자동생성) | +| created_date | TIMESTAMP | date | 생성일시 | +| updated_date | TIMESTAMP | date | 수정일시 | +| writer | VARCHAR(500) | text | 작성자 | +| company_code | VARCHAR(500) | text | 회사코드 | +| stock_id | VARCHAR(500) | text | 재고ID (FK) | +| item_code | VARCHAR(500) | text | 품목코드 | +| lot_number | VARCHAR(500) | text | 로트번호 | +| transaction_type | VARCHAR(500) | code | 구분 (IN/OUT) | +| transaction_date | TIMESTAMP | date | 일자 | +| quantity | VARCHAR(500) | number | 수량 | +| balance_qty | VARCHAR(500) | number | 재고량 | +| manager_id | VARCHAR(500) | text | 담당자ID | +| manager_name | VARCHAR(500) | text | 담당자명 | +| remark | VARCHAR(500) | text | 비고 | +| reference_type | VARCHAR(500) | text | 참조문서유형 | +| reference_id | VARCHAR(500) | text | 참조문서ID | +| reference_number | VARCHAR(500) | text | 참조문서번호 | + +--- + +## 실행 방법 + +### Docker 환경 (권장) + +```bash +# 재고 현황 테이블 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/063_create_inventory_stock.sql + +# 재고 이력 테이블 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/064_create_inventory_history.sql +``` + +### 로컬 PostgreSQL + +```bash +psql -U postgres -d ilshin -f db/migrations/063_create_inventory_stock.sql +psql -U postgres -d ilshin -f db/migrations/064_create_inventory_history.sql +``` + +### pgAdmin / DBeaver + +1. 각 SQL 파일 열기 +2. 전체 내용 복사 +3. SQL 쿼리 창에 붙여넣기 +4. 실행 (F5 또는 Execute) + +--- + +## 검증 방법 + +### 1. 테이블 생성 확인 + +```sql +SELECT table_name +FROM information_schema.tables +WHERE table_name IN ('inventory_stock', 'inventory_history'); +``` + +### 2. 메타데이터 등록 확인 + +```sql +-- table_labels +SELECT * FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); + +-- column_labels +SELECT table_name, column_name, column_label, input_type, display_order +FROM column_labels +WHERE table_name IN ('inventory_stock', 'inventory_history') +ORDER BY table_name, display_order; + +-- table_type_columns +SELECT table_name, column_name, company_code, input_type, display_order +FROM table_type_columns +WHERE table_name IN ('inventory_stock', 'inventory_history') +ORDER BY table_name, display_order; +``` + +### 3. 샘플 데이터 확인 + +```sql +-- 재고 현황 +SELECT * FROM inventory_stock WHERE company_code = 'WACE'; + +-- 재고 이력 +SELECT * FROM inventory_history WHERE company_code = 'WACE' ORDER BY transaction_date; +``` + +--- + +## 화면에서 사용할 조회 쿼리 예시 + +### 재고 현황 그리드 (좌측) + +```sql +SELECT + s.item_code, + i.item_name, + i.size as specification, + i.unit, + s.lot_number, + w.warehouse_name, + s.location_code, + s.current_qty::numeric as current_qty, + s.safety_qty::numeric as safety_qty, + CASE + WHEN s.current_qty::numeric < s.safety_qty::numeric THEN '부족' + WHEN s.current_qty::numeric > s.safety_qty::numeric * 2 THEN '과다' + ELSE '정상' + END AS stock_status, + s.last_in_date, + s.last_out_date +FROM inventory_stock s +LEFT JOIN item_info i ON s.item_code = i.item_number AND s.company_code = i.company_code +LEFT JOIN warehouse_info w ON s.warehouse_id = w.id +WHERE s.company_code = 'WACE' +ORDER BY s.item_code, s.lot_number; +``` + +### 재고 이력 패널 (우측) + +```sql +SELECT + h.transaction_type, + h.transaction_date, + h.quantity, + h.balance_qty, + h.manager_name, + h.remark +FROM inventory_history h +WHERE h.item_code = 'A001' + AND h.lot_number = 'LOT-2024-001' + AND h.company_code = 'WACE' +ORDER BY h.transaction_date DESC, h.created_date DESC; +``` + +--- + +## 데이터 흐름 + +``` +[입고 발생] + │ + ├─→ inventory_history에 INSERT (+수량, 잔량) + │ + └─→ inventory_stock에 UPDATE (current_qty 증가, last_in_date 갱신) + +[출고 발생] + │ + ├─→ inventory_history에 INSERT (-수량, 잔량) + │ + └─→ inventory_stock에 UPDATE (current_qty 감소, last_out_date 갱신) +``` + +--- + +## 롤백 방법 (문제 발생 시) + +```sql +-- 테이블 삭제 +DROP TABLE IF EXISTS inventory_history; +DROP TABLE IF EXISTS inventory_stock; + +-- 메타데이터 삭제 +DELETE FROM column_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); +DELETE FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); +DELETE FROM table_type_columns WHERE table_name IN ('inventory_stock', 'inventory_history'); +``` + +--- + +## 관련 테이블 (마스터 데이터) + +| 테이블 | 역할 | 연결 컬럼 | +|--------|------|-----------| +| item_info | 품목 마스터 | item_number | +| warehouse_info | 창고 마스터 | id | +| warehouse_location | 위치 마스터 | location_code | + +--- + +**작성일**: 2025-12-09 +**영향 범위**: 재고 관리 시스템 +**생성 방식**: 테이블 타입관리 UI와 동일 + + diff --git a/db/migrations/RUN_065_MIGRATION.md b/db/migrations/RUN_065_MIGRATION.md new file mode 100644 index 00000000..e63dba0d --- /dev/null +++ b/db/migrations/RUN_065_MIGRATION.md @@ -0,0 +1,30 @@ +# 065 마이그레이션 실행 가이드 + +## 연쇄 드롭다운 관계 관리 테이블 생성 + +### 실행 방법 + +```bash +# 로컬 환경 +psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql + +# Docker 환경 +docker exec -i psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql + +# 또는 DBeaver/pgAdmin에서 직접 실행 +``` + +### 생성되는 테이블 + +- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블 + +### 샘플 데이터 + +마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다. + +### 확인 방법 + +```sql +SELECT * FROM cascading_relation; +``` + diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 86b3b323..b3cc4996 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -12,7 +12,7 @@ services: NODE_ENV: production PORT: "3001" HOST: 0.0.0.0 - DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 JWT_EXPIRES_IN: 24h CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md new file mode 100644 index 00000000..985d730a --- /dev/null +++ b/docs/노드플로우_개선사항.md @@ -0,0 +1,585 @@ +# 노드 플로우 기능 개선 사항 + +> 작성일: 2024-12-08 +> 상태: 분석 완료, 개선 대기 + +## 현재 구현 상태 + +### 잘 구현된 기능 + +| 기능 | 상태 | 설명 | +|------|------|------| +| 위상 정렬 실행 | 완료 | DAG 기반 레벨별 실행 | +| 트랜잭션 관리 | 완료 | 전체 플로우 단일 트랜잭션, 실패 시 자동 롤백 | +| 병렬 실행 | 완료 | 같은 레벨 노드 `Promise.allSettled`로 병렬 처리 | +| CRUD 액션 | 완료 | INSERT, UPDATE, DELETE, UPSERT 지원 | +| 외부 DB 연동 | 완료 | PostgreSQL, MySQL, MSSQL, Oracle 지원 | +| REST API 연동 | 완료 | GET, POST, PUT, DELETE 지원 | +| 조건 분기 | 완료 | 다양한 연산자 지원 | +| 데이터 변환 | 부분 완료 | UPPERCASE, TRIM, EXPLODE 등 기본 변환 | +| 집계 함수 | 완료 | SUM, COUNT, AVG, MIN, MAX, FIRST, LAST | + +### 관련 파일 + +- **백엔드 실행 엔진**: `backend-node/src/services/nodeFlowExecutionService.ts` +- **백엔드 라우트**: `backend-node/src/routes/dataflow/node-flows.ts` +- **프론트엔드 API**: `frontend/lib/api/nodeFlows.ts` +- **프론트엔드 에디터**: `frontend/components/dataflow/node-editor/FlowEditor.tsx` +- **타입 정의**: `backend-node/src/types/flow.ts` + +--- + +## 개선 필요 사항 + +### 1. [우선순위 높음] 실행 이력 로깅 + +**현재 상태**: 플로우 실행 이력이 저장되지 않음 + +**문제점**: +- 언제, 누가, 어떤 플로우를 실행했는지 추적 불가 +- 실패 원인 분석 어려움 +- 감사(Audit) 요구사항 충족 불가 + +**개선 방안**: + +```sql +-- db/migrations/XXX_add_node_flow_execution_log.sql +CREATE TABLE node_flow_execution_log ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + execution_status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'partial' + execution_time_ms INTEGER, + total_nodes INTEGER, + success_nodes INTEGER, + failed_nodes INTEGER, + skipped_nodes INTEGER, + executed_by VARCHAR(50), + company_code VARCHAR(20), + context_data JSONB, + result_summary JSONB, + error_message TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_flow_execution_log_flow_id ON node_flow_execution_log(flow_id); +CREATE INDEX idx_flow_execution_log_created_at ON node_flow_execution_log(created_at DESC); +CREATE INDEX idx_flow_execution_log_company_code ON node_flow_execution_log(company_code); +``` + +**필요 작업**: +- [ ] 마이그레이션 파일 생성 +- [ ] `nodeFlowExecutionService.ts`에 로그 저장 로직 추가 +- [ ] 실행 이력 조회 API 추가 (`GET /api/dataflow/node-flows/:flowId/executions`) +- [ ] 프론트엔드 실행 이력 UI 추가 + +--- + +### 2. [우선순위 높음] 드라이런(Dry Run) 모드 + +**현재 상태**: 실제 데이터를 변경하지 않고 테스트할 방법 없음 + +**문제점**: +- 프로덕션 데이터에 직접 영향 +- 플로우 디버깅 어려움 +- 신규 플로우 검증 불가 + +**개선 방안**: + +```typescript +// nodeFlowExecutionService.ts +static async executeFlow( + flowId: number, + contextData: Record, + options: { dryRun?: boolean } = {} +): Promise { + if (options.dryRun) { + // 트랜잭션 시작 후 항상 롤백 + return transaction(async (client) => { + const result = await this.executeFlowInternal(flowId, contextData, client); + // 롤백을 위해 의도적으로 에러 발생 + throw new DryRunComplete(result); + }).catch((e) => { + if (e instanceof DryRunComplete) { + return { ...e.result, dryRun: true }; + } + throw e; + }); + } + // 기존 로직... +} +``` + +```typescript +// node-flows.ts 라우트 수정 +router.post("/:flowId/execute", async (req, res) => { + const dryRun = req.query.dryRun === 'true'; + const result = await NodeFlowExecutionService.executeFlow( + parseInt(flowId, 10), + enrichedContextData, + { dryRun } + ); + // ... +}); +``` + +**필요 작업**: +- [ ] `DryRunComplete` 예외 클래스 생성 +- [ ] `executeFlow` 메서드에 `dryRun` 옵션 추가 +- [ ] 라우트에 쿼리 파라미터 처리 추가 +- [ ] 프론트엔드 "테스트 실행" 버튼 추가 + +--- + +### 3. [우선순위 높음] 재시도 메커니즘 + +**현재 상태**: 외부 API/DB 호출 실패 시 재시도 없음 + +**문제점**: +- 일시적 네트워크 오류로 전체 플로우 실패 +- 외부 서비스 불안정 시 신뢰성 저하 + +**개선 방안**: + +```typescript +// utils/retry.ts +export async function withRetry( + fn: () => Promise, + options: { + maxRetries?: number; + delay?: number; + backoffMultiplier?: number; + retryOn?: (error: any) => boolean; + } = {} +): Promise { + const { + maxRetries = 3, + delay = 1000, + backoffMultiplier = 2, + retryOn = () => true + } = options; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries - 1 || !retryOn(error)) { + throw error; + } + const waitTime = delay * Math.pow(backoffMultiplier, attempt); + logger.warn(`재시도 ${attempt + 1}/${maxRetries}, ${waitTime}ms 후...`); + await new Promise(r => setTimeout(r, waitTime)); + } + } + throw new Error('재시도 횟수 초과'); +} +``` + +```typescript +// nodeFlowExecutionService.ts에서 사용 +const response = await withRetry( + () => axios({ method, url, headers, data, timeout }), + { + maxRetries: 3, + delay: 1000, + retryOn: (err) => err.code === 'ECONNRESET' || err.response?.status >= 500 + } +); +``` + +**필요 작업**: +- [ ] `withRetry` 유틸리티 함수 생성 +- [ ] REST API 호출 부분에 재시도 로직 적용 +- [ ] 외부 DB 연결 부분에 재시도 로직 적용 +- [ ] 노드별 재시도 설정 UI 추가 (선택사항) + +--- + +### 4. [우선순위 높음] 미완성 데이터 변환 함수 + +**현재 상태**: FORMAT, CALCULATE, JSON_EXTRACT, CUSTOM 변환이 미구현 + +**문제점**: +- 날짜/숫자 포맷팅 불가 +- 계산식 처리 불가 +- JSON 데이터 파싱 불가 + +**개선 방안**: + +```typescript +// nodeFlowExecutionService.ts - applyTransformation 메서드 수정 + +case "FORMAT": + return rows.map((row) => { + const value = row[sourceField]; + let formatted = value; + + if (transform.formatType === 'date') { + // dayjs 사용 + formatted = dayjs(value).format(transform.formatPattern || 'YYYY-MM-DD'); + } else if (transform.formatType === 'number') { + // 숫자 포맷팅 + const num = parseFloat(value); + if (transform.formatPattern === 'currency') { + formatted = num.toLocaleString('ko-KR', { style: 'currency', currency: 'KRW' }); + } else if (transform.formatPattern === 'percent') { + formatted = (num * 100).toFixed(transform.decimals || 0) + '%'; + } else { + formatted = num.toLocaleString('ko-KR', { maximumFractionDigits: transform.decimals || 2 }); + } + } + + return { ...row, [actualTargetField]: formatted }; + }); + +case "CALCULATE": + return rows.map((row) => { + // 간단한 수식 평가 (보안 주의!) + const expression = transform.expression; // 예: "price * quantity" + const result = evaluateExpression(expression, row); + return { ...row, [actualTargetField]: result }; + }); + +case "JSON_EXTRACT": + return rows.map((row) => { + const jsonValue = typeof row[sourceField] === 'string' + ? JSON.parse(row[sourceField]) + : row[sourceField]; + const extracted = jsonPath.query(jsonValue, transform.jsonPath); // JSONPath 라이브러리 사용 + return { ...row, [actualTargetField]: extracted[0] || null }; + }); +``` + +**필요 작업**: +- [ ] `dayjs` 라이브러리 추가 (날짜 포맷팅) +- [ ] `jsonpath` 라이브러리 추가 (JSON 추출) +- [ ] 안전한 수식 평가 함수 구현 (eval 대신) +- [ ] 각 변환 타입별 UI 설정 패널 추가 + +--- + +### 5. [우선순위 중간] 플로우 버전 관리 + +**현재 상태**: 플로우 수정 시 이전 버전 덮어씀 + +**문제점**: +- 실수로 수정한 플로우 복구 불가 +- 변경 이력 추적 불가 + +**개선 방안**: + +```sql +-- db/migrations/XXX_add_node_flow_versions.sql +CREATE TABLE node_flow_versions ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + version INTEGER NOT NULL, + flow_data JSONB NOT NULL, + change_description TEXT, + created_by VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(flow_id, version) +); + +CREATE INDEX idx_flow_versions_flow_id ON node_flow_versions(flow_id); +``` + +```typescript +// 플로우 수정 시 버전 저장 +async function updateNodeFlow(flowId, flowData, changeDescription, userId) { + // 현재 버전 조회 + const currentVersion = await queryOne( + 'SELECT COALESCE(MAX(version), 0) as max_version FROM node_flow_versions WHERE flow_id = $1', + [flowId] + ); + + // 새 버전 저장 + await query( + 'INSERT INTO node_flow_versions (flow_id, version, flow_data, change_description, created_by) VALUES ($1, $2, $3, $4, $5)', + [flowId, currentVersion.max_version + 1, flowData, changeDescription, userId] + ); + + // 기존 업데이트 로직... +} +``` + +**필요 작업**: +- [ ] 버전 테이블 마이그레이션 생성 +- [ ] 플로우 수정 시 버전 자동 저장 +- [ ] 버전 목록 조회 API (`GET /api/dataflow/node-flows/:flowId/versions`) +- [ ] 특정 버전으로 롤백 API (`POST /api/dataflow/node-flows/:flowId/rollback/:version`) +- [ ] 프론트엔드 버전 히스토리 UI + +--- + +### 6. [우선순위 중간] 복합 조건 지원 + +**현재 상태**: 조건 노드에서 단일 조건만 지원 + +**문제점**: +- 복잡한 비즈니스 로직 표현 불가 +- 여러 조건을 AND/OR로 조합 불가 + +**개선 방안**: + +```typescript +// 복합 조건 타입 정의 +interface ConditionGroup { + type: 'AND' | 'OR'; + conditions: (Condition | ConditionGroup)[]; +} + +interface Condition { + field: string; + operator: string; + value: any; +} + +// 조건 평가 함수 수정 +function evaluateConditionGroup(group: ConditionGroup, data: any): boolean { + const results = group.conditions.map(condition => { + if ('type' in condition) { + // 중첩된 그룹 + return evaluateConditionGroup(condition, data); + } else { + // 단일 조건 + return evaluateCondition(data[condition.field], condition.operator, condition.value); + } + }); + + return group.type === 'AND' + ? results.every(r => r) + : results.some(r => r); +} +``` + +**필요 작업**: +- [ ] 복합 조건 타입 정의 +- [ ] `evaluateConditionGroup` 함수 구현 +- [ ] 조건 노드 속성 패널 UI 수정 (AND/OR 그룹 빌더) + +--- + +### 7. [우선순위 중간] 비동기 실행 + +**현재 상태**: 동기 실행만 가능 (HTTP 요청 타임아웃 제한) + +**문제점**: +- 대용량 데이터 처리 시 타임아웃 +- 장시간 실행 플로우 처리 불가 + +**개선 방안**: + +```sql +-- 실행 큐 테이블 +CREATE TABLE node_flow_execution_queue ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id), + execution_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed + context_data JSONB, + callback_url TEXT, + result JSONB, + error_message TEXT, + queued_by VARCHAR(50), + company_code VARCHAR(20), + queued_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, + completed_at TIMESTAMP +); +``` + +```typescript +// 비동기 실행 API +router.post("/:flowId/execute-async", async (req, res) => { + const { callbackUrl, contextData } = req.body; + + // 큐에 추가 + const execution = await queryOne( + `INSERT INTO node_flow_execution_queue (flow_id, context_data, callback_url, queued_by, company_code) + VALUES ($1, $2, $3, $4, $5) RETURNING execution_id`, + [flowId, contextData, callbackUrl, req.user?.userId, req.user?.companyCode] + ); + + // 백그라운드 워커가 처리 + return res.json({ + success: true, + executionId: execution.execution_id, + status: 'queued' + }); +}); + +// 상태 조회 API +router.get("/executions/:executionId", async (req, res) => { + const execution = await queryOne( + 'SELECT * FROM node_flow_execution_queue WHERE execution_id = $1', + [req.params.executionId] + ); + return res.json({ success: true, data: execution }); +}); +``` + +**필요 작업**: +- [ ] 실행 큐 테이블 마이그레이션 +- [ ] 비동기 실행 API 추가 +- [ ] 백그라운드 워커 프로세스 구현 (별도 프로세스 또는 Bull 큐) +- [ ] 웹훅 콜백 기능 구현 +- [ ] 프론트엔드 비동기 실행 상태 폴링 UI + +--- + +### 8. [우선순위 낮음] 플로우 스케줄링 + +**현재 상태**: 수동 실행만 가능 + +**문제점**: +- 정기적인 배치 작업 자동화 불가 +- 특정 시간 예약 실행 불가 + +**개선 방안**: + +```sql +-- 스케줄 테이블 +CREATE TABLE node_flow_schedules ( + id SERIAL PRIMARY KEY, + flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE, + schedule_name VARCHAR(100), + cron_expression VARCHAR(50) NOT NULL, -- '0 9 * * 1-5' (평일 9시) + context_data JSONB, + is_active BOOLEAN DEFAULT true, + last_run_at TIMESTAMP, + next_run_at TIMESTAMP, + created_by VARCHAR(50), + company_code VARCHAR(20), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +**필요 작업**: +- [ ] 스케줄 테이블 마이그레이션 +- [ ] 스케줄 CRUD API +- [ ] node-cron 또는 Bull 스케줄러 통합 +- [ ] 스케줄 관리 UI + +--- + +### 9. [우선순위 낮음] 플러그인 아키텍처 + +**현재 상태**: 새 노드 타입 추가 시 `nodeFlowExecutionService.ts` 직접 수정 필요 + +**문제점**: +- 코드 복잡도 증가 +- 확장성 제한 + +**개선 방안**: + +```typescript +// interfaces/NodeHandler.ts +export interface NodeHandler { + type: string; + execute(node: FlowNode, inputData: any, context: ExecutionContext, client?: any): Promise; + validate?(node: FlowNode): { valid: boolean; errors: string[] }; +} + +// handlers/InsertActionHandler.ts +export class InsertActionHandler implements NodeHandler { + type = 'insertAction'; + + async execute(node, inputData, context, client) { + // 기존 executeInsertAction 로직 + } +} + +// NodeHandlerRegistry.ts +class NodeHandlerRegistry { + private handlers = new Map(); + + register(handler: NodeHandler) { + this.handlers.set(handler.type, handler); + } + + get(type: string): NodeHandler | undefined { + return this.handlers.get(type); + } +} + +// 사용 +const registry = new NodeHandlerRegistry(); +registry.register(new InsertActionHandler()); +registry.register(new UpdateActionHandler()); +// ... + +// executeNodeByType에서 +const handler = registry.get(node.type); +if (handler) { + return handler.execute(node, inputData, context, client); +} +``` + +**필요 작업**: +- [ ] `NodeHandler` 인터페이스 정의 +- [ ] 기존 노드 타입별 핸들러 클래스 분리 +- [ ] `NodeHandlerRegistry` 구현 +- [ ] 커스텀 노드 핸들러 등록 메커니즘 + +--- + +### 10. [우선순위 낮음] 프론트엔드 연동 강화 + +**현재 상태**: 기본 에디터 구현됨 + +**개선 필요 항목**: +- [ ] 실행 결과 시각화 (노드별 성공/실패 표시) +- [ ] 실시간 실행 진행률 표시 +- [ ] 드라이런 모드 UI +- [ ] 실행 이력 조회 UI +- [ ] 버전 히스토리 UI +- [ ] 노드 검증 결과 표시 + +--- + +## 프론트엔드 컴포넌트 CRUD 로직 이전 계획 + +현재 프론트엔드 컴포넌트에서 직접 CRUD를 수행하는 코드들을 노드 플로우로 이전해야 합니다. + +### 이전 대상 컴포넌트 + +| 컴포넌트 | 파일 위치 | 현재 로직 | 이전 우선순위 | +|----------|----------|----------|--------------| +| SplitPanelLayoutComponent | `frontend/lib/registry/components/split-panel-layout/` | createRecord, updateRecord, deleteRecord | 높음 | +| RepeatScreenModalComponent | `frontend/lib/registry/components/repeat-screen-modal/` | 다중 테이블 INSERT/UPDATE/DELETE | 높음 | +| UniversalFormModalComponent | `frontend/lib/registry/components/universal-form-modal/` | 다중 행 저장 | 높음 | +| SelectedItemsDetailInputComponent | `frontend/lib/registry/components/selected-items-detail-input/` | upsertGroupedRecords | 높음 | +| ButtonPrimaryComponent | `frontend/lib/registry/components/button-primary/` | 상태 변경 POST | 중간 | +| SimpleRepeaterTableComponent | `frontend/lib/registry/components/simple-repeater-table/` | 데이터 저장 POST | 중간 | + +### 이전 방식 + +1. **플로우 생성**: 각 컴포넌트의 저장 로직을 노드 플로우로 구현 +2. **프론트엔드 수정**: 직접 API 호출 대신 `executeNodeFlow(flowId, contextData)` 호출 +3. **화면 설정에 플로우 연결**: 버튼 액션에 실행할 플로우 ID 설정 + +```typescript +// 현재 (프론트엔드에서 직접 호출) +const result = await dataApi.createRecord(tableName, data); + +// 개선 후 (플로우 실행) +const result = await executeNodeFlow(flowId, { + formData: data, + tableName: tableName, + action: 'create' +}); +``` + +--- + +## 참고 자료 + +- 노드 플로우 실행 엔진: `backend-node/src/services/nodeFlowExecutionService.ts` +- 플로우 타입 정의: `backend-node/src/types/flow.ts` +- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx` +- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts` + + + + diff --git a/docs/레벨기반_연쇄드롭다운_설계.md b/docs/레벨기반_연쇄드롭다운_설계.md new file mode 100644 index 00000000..f19a0d60 --- /dev/null +++ b/docs/레벨기반_연쇄드롭다운_설계.md @@ -0,0 +1,699 @@ +# 레벨 기반 연쇄 드롭다운 시스템 설계 + +## 1. 개요 + +### 1.1 목적 +다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축 + +### 1.2 지원하는 계층 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 | +| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 | +| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 | +| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 | + +--- + +## 2. 데이터베이스 설계 + +### 2.1 테이블 구조 + +``` +┌─────────────────────────────────────┐ +│ cascading_hierarchy_group │ ← 계층 그룹 정의 +├─────────────────────────────────────┤ +│ group_code (PK) │ +│ group_name │ +│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE +│ max_levels │ +│ is_fixed_levels │ +│ self_ref_* (자기참조 설정) │ +│ bom_* (BOM 설정) │ +│ company_code │ +└─────────────────────────────────────┘ + │ + │ 1:N (MULTI_TABLE 유형만) + ▼ +┌─────────────────────────────────────┐ +│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의 +├─────────────────────────────────────┤ +│ group_code (FK) │ +│ level_order │ ← 1, 2, 3... +│ level_name │ +│ table_name │ +│ value_column │ +│ label_column │ +│ parent_key_column │ ← 부모 테이블 참조 컬럼 +│ company_code │ +└─────────────────────────────────────┘ +``` + +### 2.2 기존 시스템과의 관계 + +``` +┌─────────────────────────────────────┐ +│ cascading_relation │ ← 기존 2단계 관계 (유지) +│ (2단계 전용) │ +└─────────────────────────────────────┘ + │ + │ 호환성 뷰 + ▼ +┌─────────────────────────────────────┐ +│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환 +└─────────────────────────────────────┘ +``` + +--- + +## 3. 계층 유형별 상세 설계 + +### 3.1 MULTI_TABLE (다중 테이블 계층) + +**사용 사례**: 국가 → 시/도 → 구/군 → 동 + +**테이블 구조**: +``` +country_info province_info city_info district_info +├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK) +├─ country_name ├─ province_name ├─ city_name ├─ district_name + ├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK) +``` + +**설정 예시**: +```sql +-- 그룹 정의 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) VALUES ( + 'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX' +); + +-- 레벨 정의 +INSERT INTO cascading_hierarchy_level VALUES +(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL), +(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'), +(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'), +(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code'); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1 + → [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }] + +2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR + → [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }] + +3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL + → [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }] +``` + +--- + +### 3.2 SELF_REFERENCE (자기참조 계층) + +**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류) + +**테이블 구조** (code_info 활용): +``` +code_info +├─ code_category = 'PRODUCT_CATEGORY' +├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED' +├─ code_name = '전자제품', 'TV', 'LED TV' +├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조 +├─ level = 1, 2, 3 +├─ sort_order +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_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_filter_column, self_ref_filter_value, + company_code +) VALUES ( + 'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3, + 'code_info', 'code_value', 'parent_code', + 'code_value', 'code_name', 'level', + 'code_category', 'PRODUCT_CATEGORY', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1 + → WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }] + +2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC + → WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }] + +3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV + → WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }] +``` + +--- + +### 3.3 BOM (Bill of Materials) + +**사용 사례**: 제품 BOM 구조 + +**테이블 구조**: +``` +klbom_tbl (BOM 관계) item_info (품목 마스터) +├─ id (자식 품목) ├─ item_code (PK) +├─ pid (부모 품목) ├─ item_name +├─ qty (수량) ├─ item_spec +├─ aylevel (레벨) ├─ unit +├─ bom_report_objid +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, is_fixed_levels, + 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, + company_code +) VALUES ( + 'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N', + 'klbom_tbl', 'pid', 'id', + 'item_info', 'item_code', 'item_name', + 'qty', 'aylevel', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots + → WHERE pid IS NULL OR pid = '' + → [{ value: 'PROD001', label: '완제품 A', level: 1 }] + +2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001 + → WHERE pid = 'PROD001' + → [ + { value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 }, + { value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 } + ] + +3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001 + → WHERE pid = 'ASSY001' + → [ + { value: 'PART001', label: '부품 A', qty: 4, level: 3 }, + { value: 'PART002', label: '부품 B', qty: 2, level: 3 } + ] +``` + +**BOM 전용 응답 형식**: +```typescript +interface BomOption { + value: string; // 품목 코드 + label: string; // 품목명 + qty: number; // 수량 + level: number; // BOM 레벨 + hasChildren: boolean; // 하위 품목 존재 여부 + spec?: string; // 규격 (선택) + unit?: string; // 단위 (선택) +} +``` + +--- + +### 3.4 TREE (무한 깊이 트리) + +**사용 사례**: 조직도, 메뉴 구조 + +**테이블 구조**: +``` +dept_info +├─ dept_code (PK) +├─ dept_name +├─ parent_dept_code ← 자기참조 (무한 깊이) +├─ sort_order +├─ is_active +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, 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_order_column, + company_code +) VALUES ( + 'ORG_CHART', '조직도', 'TREE', NULL, 'N', + 'dept_info', 'dept_code', 'parent_dept_code', + 'dept_code', 'dept_name', 'sort_order', + 'EMAX' +); +``` + +**API 호출 흐름** (BOM과 유사): +``` +1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots + → WHERE parent_dept_code IS NULL + → [{ value: 'HQ', label: '본사', hasChildren: true }] + +2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ + → WHERE parent_dept_code = 'HQ' + → [ + { value: 'DIV1', label: '사업부1', hasChildren: true }, + { value: 'DIV2', label: '사업부2', hasChildren: true } + ] +``` + +--- + +## 4. API 설계 + +### 4.1 계층 그룹 관리 API + +``` +GET /api/cascading-hierarchy/groups # 그룹 목록 +POST /api/cascading-hierarchy/groups # 그룹 생성 +GET /api/cascading-hierarchy/groups/:code # 그룹 상세 +PUT /api/cascading-hierarchy/groups/:code # 그룹 수정 +DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제 +``` + +### 4.2 레벨 관리 API (MULTI_TABLE용) + +``` +GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록 +POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가 +PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정 +DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제 +``` + +### 4.3 옵션 조회 API + +``` +# MULTI_TABLE / SELF_REFERENCE +GET /api/cascading-hierarchy/options/:groupCode/:level + ?parentValue=xxx # 부모 값 (레벨 2 이상) + &companyCode=xxx # 회사 코드 (선택) + +# BOM / TREE +GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드 +GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드 + ?parentValue=xxx +GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회 + ?value=xxx +GET /api/cascading-hierarchy/tree/:groupCode/search # 검색 + ?keyword=xxx +``` + +--- + +## 5. 프론트엔드 컴포넌트 설계 + +### 5.1 CascadingHierarchyDropdown + +```typescript +interface CascadingHierarchyDropdownProps { + groupCode: string; // 계층 그룹 코드 + level: number; // 현재 레벨 (1, 2, 3...) + parentValue?: string; // 부모 값 (레벨 2 이상) + value?: string; // 선택된 값 + onChange: (value: string, option: HierarchyOption) => void; + placeholder?: string; + disabled?: boolean; + required?: boolean; +} + +// 사용 예시 (지역 계층) + + + +``` + +### 5.2 CascadingHierarchyGroup (자동 연결) + +```typescript +interface CascadingHierarchyGroupProps { + groupCode: string; + values: Record; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' } + onChange: (level: number, value: string) => void; + layout?: 'horizontal' | 'vertical'; +} + +// 사용 예시 + { + setRegionValues(prev => ({ ...prev, [level]: value })); + }} +/> +``` + +### 5.3 BomTreeSelect (BOM 전용) + +```typescript +interface BomTreeSelectProps { + groupCode: string; + value?: string; + onChange: (value: string, path: BomOption[]) => void; + showQty?: boolean; // 수량 표시 + showLevel?: boolean; // 레벨 표시 + maxDepth?: number; // 최대 깊이 제한 +} + +// 사용 예시 + { + setSelectedPart(value); + console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품] + }} + showQty +/> +``` + +--- + +## 6. 화면관리 시스템 통합 + +### 6.1 컴포넌트 설정 확장 + +```typescript +interface SelectBasicConfig { + // 기존 설정 + cascadingEnabled?: boolean; + cascadingRelationCode?: string; // 기존 2단계 관계 + cascadingRole?: 'parent' | 'child'; + cascadingParentField?: string; + + // 🆕 레벨 기반 계층 설정 + hierarchyEnabled?: boolean; + hierarchyGroupCode?: string; // 계층 그룹 코드 + hierarchyLevel?: number; // 이 컴포넌트의 레벨 + hierarchyParentField?: string; // 부모 레벨 필드명 +} +``` + +### 6.2 설정 UI 확장 + +``` +┌─────────────────────────────────────────┐ +│ 연쇄 드롭다운 설정 │ +├─────────────────────────────────────────┤ +│ ○ 2단계 관계 (기존) │ +│ └─ 관계 선택: [창고-위치 ▼] │ +│ └─ 역할: [부모] [자식] │ +│ │ +│ ● 다단계 계층 (신규) │ +│ └─ 계층 그룹: [지역 계층 ▼] │ +│ └─ 레벨: [2 - 시/도 ▼] │ +│ └─ 부모 필드: [country_code] (자동감지) │ +└─────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 우선순위 + +### Phase 1: 기반 구축 +1. ✅ 기존 2단계 연쇄 드롭다운 완성 +2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql) +3. 📋 백엔드 API 구현 (계층 그룹 CRUD) + +### Phase 2: MULTI_TABLE 지원 +1. 📋 레벨 관리 API +2. 📋 옵션 조회 API +3. 📋 프론트엔드 컴포넌트 + +### Phase 3: SELF_REFERENCE 지원 +1. 📋 자기참조 쿼리 로직 +2. 📋 code_info 기반 카테고리 계층 + +### Phase 4: BOM/TREE 지원 +1. 📋 BOM 전용 API +2. 📋 트리 컴포넌트 +3. 📋 무한 깊이 지원 + +### Phase 5: 화면관리 통합 +1. 📋 설정 UI 확장 +2. 📋 자동 연결 기능 + +--- + +## 8. 성능 고려사항 + +### 8.1 쿼리 최적화 +- 인덱스: `(group_code, company_code, level_order)` +- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱 +- Lazy Loading: 하위 레벨은 필요 시에만 로드 + +### 8.2 BOM 재귀 쿼리 +```sql +-- PostgreSQL WITH RECURSIVE 활용 +WITH RECURSIVE bom_tree AS ( + -- 루트 노드 + SELECT id, pid, qty, 1 AS level + FROM klbom_tbl + WHERE pid IS NULL + + UNION ALL + + -- 하위 노드 + SELECT b.id, b.pid, b.qty, t.level + 1 + FROM klbom_tbl b + JOIN bom_tree t ON b.pid = t.id + WHERE t.level < 10 -- 최대 깊이 제한 +) +SELECT * FROM bom_tree; +``` + +### 8.3 트리 최적화 전략 +- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1` +- Nested Set: left/right 값으로 범위 쿼리 +- Closure Table: 별도 관계 테이블 + +--- + +## 9. 추가 연쇄 패턴 + +### 9.1 조건부 연쇄 (Conditional Cascading) + +**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시 + +``` +입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시 +입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시 +``` + +**테이블**: `cascading_condition` + +```sql +INSERT INTO cascading_condition ( + relation_code, condition_name, + condition_field, condition_operator, condition_value, + filter_column, filter_values, company_code +) VALUES +('WAREHOUSE_LOCATION', '구매입고 창고', + 'inbound_type', 'EQ', 'PURCHASE', + 'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX'); +``` + +--- + +### 9.2 다중 부모 연쇄 (Multi-Parent Cascading) + +**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링 + +``` +회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀] +``` + +**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source` + +```sql +-- 관계 정의 +INSERT INTO cascading_multi_parent ( + relation_code, relation_name, + child_table, child_value_column, child_label_column, company_code +) VALUES ( + 'COMPANY_DIVISION_DEPT', '회사-사업부-부서', + 'dept_info', 'dept_code', 'dept_name', 'EMAX' +); + +-- 부모 소스 정의 +INSERT INTO cascading_multi_parent_source ( + relation_code, company_code, parent_order, parent_name, + parent_table, parent_value_column, child_filter_column +) VALUES +('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'), +('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code'); +``` + +--- + +### 9.3 자동 입력 그룹 (Auto-Fill Group) + +**사용 사례**: 마스터 선택 시 여러 필드 자동 입력 + +``` +고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력 +``` + +**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping` + +```sql +-- 그룹 정의 +INSERT INTO cascading_auto_fill_group ( + group_code, group_name, + master_table, master_value_column, master_label_column, company_code +) VALUES ( + 'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력', + 'customer_info', 'customer_code', 'customer_name', 'EMAX' +); + +-- 필드 매핑 +INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label +) VALUES +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소'); +``` + +--- + +### 9.4 상호 배제 (Mutual Exclusion) + +**사용 사례**: 같은 값 선택 불가 + +``` +출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외) +``` + +**테이블**: `cascading_mutual_exclusion` + +```sql +INSERT INTO cascading_mutual_exclusion ( + exclusion_code, exclusion_name, field_names, + source_table, value_column, label_column, + error_message, company_code +) VALUES ( + 'WAREHOUSE_TRANSFER', '창고간 이동', + 'from_warehouse_code,to_warehouse_code', + 'warehouse_info', 'warehouse_code', 'warehouse_name', + '출발 창고와 도착 창고는 같을 수 없습니다', + 'EMAX' +); +``` + +--- + +### 9.5 역방향 조회 (Reverse Lookup) + +**사용 사례**: 자식에서 부모 방향으로 조회 + +``` +품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z] +``` + +**테이블**: `cascading_reverse_lookup` + +```sql +INSERT INTO cascading_reverse_lookup ( + lookup_code, lookup_name, + source_table, source_value_column, source_label_column, + target_table, target_value_column, target_label_column, target_link_column, + company_code +) VALUES ( + 'ITEM_USED_IN_BOM', '품목 사용처 BOM', + 'item_info', 'item_code', 'item_name', + 'klbom_tbl', 'pid', 'ayupgname', 'id', + 'EMAX' +); +``` + +--- + +## 10. 전체 테이블 구조 요약 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 연쇄 드롭다운 시스템 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [기존 - 2단계] │ +│ cascading_relation ─────────────────────────────────────────── │ +│ │ +│ [신규 - 다단계 계층] │ +│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │ +│ │ (MULTI_TABLE용) │ +│ │ │ +│ [신규 - 조건부] │ +│ cascading_condition ────────┴── 조건에 따른 필터링 │ +│ │ +│ [신규 - 다중 부모] │ +│ cascading_multi_parent ─────┬── cascading_multi_parent_source │ +│ │ (여러 부모 조합) │ +│ │ +│ [신규 - 자동 입력] │ +│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │ +│ │ (마스터→다중 필드) │ +│ │ +│ [신규 - 상호 배제] │ +│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │ +│ │ +│ [신규 - 역방향] │ +│ cascading_reverse_lookup ───┴── 자식→부모 조회 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 11. 마이그레이션 가이드 + +### 11.1 기존 데이터 마이그레이션 +```sql +-- 기존 cascading_relation → cascading_hierarchy_group 변환 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) +SELECT + 'LEGACY_' || relation_code, + relation_name, + 'MULTI_TABLE', + 2, + company_code +FROM cascading_relation +WHERE is_active = 'Y'; +``` + +### 11.2 호환성 유지 +- 기존 `cascading_relation` 테이블 유지 +- 기존 API 엔드포인트 유지 +- 점진적 마이그레이션 지원 + +--- + +## 12. 구현 우선순위 (업데이트) + +| Phase | 기능 | 복잡도 | 우선순위 | +|-------|------|--------|----------| +| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 | +| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 | +| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 | +| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 | +| 5 | 조건부 연쇄 | 중 | 중간 | +| 6 | 상호 배제 | 낮음 | 중간 | +| 7 | 다중 부모 연쇄 | 높음 | 낮음 | +| 8 | BOM/TREE 구조 | 높음 | 낮음 | +| 9 | 역방향 조회 | 중 | 낮음 | + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md new file mode 100644 index 00000000..285dc6ba --- /dev/null +++ b/docs/메일발송_기능_사용_가이드.md @@ -0,0 +1,358 @@ +# 메일 발송 기능 사용 가이드 + +## 개요 + +노드 기반 제어관리 시스템을 통해 메일을 발송하는 방법을 설명합니다. +화면에서 데이터를 선택하고, 수신자를 지정하여 템플릿 기반의 메일을 발송할 수 있습니다. + +--- + +## 1. 사전 준비 + +### 1.1 메일 계정 등록 + +메일 발송을 위해 먼저 SMTP 계정을 등록해야 합니다. + +1. **관리자** > **메일관리** > **계정관리** 이동 +2. **새 계정 추가** 클릭 +3. SMTP 정보 입력: + - 계정명: 식별용 이름 (예: "회사 공식 메일") + - 이메일: 발신자 이메일 주소 + - SMTP 호스트: 메일 서버 주소 (예: smtp.gmail.com) + - SMTP 포트: 포트 번호 (예: 587) + - 보안: TLS/SSL 선택 + - 사용자명/비밀번호: SMTP 인증 정보 +4. **저장** 후 **테스트 발송**으로 동작 확인 + +--- + +## 2. 제어관리 설정 + +### 2.1 메일 발송 플로우 생성 + +**관리자** > **제어관리** > **플로우 관리**에서 새 플로우를 생성합니다. + +#### 기본 구조 + +``` +[테이블 소스] → [메일 발송] +``` + +#### 노드 구성 + +1. **테이블 소스 노드** 추가 + + - 데이터 소스: **컨텍스트 데이터** (화면에서 선택한 데이터 사용) + - 또는 **테이블 전체 데이터** (주의: 전체 데이터 건수만큼 메일 발송) + +2. **메일 발송 노드** 추가 + + - 노드 팔레트 > 외부 실행 > **메일 발송** 드래그 + +3. 두 노드 연결 (테이블 소스 → 메일 발송) + +--- + +### 2.2 메일 발송 노드 설정 + +메일 발송 노드를 클릭하면 우측에 속성 패널이 표시됩니다. + +#### 계정 탭 + +| 설정 | 설명 | +| -------------- | ----------------------------------- | +| 발송 계정 선택 | 사전에 등록한 메일 계정 선택 (필수) | + +#### 메일 탭 + +| 설정 | 설명 | +| -------------------- | ------------------------------------------------ | +| 수신자 컴포넌트 사용 | 체크 시 화면의 수신자 선택 컴포넌트 값 자동 사용 | +| 수신자 필드명 | 수신자 변수명 (기본: mailTo) | +| 참조 필드명 | 참조 변수명 (기본: mailCc) | +| 수신자 (To) | 직접 입력 또는 변수 사용 (예: `{{email}}`) | +| 참조 (CC) | 참조 수신자 | +| 숨은 참조 (BCC) | 숨은 참조 수신자 | +| 우선순위 | 높음 / 보통 / 낮음 | + +#### 본문 탭 + +| 설정 | 설명 | +| --------- | -------------------------------- | +| 제목 | 메일 제목 (변수 사용 가능) | +| 본문 형식 | 텍스트 (변수 태그 에디터) / HTML | +| 본문 내용 | 메일 본문 (변수 사용 가능) | + +#### 옵션 탭 + +| 설정 | 설명 | +| ----------- | ------------------- | +| 타임아웃 | 발송 제한 시간 (ms) | +| 재시도 횟수 | 실패 시 재시도 횟수 | + +--- + +### 2.3 변수 사용 방법 + +메일 제목과 본문에서 `{{변수명}}` 형식으로 데이터 필드를 참조할 수 있습니다. + +#### 텍스트 모드 (변수 태그 에디터) + +1. 본문 형식을 **텍스트 (변수 태그 에디터)** 선택 +2. 에디터에서 `@` 또는 `/` 키 입력 +3. 변수 목록에서 원하는 변수 선택 +4. 선택된 변수는 파란색 태그로 표시 + +#### HTML 모드 (직접 입력) + +```html +

주문 확인

+

안녕하세요 {{customerName}}님,

+

주문번호 {{orderNo}}의 주문이 완료되었습니다.

+

금액: {{totalAmount}}원

+``` + +#### 사용 가능한 변수 + +| 변수 | 설명 | +| ---------------- | ------------------------ | +| `{{timestamp}}` | 메일 발송 시점 | +| `{{sourceData}}` | 전체 소스 데이터 (JSON) | +| `{{필드명}}` | 테이블 소스의 각 컬럼 값 | + +--- + +## 3. 화면 구성 + +### 3.1 기본 구조 + +메일 발송 화면은 보통 다음과 같이 구성합니다: + +``` +[부모 화면: 데이터 목록] + ↓ (모달 열기 버튼) +[모달: 수신자 입력 + 발송 버튼] +``` + +### 3.2 수신자 선택 컴포넌트 배치 + +1. **화면관리**에서 모달 화면 편집 +2. 컴포넌트 팔레트 > **메일 수신자 선택** 드래그 +3. 컴포넌트 설정: + - 수신자 필드명: `mailTo` (메일 발송 노드와 일치) + - 참조 필드명: `mailCc` (메일 발송 노드와 일치) + +#### 수신자 선택 기능 + +- **내부 사용자**: 회사 직원 목록에서 검색/선택 +- **외부 이메일**: 직접 이메일 주소 입력 +- 여러 명 선택 가능 (쉼표로 구분) + +### 3.3 발송 버튼 설정 + +1. **버튼** 컴포넌트 추가 +2. 버튼 설정: + - 액션 타입: **제어 실행** + - 플로우 선택: 생성한 메일 발송 플로우 + - 데이터 소스: **자동** 또는 **폼 + 테이블 선택** + +--- + +## 4. 전체 흐름 예시 + +### 4.1 시나리오: 선택한 주문 건에 대해 고객에게 메일 발송 + +#### Step 1: 제어관리 플로우 생성 + +``` +[테이블 소스: 컨텍스트 데이터] + ↓ +[메일 발송] + - 계정: 회사 공식 메일 + - 수신자 컴포넌트 사용: 체크 + - 제목: [주문확인] {{orderNo}} 주문이 완료되었습니다 + - 본문: + 안녕하세요 {{customerName}}님, + + 주문번호 {{orderNo}}의 주문이 정상 처리되었습니다. + + - 상품명: {{productName}} + - 수량: {{quantity}} + - 금액: {{totalAmount}}원 + + 감사합니다. +``` + +#### Step 2: 부모 화면 (주문 목록) + +- 주문 데이터 테이블 +- "메일 발송" 버튼 + - 액션: 모달 열기 + - 모달 화면: 메일 발송 모달 + - 선택된 데이터 전달: 체크 + +#### Step 3: 모달 화면 (메일 발송) + +- 메일 수신자 선택 컴포넌트 + - 수신자 (To) 입력 + - 참조 (CC) 입력 +- "발송" 버튼 + - 액션: 제어 실행 + - 플로우: 메일 발송 플로우 + +#### Step 4: 실행 흐름 + +1. 사용자가 주문 목록에서 주문 선택 +2. "메일 발송" 버튼 클릭 → 모달 열림 +3. 수신자/참조 입력 +4. "발송" 버튼 클릭 +5. 제어 실행: + - 부모 화면 데이터 (orderNo, customerName 등) + 모달 폼 데이터 (mailTo, mailCc) 병합 + - 변수 치환 후 메일 발송 + +--- + +## 5. 데이터 소스별 동작 + +### 5.1 컨텍스트 데이터 (권장) + +- 화면에서 **선택한 데이터**만 사용 +- 선택한 건수만큼 메일 발송 + +| 선택 건수 | 메일 발송 수 | +| --------- | ------------ | +| 1건 | 1통 | +| 5건 | 5통 | +| 10건 | 10통 | + +### 5.2 테이블 전체 데이터 (주의) + +- 테이블의 **모든 데이터** 사용 +- 전체 건수만큼 메일 발송 + +| 테이블 데이터 | 메일 발송 수 | +| ------------- | ------------ | +| 100건 | 100통 | +| 1000건 | 1000통 | + +**주의사항:** + +- 대량 발송 시 SMTP 서버 rate limit 주의 +- 테스트 시 반드시 데이터 건수 확인 + +--- + +## 6. 문제 해결 + +### 6.1 메일이 발송되지 않음 + +1. **계정 설정 확인**: 메일관리 > 계정관리에서 테스트 발송 확인 +2. **수신자 확인**: 수신자 이메일 주소가 올바른지 확인 +3. **플로우 연결 확인**: 테이블 소스 → 메일 발송 노드가 연결되어 있는지 확인 + +### 6.2 변수가 치환되지 않음 + +1. **변수명 확인**: `{{변수명}}`에서 변수명이 테이블 컬럼명과 일치하는지 확인 +2. **데이터 소스 확인**: 테이블 소스 노드가 올바른 데이터를 가져오는지 확인 +3. **데이터 전달 확인**: 부모 화면 → 모달로 데이터가 전달되는지 확인 + +### 6.3 수신자 컴포넌트 값이 전달되지 않음 + +1. **필드명 일치 확인**: + - 수신자 컴포넌트의 필드명과 메일 발송 노드의 필드명이 일치해야 함 + - 기본값: `mailTo`, `mailCc` +2. **수신자 컴포넌트 사용 체크**: 메일 발송 노드에서 "수신자 컴포넌트 사용" 활성화 + +### 6.4 부모 화면 데이터가 메일에 포함되지 않음 + +1. **모달 열기 설정 확인**: "선택된 데이터 전달" 옵션 활성화 +2. **데이터 소스 설정 확인**: 발송 버튼의 데이터 소스가 "자동" 또는 "폼 + 테이블 선택"인지 확인 + +--- + +## 7. 고급 기능 + +### 7.1 조건부 메일 발송 + +조건 분기 노드를 사용하여 특정 조건에서만 메일을 발송할 수 있습니다. + +``` +[테이블 소스] + ↓ +[조건 분기: status === 'approved'] + ↓ (true) +[메일 발송: 승인 알림] +``` + +### 7.2 다중 수신자 처리 + +수신자 필드에 쉼표로 구분하여 여러 명에게 동시 발송: + +``` +{{managerEmail}}, {{teamLeadEmail}}, external@example.com +``` + +### 7.3 HTML 템플릿 활용 + +본문 형식을 HTML로 설정하면 풍부한 형식의 메일을 보낼 수 있습니다: + +```html + + + + + + +
+

주문 확인

+
+
+

안녕하세요 {{customerName}}님,

+

주문번호 {{orderNo}}의 주문이 완료되었습니다.

+ + + + + + + + + +
상품명{{productName}}
금액{{totalAmount}}원
+
+ + + +``` + +--- + +## 8. 체크리스트 + +메일 발송 기능 구현 시 확인 사항: + +- [ ] 메일 계정이 등록되어 있는가? +- [ ] 메일 계정 테스트 발송이 성공하는가? +- [ ] 제어관리에 메일 발송 플로우가 생성되어 있는가? +- [ ] 테이블 소스 노드의 데이터 소스가 올바르게 설정되어 있는가? +- [ ] 메일 발송 노드에서 계정이 선택되어 있는가? +- [ ] 수신자 컴포넌트 사용 시 필드명이 일치하는가? +- [ ] 변수명이 테이블 컬럼명과 일치하는가? +- [ ] 부모 화면에서 모달로 데이터가 전달되는가? +- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + + diff --git a/frontend/app/(admin)/admin/vehicle-reports/page.tsx b/frontend/app/(admin)/admin/vehicle-reports/page.tsx new file mode 100644 index 00000000..ce84f584 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-reports/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleReport = dynamic( + () => import("@/components/vehicle/VehicleReport"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleReportsPage() { + return ( +
+
+

운행 리포트

+

+ 차량 운행 통계 및 분석 리포트를 확인합니다. +

+
+ +
+ ); +} + diff --git a/frontend/app/(admin)/admin/vehicle-trips/page.tsx b/frontend/app/(admin)/admin/vehicle-trips/page.tsx new file mode 100644 index 00000000..fea63166 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-trips/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleTripHistory = dynamic( + () => import("@/components/vehicle/VehicleTripHistory"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleTripsPage() { + return ( +
+
+

운행 이력 관리

+

+ 차량 운행 이력을 조회하고 관리합니다. +

+
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/auto-fill/page.tsx b/frontend/app/(main)/admin/auto-fill/page.tsx new file mode 100644 index 00000000..64e5e789 --- /dev/null +++ b/frontend/app/(main)/admin/auto-fill/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** + * 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트 + */ +export default function AutoFillRedirect() { + const router = useRouter(); + + useEffect(() => { + router.replace("/admin/cascading-management?tab=autofill"); + }, [router]); + + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index f70d711a..3093ed10 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo, memo } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -8,12 +8,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; +import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { toast } from "sonner"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; // 타입 정의 -type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; +type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi"; interface BatchTypeOption { value: BatchType; @@ -33,14 +33,45 @@ interface BatchColumnInfo { is_nullable: string; } +// 통합 매핑 아이템 타입 +interface MappingItem { + id: string; + dbColumn: string; + sourceType: "api" | "fixed"; + apiField: string; + fixedValue: string; +} + +interface RestApiToDbMappingCardProps { + fromApiFields: string[]; + toColumns: BatchColumnInfo[]; + fromApiData: any[]; + mappingList: MappingItem[]; + setMappingList: React.Dispatch>; +} + +interface DbToRestApiMappingCardProps { + fromColumns: BatchColumnInfo[]; + selectedColumns: string[]; + toApiFields: string[]; + dbToApiFieldMapping: Record; + setDbToApiFieldMapping: React.Dispatch>>; + setToApiBody: (body: string) => void; +} + export default function BatchManagementNewPage() { const router = useRouter(); - + // 기본 상태 const [batchName, setBatchName] = useState(""); const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); + // 인증 토큰 설정 + const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택 + const [authServiceName, setAuthServiceName] = useState(""); + const [authServiceNames, setAuthServiceNames] = useState([]); + // 연결 정보 const [connections, setConnections] = useState([]); const [toConnection, setToConnection] = useState(null); @@ -52,13 +83,15 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 - + const [fromApiMethod, setFromApiMethod] = useState<"GET" | "POST" | "PUT" | "DELETE">("GET"); + const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) + const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items) + // REST API 파라미터 설정 - const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); + const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none"); const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id) const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿 - const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값 + const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static"); // 정적 값 또는 동적 값 // DB → REST API용 상태 const [fromConnection, setFromConnection] = useState(null); @@ -67,13 +100,13 @@ export default function BatchManagementNewPage() { const [fromColumns, setFromColumns] = useState([]); const [selectedColumns, setSelectedColumns] = useState([]); // 선택된 컬럼들 const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState>({}); // DB 컬럼 → API 필드 매핑 - + // REST API 대상 설정 (DB → REST API용) const [toApiUrl, setToApiUrl] = useState(""); const [toApiKey, setToApiKey] = useState(""); const [toEndpoint, setToEndpoint] = useState(""); - const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST'); - const [toApiBody, setToApiBody] = useState(''); // Request Body 템플릿 + const [toApiMethod, setToApiMethod] = useState<"POST" | "PUT" | "DELETE">("POST"); + const [toApiBody, setToApiBody] = useState(""); // Request Body 템플릿 const [toApiFields, setToApiFields] = useState([]); // TO API 필드 목록 const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용) @@ -81,36 +114,51 @@ export default function BatchManagementNewPage() { const [fromApiData, setFromApiData] = useState([]); const [fromApiFields, setFromApiFields] = useState([]); - // API 필드 → DB 컬럼 매핑 - const [apiFieldMappings, setApiFieldMappings] = useState>({}); + // 통합 매핑 리스트 + const [mappingList, setMappingList] = useState([]); + + // INSERT/UPSERT 설정 + const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT"); + const [conflictKey, setConflictKey] = useState(""); // 배치 타입 상태 - const [batchType, setBatchType] = useState('restapi-to-db'); + const [batchType, setBatchType] = useState("restapi-to-db"); // 배치 타입 옵션 const batchTypeOptions: BatchTypeOption[] = [ { - value: 'restapi-to-db', - label: 'REST API → DB', - description: 'REST API에서 데이터베이스로 데이터 수집' + value: "restapi-to-db", + label: "REST API → DB", + description: "REST API에서 데이터베이스로 데이터 수집", }, { - value: 'db-to-restapi', - label: 'DB → REST API', - description: '데이터베이스에서 REST API로 데이터 전송' - } + value: "db-to-restapi", + label: "DB → REST API", + description: "데이터베이스에서 REST API로 데이터 전송", + }, ]; // 초기 데이터 로드 useEffect(() => { loadConnections(); + loadAuthServiceNames(); }, []); + // 인증 서비스명 목록 로드 + const loadAuthServiceNames = async () => { + try { + const serviceNames = await BatchManagementAPI.getAuthServiceNames(); + setAuthServiceNames(serviceNames); + } catch (error) { + console.error("인증 서비스 목록 로드 실패:", error); + } + }; + // 배치 타입 변경 시 상태 초기화 useEffect(() => { // 공통 초기화 - setApiFieldMappings({}); - + setMappingList([]); + // REST API → DB 관련 초기화 setToConnection(null); setToTables([]); @@ -121,7 +169,7 @@ export default function BatchManagementNewPage() { setFromEndpoint(""); setFromApiData([]); setFromApiFields([]); - + // DB → REST API 관련 초기화 setFromConnection(null); setFromTables([]); @@ -136,7 +184,6 @@ export default function BatchManagementNewPage() { setToApiFields([]); }, [batchType]); - // 연결 목록 로드 const loadConnections = async () => { try { @@ -151,26 +198,26 @@ export default function BatchManagementNewPage() { // TO 연결 변경 핸들러 const handleToConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - - if (connectionValue === 'internal') { + + if (connectionValue === "internal") { // 내부 데이터베이스 선택 - connection = connections.find(conn => conn.type === 'internal') || null; + connection = connections.find((conn) => conn.type === "internal") || null; } else { // 외부 데이터베이스 선택 const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } - + setToConnection(connection); setToTable(""); setToColumns([]); if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); - const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + const tableNames = Array.isArray(result) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setToTables(tableNames); } catch (error) { @@ -182,24 +229,17 @@ export default function BatchManagementNewPage() { // TO 테이블 변경 핸들러 const handleToTableChange = async (tableName: string) => { - console.log("🔍 테이블 변경:", { tableName, toConnection }); setToTable(tableName); setToColumns([]); if (toConnection && tableName) { try { - const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; - console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName }); - + const connectionType = toConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); - console.log("🔍 컬럼 조회 결과:", result); - if (result && result.length > 0) { setToColumns(result); - console.log("✅ 컬럼 설정 완료:", result.length, "개"); } else { setToColumns([]); - console.log("⚠️ 컬럼이 없음"); } } catch (error) { console.error("❌ 컬럼 목록 로드 오류:", error); @@ -212,11 +252,11 @@ export default function BatchManagementNewPage() { // FROM 연결 변경 핸들러 (DB → REST API용) const handleFromConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - if (connectionValue === 'internal') { - connection = connections.find(conn => conn.type === 'internal') || null; + if (connectionValue === "internal") { + connection = connections.find((conn) => conn.type === "internal") || null; } else { const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } setFromConnection(connection); setFromTable(""); @@ -224,10 +264,10 @@ export default function BatchManagementNewPage() { if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setFromTables(tableNames); } catch (error) { @@ -239,7 +279,6 @@ export default function BatchManagementNewPage() { // FROM 테이블 변경 핸들러 (DB → REST API용) const handleFromTableChange = async (tableName: string) => { - console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection }); setFromTable(tableName); setFromColumns([]); setSelectedColumns([]); // 선택된 컬럼도 초기화 @@ -247,18 +286,12 @@ export default function BatchManagementNewPage() { if (fromConnection && tableName) { try { - const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; - console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName }); - + const connectionType = fromConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); - console.log("🔍 FROM 컬럼 조회 결과:", result); - if (result && result.length > 0) { setFromColumns(result); - console.log("✅ FROM 컬럼 설정 완료:", result.length, "개"); } else { setFromColumns([]); - console.log("⚠️ FROM 컬럼이 없음"); } } catch (error) { console.error("❌ FROM 컬럼 목록 로드 오류:", error); @@ -276,17 +309,13 @@ export default function BatchManagementNewPage() { } try { - console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod }); - const result = await BatchManagementAPI.previewRestApiData( toApiUrl, toApiKey, toEndpoint, - 'GET' // 미리보기는 항상 GET으로 + "GET", // 미리보기는 항상 GET으로 ); - console.log("🔍 TO API 미리보기 결과:", result); - if (result.fields && result.fields.length > 0) { setToApiFields(result.fields); toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`); @@ -303,52 +332,59 @@ export default function BatchManagementNewPage() { // REST API 데이터 미리보기 const previewRestApiData = async () => { - if (!fromApiUrl || !fromApiKey || !fromEndpoint) { - toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + // API URL, 엔드포인트는 항상 필수 + if (!fromApiUrl || !fromEndpoint) { + toast.error("API URL과 엔드포인트를 모두 입력해주세요."); + return; + } + + // 직접 입력 모드일 때만 토큰 검증 + if (authTokenMode === "direct" && !fromApiKey) { + toast.error("인증 토큰을 입력해주세요."); + return; + } + + // DB 선택 모드일 때 서비스명 검증 + if (authTokenMode === "db" && !authServiceName) { + toast.error("인증 토큰 서비스를 선택해주세요."); return; } try { - console.log("REST API 데이터 미리보기 시작..."); - const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey, + authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달 fromEndpoint, fromApiMethod, // 파라미터 정보 추가 - apiParamType !== 'none' ? { - paramType: apiParamType, - paramName: apiParamName, - paramValue: apiParamValue, - paramSource: apiParamSource - } : undefined + apiParamType !== "none" + ? { + paramType: apiParamType, + paramName: apiParamName, + paramValue: apiParamValue, + paramSource: apiParamSource, + } + : undefined, + // Request Body 추가 (POST/PUT/DELETE) + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + // DB 선택 모드일 때 서비스명 전달 + authTokenMode === "db" ? authServiceName : undefined, + // 데이터 배열 경로 전달 + dataArrayPath || undefined, ); - console.log("API 미리보기 결과:", result); - console.log("result.fields:", result.fields); - console.log("result.samples:", result.samples); - console.log("result.totalCount:", result.totalCount); - if (result.fields && result.fields.length > 0) { - console.log("✅ 백엔드에서 fields 제공됨:", result.fields); setFromApiFields(result.fields); setFromApiData(result.samples); - - console.log("추출된 필드:", result.fields); toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`); } else if (result.samples && result.samples.length > 0) { // 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출 - console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출"); const extractedFields = Object.keys(result.samples[0]); - console.log("프론트엔드에서 추출한 필드:", extractedFields); - setFromApiFields(extractedFields); setFromApiData(result.samples); - + toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); } else { - console.log("❌ 데이터가 없음"); setFromApiFields([]); setFromApiData([]); toast.warning("API에서 데이터를 가져올 수 없습니다."); @@ -369,40 +405,45 @@ export default function BatchManagementNewPage() { } // 배치 타입별 검증 및 저장 - if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); - if (mappedFields.length === 0) { - toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); + if (batchType === "restapi-to-db") { + // 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것) + const validMappings = mappingList.filter( + (m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue), + ); + + if (validMappings.length === 0) { + toast.error("최소 하나의 매핑을 설정해주세요."); return; } - - // API 필드 매핑을 배치 매핑 형태로 변환 - const apiMappings = mappedFields.map(apiField => ({ - from_connection_type: 'restapi' as const, - from_table_name: fromEndpoint, // API 엔드포인트 - from_column_name: apiField, // API 필드명 - from_api_url: fromApiUrl, - from_api_key: fromApiKey, - from_api_method: fromApiMethod, - // API 파라미터 정보 추가 - from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined, - from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined, - from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined, - from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined, - to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', - to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, - to_table_name: toTable, - to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 - mapping_type: 'direct' as const - })); - console.log("REST API 배치 설정 저장:", { - batchName, - batchType, - cronSchedule, - description, - apiMappings - }); + // UPSERT 모드일 때 conflict key 검증 + if (saveMode === "UPSERT" && !conflictKey) { + toast.error("UPSERT 모드에서는 충돌 기준 컬럼을 선택해주세요."); + return; + } + + // 통합 매핑 리스트를 배치 매핑 형태로 변환 + // 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨 + const apiMappings = validMappings.map((mapping) => ({ + from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용 + from_table_name: fromEndpoint, + from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue, + from_api_url: fromApiUrl, + from_api_key: authTokenMode === "direct" ? fromApiKey : "", + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, + from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, + from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, + from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined, + to_connection_type: toConnection?.type === "internal" ? "internal" : "external", + to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: mapping.dbColumn, + mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const), + fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined, + })); // 실제 API 호출 try { @@ -411,13 +452,17 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings + apiMappings, + authServiceName: authTokenMode === "db" ? authServiceName : undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode, + conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, }); if (result.success) { toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -427,79 +472,71 @@ export default function BatchManagementNewPage() { toast.error("배치 저장 중 오류가 발생했습니다."); } return; - } else if (batchType === 'db-to-restapi') { + } else if (batchType === "db-to-restapi") { // DB → REST API 배치 검증 if (!fromConnection || !fromTable || selectedColumns.length === 0) { toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요."); return; } - + if (!toApiUrl || !toApiKey || !toEndpoint) { toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요."); return; } - if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) { + if ((toApiMethod === "POST" || toApiMethod === "PUT") && !toApiBody) { toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요."); return; } // DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정 let finalToApiBody = toApiBody; - if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) { - finalToApiBody = '{}'; + if (toApiMethod === "DELETE" && !finalToApiBody.trim()) { + finalToApiBody = "{}"; } // DB → REST API 매핑 생성 (선택된 컬럼만) - const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name)); + const selectedColumnObjects = fromColumns.filter((column) => selectedColumns.includes(column.column_name)); const dbMappings = selectedColumnObjects.map((column, index) => ({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: column.column_name, from_column_type: column.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, // API 엔드포인트 to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, // Request Body 템플릿 - mapping_type: 'template' as const, - mapping_order: index + 1 + mapping_type: "template" as const, + mapping_order: index + 1, })); // URL 경로 파라미터 매핑 추가 (PUT/DELETE용) - if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) { - const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn); + if ((toApiMethod === "PUT" || toApiMethod === "DELETE") && urlPathColumn) { + const urlPathColumnObject = fromColumns.find((col) => col.column_name === urlPathColumn); if (urlPathColumnObject) { dbMappings.push({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: urlPathColumn, from_column_type: urlPathColumnObject.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, - to_column_name: 'URL_PATH_PARAM', // 특별한 식별자 + to_column_name: "URL_PATH_PARAM", // 특별한 식별자 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, - mapping_type: 'url_path' as const, - mapping_order: 999 // 마지막 순서 + mapping_type: "url_path" as const, + mapping_order: 999, // 마지막 순서 }); } } - console.log("DB → REST API 배치 설정 저장:", { - batchName, - batchType, - cronSchedule, - description, - dbMappings - }); - // 실제 API 호출 (기존 saveRestApiBatch 재사용) try { const result = await BatchManagementAPI.saveRestApiBatch({ @@ -507,13 +544,14 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings: dbMappings + apiMappings: dbMappings, + authServiceName: authServiceName || undefined, }); if (result.success) { toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -529,19 +567,10 @@ export default function BatchManagementNewPage() { }; return ( -
-
+
+ {/* 페이지 헤더 */} +

고급 배치 생성

-
- - -
{/* 기본 정보 */} @@ -553,26 +582,24 @@ export default function BatchManagementNewPage() { {/* 배치 타입 선택 */}
-
+
{batchTypeOptions.map((option) => (
setBatchType(option.value)} >
- {option.value === 'restapi-to-db' ? ( - + {option.value === "restapi-to-db" ? ( + ) : ( - + )}
-
{option.label}
-
{option.description}
+
{option.label}
+
{option.description}
@@ -580,7 +607,7 @@ export default function BatchManagementNewPage() {
-
+
- {/* FROM 설정 */} - - - - {batchType === 'restapi-to-db' ? ( - <> - - FROM: REST API (소스) - - ) : ( - <> - - FROM: 데이터베이스 (소스) - - )} - - - - {/* REST API 설정 (REST API → DB) */} - {batchType === 'restapi-to-db' && ( -
-
+ {/* FROM/TO 설정 - 가로 배치 */} +
+ {/* FROM 설정 */} + + + + {batchType === "restapi-to-db" ? ( + <> + + FROM: REST API (소스) + + ) : ( + <> + + FROM: 데이터베이스 (소스) + + )} + + + + {/* REST API 설정 (REST API → DB) */} + {batchType === "restapi-to-db" && ( +
+ {/* API 서버 URL */}
-
- - setFromApiKey(e.target.value)} - placeholder="ak_your_api_key_here" - /> -
-
-
+ {/* 인증 토큰 설정 */} +
+ + {/* 토큰 설정 방식 선택 */} +
+ + +
+ {/* 직접 입력 모드 */} + {authTokenMode === "direct" && ( + setFromApiKey(e.target.value)} + placeholder="Bearer eyJhbGciOiJIUzI1NiIs..." + className="mt-2" + /> + )} + {/* DB 선택 모드 */} + {authTokenMode === "db" && ( + + )} +

+ {authTokenMode === "direct" + ? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요." + : "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."} +

+
+ + {/* 엔드포인트 */}
+ + {/* HTTP 메서드 */}
-
- - {/* API 파라미터 설정 */} -
-
- -

특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.

-
- + {/* 데이터 배열 경로 */}
- - + + setDataArrayPath(e.target.value)} + placeholder="response (예: data.items, results)" + /> +

+ API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다. +
+ 예시: response, data.items, result.list +

- {apiParamType !== 'none' && ( - <> -
+ {/* Request Body (POST/PUT/DELETE용) */} + {(fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") && ( +
+ +