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/backend-node/src/app.ts b/backend-node/src/app.ts index d214c19a..5c2415ea 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"; @@ -74,6 +75,12 @@ 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"; // 임시 주석 @@ -168,6 +175,10 @@ const limiter = rateLimit({ }); app.use("/api/", limiter); +// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용) +// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함 +app.use("/api/", refreshTokenIfNeeded); + // 헬스 체크 엔드포인트 app.get("/health", (req, res) => { res.status(200).json({ @@ -240,6 +251,12 @@ 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); // 임시 주석 diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index e324c332..d1328bcd 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -632,6 +632,9 @@ export class DashboardController { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) }; + // 연결 정보 (응답에 포함용) + let connectionInfo: { saveToHistory?: boolean } | null = null; + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 if (externalConnectionId) { try { @@ -652,6 +655,11 @@ export class DashboardController { if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; + // 연결 정보 저장 (응답에 포함) + connectionInfo = { + saveToHistory: connection.save_to_history === "Y", + }; + // 인증 헤더 생성 (DB 토큰 등) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( @@ -694,6 +702,15 @@ export class DashboardController { requestConfig.data = body; } + // 디버깅 로그: 실제 요청 정보 출력 + logger.info(`[fetchExternalApi] 요청 정보:`, { + url: requestConfig.url, + method: requestConfig.method, + headers: requestConfig.headers, + body: requestConfig.data, + externalConnectionId, + }); + // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) // ExternalRestApiConnectionService와 동일한 로직 적용 const bypassDomains = ["thiratis.com"]; @@ -709,9 +726,9 @@ export class DashboardController { } // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 - const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { - requestConfig.responseType = 'arraybuffer'; + requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); @@ -727,18 +744,22 @@ export class DashboardController { // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); const buffer = Buffer.from(data); - const utf8Text = buffer.toString('utf-8'); - + 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' }; + 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' }; + const eucKrText = iconv.decode(buffer, "EUC-KR"); + data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // 텍스트 응답인 경우 포맷팅 @@ -749,6 +770,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3ac5d26b..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) { @@ -3406,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/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts new file mode 100644 index 00000000..bf033880 --- /dev/null +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -0,0 +1,568 @@ +/** + * 자동 입력 (Auto-Fill) 컨트롤러 + * 마스터 선택 시 여러 필드 자동 입력 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 자동 입력 그룹 CRUD +// ===================================================== + +/** + * 자동 입력 그룹 목록 조회 + */ +export const getAutoFillGroups = async (req: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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..cf30a725 --- /dev/null +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -0,0 +1,525 @@ +/** + * 조건부 연쇄 (Conditional Cascading) 컨트롤러 + * 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 조건부 연쇄 규칙 CRUD +// ===================================================== + +/** + * 조건부 연쇄 규칙 목록 조회 + */ +export const getConditions = async (req: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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..59d243e2 --- /dev/null +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -0,0 +1,752 @@ +/** + * 다단계 계층 (Hierarchy) 컨트롤러 + * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 계층 그룹 CRUD +// ===================================================== + +/** + * 계층 그룹 목록 조회 + */ +export const getHierarchyGroups = async (req: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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..8714c73b --- /dev/null +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -0,0 +1,505 @@ +/** + * 상호 배제 (Mutual Exclusion) 컨트롤러 + * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 상호 배제 규칙 CRUD +// ===================================================== + +/** + * 상호 배제 규칙 목록 조회 + */ +export const getExclusions = async (req: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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..3f7b5cb6 --- /dev/null +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -0,0 +1,750 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +/** + * 연쇄 관계 목록 조회 + */ +export const getCascadingRelations = async (req: Request, 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: Request, 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: Request, + 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: Request, 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: Request, 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: Request, 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: Request, 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: Request, 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/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..98606f51 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -427,7 +427,8 @@ export const updateFieldValue = async ( ): Promise => { try { const { companyCode, userId } = req.user as any; - const { tableName, keyField, keyValue, updateField, updateValue } = req.body; + const { tableName, keyField, keyValue, updateField, updateValue } = + req.body; console.log("🔄 [updateFieldValue] 요청:", { tableName, @@ -440,16 +441,27 @@ export const updateFieldValue = async ( }); // 필수 필드 검증 - if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { + if ( + !tableName || + !keyField || + keyValue === undefined || + !updateField || + updateValue === undefined + ) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", + message: + "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", }); } // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { + if ( + !validNamePattern.test(tableName) || + !validNamePattern.test(keyField) || + !validNamePattern.test(updateField) + ) { return res.status(400).json({ success: false, message: "유효하지 않은 테이블명 또는 컬럼명입니다.", @@ -492,7 +504,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { try { - const { companyCode, userId } = req.user as any; + const { companyCode, userId: loginUserId } = req.user as any; const { latitude, longitude, @@ -508,10 +520,17 @@ export const saveLocationHistory = async ( destinationName, recordedAt, vehicleId, + userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등) } = req.body; + // 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등) + // 없으면 로그인한 사용자의 userId 사용 + const userId = requestUserId || loginUserId; + console.log("📍 [saveLocationHistory] 요청:", { userId, + requestUserId, + loginUserId, companyCode, latitude, longitude, 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 9459e1f6..393b33cc 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -837,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/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 248bb867..75e225e6 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re } }; +/** + * 카테고리 코드로 라벨 조회 + * + * POST /api/table-categories/labels-by-codes + * + * Body: + * - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"]) + * + * Response: + * - { [code]: label } 형태의 매핑 객체 + */ +export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { valueCodes } = req.body; + + if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) { + return res.json({ + success: true, + data: {}, + }); + } + + logger.info("카테고리 코드로 라벨 조회", { + valueCodes, + companyCode, + }); + + const labels = await tableCategoryValueService.getCategoryLabelsByCodes( + valueCodes, + companyCode + ); + + return res.json({ + success: true, + data: labels, + }); + } catch (error: any) { + logger.error(`카테고리 라벨 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 라벨 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 2레벨 메뉴 목록 조회 * 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 4a80b007..66c70a77 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1811,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/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/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts new file mode 100644 index 00000000..5d922dd6 --- /dev/null +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -0,0 +1,52 @@ +/** + * 자동 입력 (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..813dbff1 --- /dev/null +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -0,0 +1,48 @@ +/** + * 조건부 연쇄 (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..be37da49 --- /dev/null +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -0,0 +1,64 @@ +/** + * 다단계 계층 (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..46bbf427 --- /dev/null +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -0,0 +1,52 @@ +/** + * 상호 배제 (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/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/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index b79aab75..e59d9b9d 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -13,6 +13,7 @@ import { deleteColumnMapping, deleteColumnMappingsByColumn, getSecondLevelMenus, + getCategoryLabelsByCodes, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues); // 카테고리 값 순서 변경 router.post("/values/reorder", reorderCategoryValues); +// 카테고리 코드로 라벨 조회 +router.post("/labels-by-codes", getCategoryLabelsByCodes); + // ================================================ // 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) // ================================================ 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/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/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 743c0386..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -65,12 +65,18 @@ export class BatchSchedulerService { `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` ); - const task = cron.schedule(config.cron_schedule, async () => { - logger.info( - `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` - ); - await this.executeBatchConfig(config); - }); + 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) { 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 04586d65..65efcd1b 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -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); @@ -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,32 @@ 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 +1127,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 +1240,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 +1278,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) { @@ -1501,7 +1594,8 @@ export class DynamicFormService { tableName: string, savedData: Record, triggerType: "insert" | "update" | "delete", - userId: string = "system" + userId: string = "system", + companyCode: string = "*" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); @@ -1530,9 +1624,11 @@ 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, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 @@ -1557,21 +1653,27 @@ export class DynamicFormService { // 노드 플로우 실행 (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, - }); - + 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, @@ -1586,15 +1688,18 @@ export class DynamicFormService { }; } else { // 관계 기반 제어관리 실행 - console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`); - controlResult = await this.dataflowControlService.executeDataflowControl( - diagramId, - relationshipId, - triggerType, - savedData, - tableName, - userId + console.log( + `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` ); + controlResult = + await this.dataflowControlService.executeDataflowControl( + diagramId, + relationshipId, + triggerType, + savedData, + tableName, + userId + ); } console.log(`🎯 제어관리 실행 결과:`, controlResult); @@ -1651,7 +1756,7 @@ export class DynamicFormService { ): Promise<{ affectedRows: number }> { const pool = getPool(); const client = await pool.connect(); - + try { console.log("🔄 [updateFieldValue] 업데이트 실행:", { tableName, @@ -1669,11 +1774,13 @@ export class DynamicFormService { 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'); + 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, @@ -1870,7 +1977,8 @@ export class DynamicFormService { paramIndex++; } - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; const sqlQuery = ` diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService { connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + 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 * `; @@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService { data.company_code || "*", data.is_active || "Y", data.created_by || "system", + data.save_to_history || "N", ]; // 디버깅: 저장하려는 데이터 로깅 @@ -377,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); 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/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/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9cdd85f3..461cd8d2 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.getAccounts(); + 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.evaluateCondition( + 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 evaluateCondition( + 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 83b4f63b..7ba5c47e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -607,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]; @@ -897,13 +899,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // 순번 (현재 순번으로 미리보기, 증가 안 함) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).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"); } @@ -957,13 +959,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // 순번 (자동 증가 숫자) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).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/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6628cf4c..9fc0f079 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2360,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 b68d5f05..cdf1b838 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1258,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/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/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -53,6 +53,9 @@ export interface ExternalRestApiConnection { 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; 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/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md new file mode 100644 index 00000000..e80a1a61 --- /dev/null +++ b/docs/노드플로우_개선사항.md @@ -0,0 +1,584 @@ +# 노드 플로우 기능 개선 사항 + +> 작성일: 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..2ef68524 --- /dev/null +++ b/docs/메일발송_기능_사용_가이드.md @@ -0,0 +1,357 @@ +# 메일 발송 기능 사용 가이드 + +## 개요 + +노드 기반 제어관리 시스템을 통해 메일을 발송하는 방법을 설명합니다. +화면에서 데이터를 선택하고, 수신자를 지정하여 템플릿 기반의 메일을 발송할 수 있습니다. + +--- + +## 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/(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/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx new file mode 100644 index 00000000..70382dd9 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; + +// 탭별 컴포넌트 +import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; +import AutoFillTab from "./tabs/AutoFillTab"; +import HierarchyTab from "./tabs/HierarchyTab"; +import ConditionTab from "./tabs/ConditionTab"; +import MutualExclusionTab from "./tabs/MutualExclusionTab"; + +export default function CascadingManagementPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState("relations"); + + // URL 쿼리 파라미터에서 탭 설정 + useEffect(() => { + const tab = searchParams.get("tab"); + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + setActiveTab(tab); + } + }, [searchParams]); + + // 탭 변경 시 URL 업데이트 + const handleTabChange = (value: string) => { + setActiveTab(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + router.replace(url.pathname + url.search); + }; + + return ( +
+
+ {/* 페이지 헤더 */} +
+

연쇄 드롭다운 통합 관리

+

+ 연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다. +

+
+ + {/* 탭 네비게이션 */} + + + + + 2단계 연쇄관계 + 연쇄 + + + + 다단계 계층 + 계층 + + + + 조건부 필터 + 조건 + + + + 자동 입력 + 자동 + + + + 상호 배제 + 배제 + + + + {/* 탭 컨텐츠 */} +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx new file mode 100644 index 00000000..79208186 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx @@ -0,0 +1,686 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { + Check, + ChevronsUpDown, + Plus, + Pencil, + Trash2, + Search, + RefreshCw, + ArrowRight, + X, + GripVertical, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableColumn { + columnName: string; + columnLabel?: string; + dataType?: string; +} + +export default function AutoFillTab() { + // 목록 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(""); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [deletingGroupCode, setDeletingGroupCode] = useState(null); + + // 테이블/컬럼 목록 + const [tableList, setTableList] = useState>([]); + const [masterColumns, setMasterColumns] = useState([]); + + // 폼 데이터 + const [formData, setFormData] = useState({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + + // 매핑 데이터 + const [mappings, setMappings] = useState([]); + + // 테이블 Combobox 상태 + const [tableComboOpen, setTableComboOpen] = useState(false); + + // 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const response = await cascadingAutoFillApi.getGroups(); + if (response.success && response.data) { + setGroups(response.data); + } + } catch (error) { + console.error("그룹 목록 로드 실패:", error); + toast.error("그룹 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTableList = useCallback(async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTableList(response.data); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }, []); + + // 테이블 컬럼 로드 + const loadColumns = useCallback(async (tableName: string) => { + if (!tableName) { + setMasterColumns([]); + return; + } + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data?.columns) { + setMasterColumns( + response.data.columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName, + dataType: col.dataType || col.data_type, + })), + ); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setMasterColumns([]); + } + }, []); + + useEffect(() => { + loadGroups(); + loadTableList(); + }, [loadGroups, loadTableList]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.masterTable) { + loadColumns(formData.masterTable); + } + }, [formData.masterTable, loadColumns]); + + // 필터된 목록 + const filteredGroups = groups.filter( + (g) => + g.groupCode.toLowerCase().includes(searchText.toLowerCase()) || + g.groupName.toLowerCase().includes(searchText.toLowerCase()) || + g.masterTable?.toLowerCase().includes(searchText.toLowerCase()), + ); + + // 모달 열기 (생성) + const handleOpenCreate = () => { + setEditingGroup(null); + setFormData({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + setMappings([]); + setMasterColumns([]); + setIsModalOpen(true); + }; + + // 모달 열기 (수정) + const handleOpenEdit = async (group: AutoFillGroup) => { + setEditingGroup(group); + + // 상세 정보 로드 + const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode); + if (detailResponse.success && detailResponse.data) { + const detail = detailResponse.data; + + // 컬럼 먼저 로드 + if (detail.masterTable) { + await loadColumns(detail.masterTable); + } + + setFormData({ + groupCode: detail.groupCode, + groupName: detail.groupName, + description: detail.description || "", + masterTable: detail.masterTable, + masterValueColumn: detail.masterValueColumn, + masterLabelColumn: detail.masterLabelColumn || "", + isActive: detail.isActive || "Y", + }); + + // 매핑 데이터 변환 (snake_case → camelCase) + const convertedMappings = (detail.mappings || []).map((m: any) => ({ + sourceColumn: m.source_column || m.sourceColumn, + targetField: m.target_field || m.targetField, + targetLabel: m.target_label || m.targetLabel || "", + isEditable: m.is_editable || m.isEditable || "Y", + isRequired: m.is_required || m.isRequired || "N", + defaultValue: m.default_value || m.defaultValue || "", + sortOrder: m.sort_order || m.sortOrder || 0, + })); + setMappings(convertedMappings); + } + + setIsModalOpen(true); + }; + + // 삭제 확인 + const handleDeleteConfirm = (groupCode: string) => { + setDeletingGroupCode(groupCode); + setIsDeleteDialogOpen(true); + }; + + // 삭제 실행 + const handleDelete = async () => { + if (!deletingGroupCode) return; + + try { + const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode); + if (response.success) { + toast.success("자동 입력 그룹이 삭제되었습니다."); + loadGroups(); + } else { + toast.error(response.error || "삭제에 실패했습니다."); + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroupCode(null); + } + }; + + // 저장 + const handleSave = async () => { + // 유효성 검사 + if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) { + toast.error("필수 항목을 모두 입력해주세요."); + return; + } + + try { + const saveData = { + ...formData, + mappings, + }; + + let response; + if (editingGroup) { + response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData); + } else { + response = await cascadingAutoFillApi.createGroup(saveData); + } + + if (response.success) { + toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다."); + setIsModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "저장에 실패했습니다."); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 매핑 추가 + const handleAddMapping = () => { + setMappings([ + ...mappings, + { + sourceColumn: "", + targetField: "", + targetLabel: "", + isEditable: "Y", + isRequired: "N", + defaultValue: "", + sortOrder: mappings.length + 1, + }, + ]); + }; + + // 매핑 삭제 + const handleRemoveMapping = (index: number) => { + setMappings(mappings.filter((_, i) => i !== index)); + }; + + // 매핑 수정 + const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => { + const updated = [...mappings]; + updated[index] = { ...updated[index], [field]: value }; + setMappings(updated); + }; + + return ( +
+ {/* 검색 및 액션 */} + + +
+
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ +
+
+
+ + {/* 목록 */} + + +
+
+ 자동 입력 그룹 + + 마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개) + +
+ +
+
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : filteredGroups.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."} +
+ ) : ( + + + + 그룹 코드 + 그룹명 + 마스터 테이블 + 매핑 수 + 상태 + 작업 + + + + {filteredGroups.map((group) => ( + + {group.groupCode} + {group.groupName} + {group.masterTable} + + {group.mappingCount || 0}개 + + + + {group.isActive === "Y" ? "활성" : "비활성"} + + + + + + + + ))} + +
+ )} +
+
+ + {/* 생성/수정 모달 */} + + + + {editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"} + 마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다. + + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+ + setFormData({ ...formData, groupName: e.target.value })} + placeholder="예: 고객사 정보 자동입력" + /> +
+ +
+ +