diff --git a/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java b/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java new file mode 100644 index 00000000..30438e01 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java @@ -0,0 +1,391 @@ +package com.erp.batch; + +import com.erp.service.ExternalDbConnectionService; +import com.erp.service.ExternalRestApiConnectionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.session.SqlSession; +import org.springframework.stereotype.Service; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.*; +import java.util.regex.Pattern; + +/** + * 배치 ETL 실행기 — vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식. + * + * 흐름: + * 1. 매핑을 (fixed | non-fixed) 로 partition + * 2. non-fixed 매핑을 (from_connection_type, from_connection_id, from_table_name) 키로 그룹화 + * 3. 그룹별로 FROM 데이터 읽기 → MappingTransformer 로 행 변환 → TO 저장 + * 4. (totalRecords, successRecords, failedRecords) 집계 + * + * FROM 소스 지원: + * - internal : 현 tenant DB 의 테이블 (JDBC 직접 SELECT, LIMIT 1000) + * - external_db : ExternalDbConnectionService.executeQuery (SELECT-only 보안 정책) + * - restapi : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath) + * + * TO 대상 지원: + * - internal : 현 tenant DB INSERT / UPSERT (save_mode + conflict_key) + * - restapi : 행 단위 POST/PUT/DELETE — testConnection 으로 호출 + * - external_db : 미지원 (ExternalDbConnectionService 가 SELECT-only 라 의도적으로 차단) + * + * 미지원 (vexplor_rps 대비 단순화): + * - to_api_body 템플릿 기반 일괄 전송 + * - URL_PATH_PARAM 컬럼 처리 + * - auth_tokens 자동 조회 (inline-mode REST API) + * - row_filter_config + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class BatchExecutor { + + private final SqlSession sqlSession; + private final ExternalDbConnectionService externalDb; + private final ExternalRestApiConnectionService externalRest; + + /** PostgreSQL 식별자 화이트리스트 (영문/숫자/언더스코어만). SQL injection 방어용. */ + private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final int FROM_LIMIT = 1000; + + public ExecutionResult execute(Map config) { + ExecutionResult r = new ExecutionResult(); + Object mappingsRaw = config.get("batch_mappings"); + if (!(mappingsRaw instanceof List)) { + log.warn("배치 매핑이 없습니다: {}", config.get("batch_name")); + return r; + } + @SuppressWarnings("unchecked") + List> mappings = (List>) mappingsRaw; + if (mappings.isEmpty()) { + log.warn("배치 매핑이 없습니다: {}", config.get("batch_name")); + return r; + } + + // 1. fixed 분리 + MappingTransformer.Partition partition = MappingTransformer.partitionFixed(mappings); + + // 2. non-fixed 그룹화 (from_connection 기준) + Map>> tableGroups = new LinkedHashMap<>(); + for (Map m : partition.nonFixed) { + String key = str(m.get("from_connection_type")) + ":" + + (m.get("from_connection_id") == null ? "internal" : m.get("from_connection_id")) + + ":" + str(m.get("from_table_name")); + tableGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + if (tableGroups.isEmpty() && !partition.fixed.isEmpty()) { + log.warn("일반 매핑이 없고 고정값 매핑만 있어 실행 불가"); + return r; + } + + String companyCode = str(config.get("company_code")); + String saveMode = strOr(config.get("save_mode"), "INSERT"); + String conflictKey = str(config.get("conflict_key")); + String dataArrayPath = str(config.get("data_array_path")); + + // 3. 그룹별 처리 + for (Map.Entry>> e : tableGroups.entrySet()) { + String key = e.getKey(); + List> groupMappings = e.getValue(); + Map first = groupMappings.get(0); + try { + log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size()); + + // FROM 읽기 + List> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode); + + r.totalRecords += fromData.size(); + + // Transform + String toConnType = str(first.get("to_connection_type")); + List> mappedRows = new ArrayList<>(fromData.size()); + for (Map row : fromData) { + mappedRows.add(MappingTransformer.transformRow( + row, groupMappings, partition.fixed, toConnType, companyCode)); + } + + // TO 저장 + WriteResult wr = writeTo(first, mappedRows, saveMode, conflictKey, companyCode); + r.successRecords += wr.success; + r.failedRecords += wr.failed; + + } catch (Exception ex) { + log.error("테이블 처리 중 오류: {} — {}", key, ex.getMessage(), ex); + r.errorMessages.add(key + ": " + ex.getMessage()); + } + } + + return r; + } + + // ── FROM 읽기 ─────────────────────────────────────────────────────────── + + private List> readFrom( + Map firstMapping, + List> groupMappings, + String dataArrayPath, + String companyCode + ) { + String type = str(firstMapping.get("from_connection_type")); + String tableName = str(firstMapping.get("from_table_name")); + List columns = new ArrayList<>(); + for (Map m : groupMappings) { + String col = str(m.get("from_column_name")); + if (col != null && !col.isEmpty() && !columns.contains(col)) columns.add(col); + } + + if ("restapi".equals(type)) { + return readFromRestApi(firstMapping, dataArrayPath, companyCode); + } + if ("external".equals(type) || "external_db".equals(type)) { + return readFromExternalDb(firstMapping, columns); + } + // internal (기본) + return readFromInternal(tableName, columns); + } + + /** Internal DB 의 동적 SELECT. sqlSession 의 현 tenant connection 사용. */ + private List> readFromInternal(String tableName, List columns) { + if (tableName == null) throw new IllegalArgumentException("from_table_name 누락"); + if (columns.isEmpty()) throw new IllegalArgumentException("from_column_name 매핑 없음"); + StringBuilder sql = new StringBuilder("SELECT "); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(columns.get(i))); + } + sql.append(" FROM ").append(safeIdent(tableName)); + sql.append(" LIMIT ").append(FROM_LIMIT); + + try (Connection c = sqlSession.getConnection(); + PreparedStatement ps = c.prepareStatement(sql.toString()); + ResultSet rs = ps.executeQuery()) { + return materialize(rs); + } catch (SQLException e) { + throw new RuntimeException("internal SELECT 실패: " + e.getMessage(), e); + } + } + + /** External DB SELECT — ExternalDbConnectionService.executeQuery 경유 (SELECT-only). */ + @SuppressWarnings("unchecked") + private List> readFromExternalDb(Map firstMapping, List columns) { + Object connIdObj = firstMapping.get("from_connection_id"); + if (connIdObj == null) throw new IllegalArgumentException("external_db 인데 from_connection_id 가 비어있음"); + long connId = Long.parseLong(connIdObj.toString()); + String tableName = str(firstMapping.get("from_table_name")); + StringBuilder sql = new StringBuilder("SELECT "); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(columns.get(i))); + } + sql.append(" FROM ").append(safeIdent(tableName)).append(" LIMIT ").append(FROM_LIMIT); + + Map result = externalDb.executeQuery(connId, sql.toString()); + Object data = result.get("data"); + return data instanceof List ? (List>) data : List.of(); + } + + /** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */ + @SuppressWarnings("unchecked") + private List> readFromRestApi( + Map firstMapping, String dataArrayPath, String companyCode + ) { + Object connIdObj = firstMapping.get("from_connection_id"); + if (connIdObj == null) { + throw new UnsupportedOperationException( + "REST API 등록 연결 없는 inline-mode (from_api_url 직접 호출) 는 현재 미지원"); + } + int connId = Integer.parseInt(connIdObj.toString()); + String endpoint = str(firstMapping.get("from_table_name")); + + Map params = new HashMap<>(); + if (companyCode != null) params.put("company_code", companyCode); + Map result = externalRest.fetchData(connId, endpoint, dataArrayPath, params); + + if (!Boolean.TRUE.equals(result.get("success"))) { + throw new RuntimeException("REST API 호출 실패: " + result.getOrDefault("message", "")); + } + Object data = result.get("data"); + if (!(data instanceof Map)) return List.of(); + Object rows = ((Map) data).get("rows"); + if (!(rows instanceof List)) return List.of(); + List raw = (List) rows; + List> out = new ArrayList<>(raw.size()); + for (Object o : raw) if (o instanceof Map) out.add((Map) o); + return out; + } + + // ── TO 저장 ──────────────────────────────────────────────────────────── + + // 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit. + // 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음. + public WriteResult writeTo( + Map firstMapping, + List> rows, + String saveMode, + String conflictKey, + String companyCode + ) { + if (rows == null || rows.isEmpty()) return new WriteResult(); + String type = str(firstMapping.get("to_connection_type")); + String tableName = str(firstMapping.get("to_table_name")); + + if ("restapi".equals(type)) { + return writeToRestApi(firstMapping, rows, companyCode); + } + if ("external".equals(type) || "external_db".equals(type)) { + throw new UnsupportedOperationException( + "external_db TO 쓰기는 현재 미지원 (ExternalDbConnectionService 가 SELECT-only)"); + } + return writeToInternal(tableName, rows, saveMode, conflictKey); + } + + /** Internal DB INSERT / UPSERT — 행 단위 PreparedStatement. */ + private WriteResult writeToInternal(String tableName, List> rows, + String saveMode, String conflictKey) { + WriteResult r = new WriteResult(); + if (tableName == null) throw new IllegalArgumentException("to_table_name 누락"); + safeIdent(tableName); + + try (Connection c = sqlSession.getConnection()) { + for (Map row : rows) { + try { + String sql = buildInsertSql(tableName, row, saveMode, conflictKey); + try (PreparedStatement ps = c.prepareStatement(sql)) { + int idx = 1; + for (Object v : row.values()) { + ps.setObject(idx++, v); + } + ps.executeUpdate(); + r.success++; + } + } catch (SQLException e) { + log.error("INSERT 실패 row={} — {}", row, e.getMessage()); + r.failed++; + } + } + } catch (SQLException e) { + throw new RuntimeException("internal write 실패: " + e.getMessage(), e); + } + return r; + } + + /** INSERT (또는 UPSERT) SQL 생성. row 의 key 순서로 컬럼/플레이스홀더 배열. */ + private String buildInsertSql(String tableName, Map row, + String saveMode, String conflictKey) { + List cols = new ArrayList<>(row.keySet()); + StringBuilder sql = new StringBuilder("INSERT INTO ").append(safeIdent(tableName)).append(" ("); + for (int i = 0; i < cols.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(cols.get(i))); + } + sql.append(") VALUES ("); + for (int i = 0; i < cols.size(); i++) { + if (i > 0) sql.append(", "); + sql.append("?"); + } + sql.append(")"); + + if ("UPSERT".equalsIgnoreCase(saveMode) && conflictKey != null && !conflictKey.isEmpty()) { + safeIdent(conflictKey); + List updateCols = new ArrayList<>(); + for (String col : cols) if (!col.equalsIgnoreCase(conflictKey)) updateCols.add(col); + sql.append(" ON CONFLICT (").append(conflictKey).append(") "); + if (updateCols.isEmpty()) { + sql.append("DO NOTHING"); + } else { + sql.append("DO UPDATE SET "); + for (int i = 0; i < updateCols.size(); i++) { + if (i > 0) sql.append(", "); + String c = safeIdent(updateCols.get(i)); + sql.append(c).append(" = EXCLUDED.").append(c); + } + if (cols.stream().anyMatch(c -> c.equalsIgnoreCase("updated_date"))) { + sql.append(", updated_date = NOW()"); + } + } + } + return sql.toString(); + } + + /** REST API TO — 행 단위로 testConnection 호출 (POST/PUT/DELETE). */ + private WriteResult writeToRestApi(Map firstMapping, + List> rows, String companyCode) { + WriteResult r = new WriteResult(); + String baseUrl = str(firstMapping.get("to_api_url")); + String endpoint = str(firstMapping.get("to_table_name")); + String method = strOr(firstMapping.get("to_api_method"), "POST"); + + for (Map row : rows) { + try { + Map testReq = new LinkedHashMap<>(); + testReq.put("base_url", baseUrl); + testReq.put("endpoint", endpoint); + testReq.put("method", method); + testReq.put("body", row); + testReq.put("auth_type", "none"); + testReq.put("timeout", 30000); + Map result = externalRest.testConnection(testReq, companyCode); + if (Boolean.TRUE.equals(result.get("success"))) r.success++; else r.failed++; + } catch (Exception e) { + log.error("REST API 전송 실패 row={} — {}", row, e.getMessage()); + r.failed++; + } + } + return r; + } + + // ── 유틸 ──────────────────────────────────────────────────────────────── + + private static List> materialize(ResultSet rs) throws SQLException { + ResultSetMetaData md = rs.getMetaData(); + int n = md.getColumnCount(); + List> rows = new ArrayList<>(); + while (rs.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= n; i++) row.put(md.getColumnLabel(i), rs.getObject(i)); + rows.add(row); + } + return rows; + } + + private static String safeIdent(String s) { + if (s == null || !SAFE_IDENT.matcher(s).matches()) { + throw new IllegalArgumentException("Unsafe identifier: " + s); + } + return s; + } + + private static String str(Object v) { return v == null ? null : v.toString(); } + private static String strOr(Object v, String fallback) { + String s = str(v); + return (s == null || s.isEmpty()) ? fallback : s; + } + + // ── 결과 클래스 ──────────────────────────────────────────────────────── + + public static final class ExecutionResult { + public int totalRecords = 0; + public int successRecords = 0; + public int failedRecords = 0; + public final List errorMessages = new ArrayList<>(); + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("total_records", totalRecords); + m.put("success_records", successRecords); + m.put("failed_records", failedRecords); + m.put("error_message", errorMessages.isEmpty() ? null : String.join("\n", errorMessages)); + return m; + } + } + + public static final class WriteResult { + public int success = 0; + public int failed = 0; + } +} diff --git a/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java b/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java new file mode 100644 index 00000000..a9129a3d --- /dev/null +++ b/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java @@ -0,0 +1,179 @@ +package com.erp.batch; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 매핑 변환 유틸리티 — vexplor_rps 의 batchSchedulerService L550~617 .map() 로직 1:1 이식. + * + * BatchExecutor 가 FROM 에서 읽은 row 들을 TO 형태로 변환할 때 사용. 의존성 없는 정적 메서드만. + * + * mapping_type 분기: + * - "direct" : row[from_column_name] → row[to_column_name] 그대로 복사 + * from_column_name 은 점 표기법 지원 (예: "response.access_token") + * - "fixed" : from_column_name 자체가 고정값. transformRow 는 fixed 매핑을 건너뛰고, + * 호출측이 partition 한 뒤 mappedRow 에 적용 (vexplor_rps L598-603 패턴). + * - "conditional" : ConditionalConfig.rules 의 when 과 sourceVal 문자열 동등 비교, 매칭 then 반환. + * 매칭 없으면 default. (단순 문자열 lookup. SpEL/JEXL 등 표현식 평가 안 함) + */ +@Slf4j +public final class MappingTransformer { + + private static final ObjectMapper OM = new ObjectMapper(); + + private MappingTransformer() {} + + /** 단일 row 를 매핑 룰에 따라 변환. mapping_type 별 분기 처리. */ + public static Map transformRow( + Map row, + List> nonFixedMappings, + List> fixedMappings, + String toConnectionType, + String companyCode + ) { + Map mappedRow = new LinkedHashMap<>(); + + for (Map mapping : nonFixedMappings) { + String mt = strOr(mapping.get("mapping_type"), "direct"); + String fromCol = str(mapping.get("from_column_name")); + String toCol = str(mapping.get("to_column_name")); + + if ("conditional".equals(mt)) { + ConditionalConfig cfg = parseConditionalConfig(mapping.get("mapping_config")); + String sourceVal = String.valueOf(getValueByPath(row, fromCol)); + if (sourceVal == null || "null".equals(sourceVal)) sourceVal = ""; + mappedRow.put(toCol, evaluateConditional(sourceVal, cfg)); + continue; + } + + // direct 또는 알 수 없는 type — 그대로 복사 + // DB→REST 의 to_api_body 템플릿 처리는 BatchExecutor 측에서 별도 처리 (vexplor_rps L582~595). + // 여기서는 단순 to_column_name 으로 값 흘림. + Object value = getValueByPath(row, fromCol); + mappedRow.put(toCol, value); + } + + // 고정값 매핑 적용 — from_column_name 자체가 저장값 (vexplor_rps L598-603) + if (fixedMappings != null) { + for (Map fm : fixedMappings) { + mappedRow.put(str(fm.get("to_column_name")), fm.get("from_column_name")); + } + } + + // 멀티테넌시: TO 가 DB 일 때 company_code 자동 주입 (vexplor_rps L605-614) + if (!"restapi".equals(toConnectionType) + && companyCode != null + && !mappedRow.containsKey("company_code")) { + mappedRow.put("company_code", companyCode); + } + + return mappedRow; + } + + /** 점 표기법 path 평가 — "response.access_token" 같은 중첩 키 지원 (vexplor_rps L540-548). */ + @SuppressWarnings("unchecked") + public static Object getValueByPath(Map obj, String path) { + if (obj == null || path == null || path.isEmpty()) return null; + if (!path.contains(".")) return obj.get(path); + Object cur = obj; + for (String part : path.split("\\.")) { + if (!(cur instanceof Map)) return null; + cur = ((Map) cur).get(part); + if (cur == null) return null; + } + return cur; + } + + /** ConditionalConfig 단일 평가 — when/then lookup + default. */ + public static Object evaluateConditional(String sourceVal, ConditionalConfig cfg) { + if (cfg == null || cfg.rules == null) return cfg != null ? cfg.defaultValue : null; + for (ConditionalRule r : cfg.rules) { + String when = r.when == null ? "" : r.when; + if (Objects.equals(when, sourceVal)) return r.then; + } + return cfg.defaultValue; + } + + /** + * mapping_config (JSONB) 의 원시 값 → ConditionalConfig. + * - BatchService.attachMappings 가 이미 파싱한 경우 → Map + * - 직접 SELECT 결과 → String(JSON) 가능 + * - null → 빈 cfg + */ + @SuppressWarnings("unchecked") + public static ConditionalConfig parseConditionalConfig(Object raw) { + if (raw == null) return ConditionalConfig.empty(); + Map map; + try { + if (raw instanceof Map) { + map = (Map) raw; + } else if (raw instanceof String) { + String s = ((String) raw).trim(); + if (s.isEmpty()) return ConditionalConfig.empty(); + map = OM.readValue(s, Map.class); + } else { + return ConditionalConfig.empty(); + } + } catch (Exception e) { + log.warn("[conditional 매핑] JSON 파싱 실패: {}", e.getMessage()); + return ConditionalConfig.empty(); + } + + ConditionalConfig cfg = new ConditionalConfig(); + Object rulesRaw = map.get("rules"); + if (rulesRaw instanceof List) { + for (Object r : (List) rulesRaw) { + if (r instanceof Map) { + Map rm = (Map) r; + cfg.rules.add(new ConditionalRule( + rm.get("when") == null ? "" : String.valueOf(rm.get("when")), + rm.get("then") == null ? null : String.valueOf(rm.get("then")) + )); + } + } + } + Object def = map.get("default"); + cfg.defaultValue = def == null ? null : String.valueOf(def); + return cfg; + } + + /** non-fixed / fixed 매핑 분리. vexplor_rps L265~271 partition 패턴. */ + public static Partition partitionFixed(List> mappings) { + Partition p = new Partition(); + if (mappings == null) return p; + for (Map m : mappings) { + String mt = strOr(m.get("mapping_type"), "direct"); + if ("fixed".equals(mt)) p.fixed.add(m); else p.nonFixed.add(m); + } + return p; + } + + public static final class Partition { + public final List> nonFixed = new ArrayList<>(); + public final List> fixed = new ArrayList<>(); + } + + public static final class ConditionalConfig { + public List rules = new ArrayList<>(); + public String defaultValue; + public static ConditionalConfig empty() { return new ConditionalConfig(); } + } + + public static final class ConditionalRule { + public final String when; + public final String then; + public ConditionalRule(String when, String then) { this.when = when; this.then = then; } + } + + private static String str(Object v) { return v == null ? null : v.toString(); } + private static String strOr(Object v, String fallback) { + String s = str(v); + return (s == null || s.isEmpty()) ? fallback : s; + } +} diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index aad1c53d..bbb32623 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -177,7 +177,13 @@ public class StartupSchemaMigrator { AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE) AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE) ) - """ + """, + + // V021 / RUN_087: BATCH_MAPPINGS 에 MAPPING_CONFIG JSONB 컬럼 추가. + // conditional 매핑(when/then/default) 규칙 저장용. + // direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만 + // 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + "ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB" ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/java/com/erp/service/BatchManagementService.java b/backend-spring/src/main/java/com/erp/service/BatchManagementService.java index 4fc7f9d8..2ee2786c 100644 --- a/backend-spring/src/main/java/com/erp/service/BatchManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/BatchManagementService.java @@ -1,5 +1,6 @@ package com.erp.service; +import com.erp.batch.BatchExecutor; import com.erp.common.BaseService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -24,8 +25,11 @@ public class BatchManagementService extends BaseService { private CommonService commonService; @Autowired private ObjectMapper objectMapper; + @Autowired + private BatchExecutor batchExecutor; private static final String NS = "batchManagement."; + private static final String EXEC_LOG_NS = "batchExecutionLog."; // ── Stats ───────────────────────────────────────────────────────────────── @@ -113,24 +117,102 @@ public class BatchManagementService extends BaseService { Map batchConfig = batchService.getBatchInfo(params); if (batchConfig == null) throw new RuntimeException("배치 설정을 찾을 수 없습니다."); - long startTime = System.currentTimeMillis(); + long startMs = System.currentTimeMillis(); String batchName = str(batchConfig.get("batch_name")); + String companyCode = str(batchConfig.get("company_code")); log.info("배치 수동 실행: id={}, name={}", id, batchName); - long duration = System.currentTimeMillis() - startTime; + // 1. 실행 로그 INSERT — RUNNING 상태로 먼저 박아두면 도중 비정상 종료해도 추적 가능 + Map logRow = new LinkedHashMap<>(); + logRow.put("batch_config_id", id); + logRow.put("company_code", companyCode); + logRow.put("execution_status", "RUNNING"); + logRow.put("server_name", safeHostName()); + logRow.put("process_id", String.valueOf(ProcessHandle.current().pid())); + try { + sqlSession.insert(EXEC_LOG_NS + "insertBatchExecutionLog", logRow); + } catch (Exception e) { + log.warn("실행 로그 INSERT 실패 (실행은 계속 진행): {}", e.getMessage()); + } + Object logId = logRow.get("id"); + + // 2. 실제 ETL 실행 — 예외는 로그에 기록 후 다시 throw (controller 의 에러 응답 위해) + BatchExecutor.ExecutionResult execResult = null; + String status = "SUCCESS"; + String errorMessage = null; + try { + execResult = batchExecutor.execute(batchConfig); + if (execResult.failedRecords > 0) { + status = execResult.successRecords > 0 ? "PARTIAL" : "FAILED"; + } + if (!execResult.errorMessages.isEmpty()) { + errorMessage = String.join("\n", execResult.errorMessages); + } + } catch (Exception e) { + status = "FAILED"; + errorMessage = e.getMessage(); + log.error("배치 실행 중 예외: id={} — {}", id, e.getMessage(), e); + } + + long duration = System.currentTimeMillis() - startMs; + + // 3. 실행 로그 UPDATE — 최종 상태/카운트/duration 마무리 + // 주의: batch_execution_logs 의 duration_ms / *_records 컬럼은 운영 DB 에서 VARCHAR + // (V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 로 자동 변환하지 못할 수 있어 + // 명시적으로 String 으로 보낸다. mapper 의 COALESCE default 도 '0' (문자열) 이라 일관됨. + if (logId != null) { + Map updateLog = new LinkedHashMap<>(); + updateLog.put("id", logId); + updateLog.put("execution_status", status); + updateLog.put("end_time", new java.sql.Timestamp(System.currentTimeMillis())); + updateLog.put("duration_ms", String.valueOf(duration)); + updateLog.put("total_records", String.valueOf(execResult != null ? execResult.totalRecords : 0)); + updateLog.put("success_records", String.valueOf(execResult != null ? execResult.successRecords : 0)); + updateLog.put("failed_records", String.valueOf(execResult != null ? execResult.failedRecords : 0)); + if (errorMessage != null) updateLog.put("error_message", errorMessage); + try { + sqlSession.update(EXEC_LOG_NS + "updateBatchExecutionLog", updateLog); + } catch (Exception e) { + log.warn("실행 로그 UPDATE 실패: {}", e.getMessage()); + } + } Map result = new LinkedHashMap<>(); result.put("batch_name", batchName); - result.put("total_records", 0); - result.put("success_records", 0); - result.put("failed_records", 0); + result.put("execution_status", status); + result.put("total_records", execResult != null ? execResult.totalRecords : 0); + result.put("success_records", execResult != null ? execResult.successRecords : 0); + result.put("failed_records", execResult != null ? execResult.failedRecords : 0); result.put("execution_time", duration); + if (errorMessage != null) result.put("error_message", errorMessage); return result; } + /** 실행 로그 server_name 컬럼용 — hostname resolve 실패 시 "unknown". */ + private static String safeHostName() { + try { + return java.net.InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return "unknown"; + } + } + // ── REST API Preview / Save ─────────────────────────────────────────────── public Map previewRestApiData(Map body) { + // 프론트(batchManagement.ts)는 camelCase 로 키를 보내고 백엔드는 snake_case 로 읽음. + // 기존 convertCamelToSnake() 는 batch_configs 전용 remap 이라 여기엔 효과 없음. + // → previewRestApiData 전용으로 사용하는 키만 직접 remap. + remap(body, "apiUrl", "api_url"); + remap(body, "apiKey", "api_key"); + remap(body, "requestBody", "request_body"); + remap(body, "dataArrayPath", "data_array_path"); + remap(body, "paramType", "param_type"); + remap(body, "paramName", "param_name"); + remap(body, "paramValue", "param_value"); + remap(body, "paramSource", "param_source"); + remap(body, "authServiceName", "auth_service_name"); + String apiUrl = str(body.get("api_url")); String endpoint = str(body.get("endpoint")); String method = body.get("method") != null ? str(body.get("method")) : "GET"; diff --git a/backend-spring/src/main/java/com/erp/service/BatchService.java b/backend-spring/src/main/java/com/erp/service/BatchService.java index 83e5cd71..eed8679d 100644 --- a/backend-spring/src/main/java/com/erp/service/BatchService.java +++ b/backend-spring/src/main/java/com/erp/service/BatchService.java @@ -1,6 +1,7 @@ package com.erp.service; import com.erp.common.BaseService; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -15,6 +16,9 @@ public class BatchService extends BaseService { @Autowired private CommonService commonService; + @Autowired + private ObjectMapper objectMapper; + private static final String NS = "batch."; private static final String EXT_NS = "externalDbConnection."; @@ -29,7 +33,11 @@ public class BatchService extends BaseService { public Map getBatchInfo(Map params) { commonService.applyCompanyCodeFilter(params); - return sqlSession.selectOne(NS + "getBatchInfo", params); + Map batch = sqlSession.selectOne(NS + "getBatchInfo", params); + if (batch != null) { + attachMappings(batch); + } + return batch; } @Transactional @@ -37,9 +45,18 @@ public class BatchService extends BaseService { sqlSession.insert(NS + "insertBatch", params); Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null; if (id != null) { + // batch_configs INSERT 직후 mappings 동기화 (params 에 mappings 키가 있을 때만) + if (params.containsKey("mappings")) { + syncMappings(id, + toStr(params.get("company_code")), + toMappingList(params.get("mappings")), + toStr(params.get("created_by"))); + } Map infoParams = new HashMap<>(); infoParams.put("id", id); - return sqlSession.selectOne(NS + "getBatchInfo", infoParams); + Map result = sqlSession.selectOne(NS + "getBatchInfo", infoParams); + if (result != null) attachMappings(result); + return result; } return params; } @@ -48,9 +65,89 @@ public class BatchService extends BaseService { public Map updateBatch(Map params) { commonService.applyCompanyCodeFilter(params); sqlSession.update(NS + "updateBatch", params); + Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null; + // replace-all: body 에 mappings 키가 들어왔으면 (빈 배열 포함) 매핑 전체 교체 + if (id != null && params.containsKey("mappings")) { + syncMappings(id, + toStr(params.get("company_code")), + toMappingList(params.get("mappings")), + toStr(params.get("updated_by") != null ? params.get("updated_by") : params.get("created_by"))); + } Map infoParams = new HashMap<>(); infoParams.put("id", params.get("id")); - return sqlSession.selectOne(NS + "getBatchInfo", infoParams); + Map result = sqlSession.selectOne(NS + "getBatchInfo", infoParams); + if (result != null) attachMappings(result); + return result; + } + + // ── batch_mappings replace-all 동기화 ───────────────────────────────────── + + /** batch_config_id 의 매핑을 전부 지우고 mappings 리스트로 다시 채운다. */ + private void syncMappings(Long batchConfigId, String companyCode, + List> mappings, String userId) { + Map delParams = new HashMap<>(); + delParams.put("batch_config_id", batchConfigId); + sqlSession.delete(NS + "deleteBatchMappingsByConfigId", delParams); + + if (mappings == null || mappings.isEmpty()) return; + + for (int i = 0; i < mappings.size(); i++) { + Map row = new HashMap<>(mappings.get(i)); + row.put("batch_config_id", batchConfigId); + if (row.get("company_code") == null) row.put("company_code", companyCode); + if (row.get("created_by") == null) row.put("created_by", userId); + if (row.get("mapping_order") == null) row.put("mapping_order", i + 1); + stringifyJsonField(row, "mapping_config"); + sqlSession.insert(NS + "insertBatchMapping", row); + } + } + + /** getBatchInfo 결과에 batch_mappings 리스트 attach. */ + private void attachMappings(Map batch) { + Object idObj = batch.get("id"); + if (idObj == null) return; + Map params = new HashMap<>(); + params.put("batch_config_id", idObj); + List> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params); + if (mappings != null) { + for (Map row : mappings) parseJsonField(row, "mapping_config"); + } + batch.put("batch_mappings", mappings != null ? mappings : new ArrayList<>()); + } + + /** JSONB → 객체. SELECT 결과의 TEXT cast 값을 파싱해 Map/List 로 되돌린다. */ + private void parseJsonField(Map row, String key) { + Object val = row.get(key); + if (val instanceof String && !((String) val).isEmpty()) { + try { + row.put(key, objectMapper.readValue((String) val, Object.class)); + } catch (Exception e) { + log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage()); + } + } + } + + /** 객체 → JSON 문자열. INSERT 전 ::jsonb 캐스팅을 위해 직렬화한다. null 은 그대로 둠. */ + private void stringifyJsonField(Map params, String key) { + Object val = params.get(key); + if (val == null || val instanceof String) return; + try { + params.put(key, objectMapper.writeValueAsString(val)); + } catch (Exception e) { + log.warn("Failed to stringify field '{}': {}", key, e.getMessage()); + params.put(key, null); + } + } + + @SuppressWarnings("unchecked") + private List> toMappingList(Object raw) { + if (raw == null) return new ArrayList<>(); + if (raw instanceof List) return (List>) raw; + return new ArrayList<>(); + } + + private String toStr(Object v) { + return v != null ? v.toString() : null; } @Transactional diff --git a/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql new file mode 100644 index 00000000..2ee3c7da --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql @@ -0,0 +1,7 @@ +-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가 +-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다. +-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도 +-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다. + +ALTER TABLE BATCH_MAPPINGS + ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB; diff --git a/backend-spring/src/main/resources/mapper/batch.xml b/backend-spring/src/main/resources/mapper/batch.xml index dc599c6a..5793e2f4 100644 --- a/backend-spring/src/main/resources/mapper/batch.xml +++ b/backend-spring/src/main/resources/mapper/batch.xml @@ -102,6 +102,117 @@ + + + + + + INSERT INTO BATCH_MAPPINGS ( + BATCH_CONFIG_ID + , COMPANY_CODE + , FROM_CONNECTION_TYPE + , FROM_CONNECTION_ID + , FROM_TABLE_NAME + , FROM_COLUMN_NAME + , FROM_COLUMN_TYPE + , FROM_API_URL + , FROM_API_KEY + , FROM_API_METHOD + , FROM_API_PARAM_TYPE + , FROM_API_PARAM_NAME + , FROM_API_PARAM_VALUE + , FROM_API_PARAM_SOURCE + , FROM_API_BODY + , TO_CONNECTION_TYPE + , TO_CONNECTION_ID + , TO_TABLE_NAME + , TO_COLUMN_NAME + , TO_COLUMN_TYPE + , TO_API_URL + , TO_API_KEY + , TO_API_METHOD + , TO_API_BODY + , MAPPING_ORDER + , MAPPING_TYPE + , MAPPING_CONFIG + , CREATED_BY + , CREATED_DATE + ) VALUES ( + #{batch_config_id}::varchar + , #{company_code} + , #{from_connection_type} + , #{from_connection_id} + , #{from_table_name} + , #{from_column_name} + , #{from_column_type} + , #{from_api_url} + , #{from_api_key} + , #{from_api_method} + , #{from_api_param_type} + , #{from_api_param_name} + , #{from_api_param_value} + , #{from_api_param_source} + , #{from_api_body} + , #{to_connection_type} + , #{to_connection_id} + , #{to_table_name} + , #{to_column_name} + , #{to_column_type} + , #{to_api_url} + , #{to_api_key} + , #{to_api_method} + , #{to_api_body} + , #{mapping_order} + , + #{mapping_type} + 'direct' + + , #{mapping_config,jdbcType=OTHER}::jsonb + , #{created_by} + , NOW() + ) + + + + + DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar + + SELECT * FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar ORDER BY start_time DESC LIMIT 1 @@ -106,7 +106,7 @@ WHERE 1=1 - AND batch_config_id = #{batch_config_id} + AND batch_config_id = #{batch_config_id}::varchar AND start_time >= #{start_date}::timestamp @@ -123,7 +123,7 @@ total_records, success_records, failed_records, error_message, error_details, server_name, process_id ) VALUES ( - #{batch_config_id}, #{company_code}, #{execution_status}, + #{batch_config_id}::varchar, #{company_code}, #{execution_status}, COALESCE(#{start_time}::timestamp, NOW()), #{end_time}::timestamp, #{duration_ms}, diff --git a/backend-spring/src/main/resources/mapper/batchManagement.xml b/backend-spring/src/main/resources/mapper/batchManagement.xml index 414a215a..304987ec 100644 --- a/backend-spring/src/main/resources/mapper/batchManagement.xml +++ b/backend-spring/src/main/resources/mapper/batchManagement.xml @@ -15,14 +15,14 @@ execution_today AS ( SELECT COUNT(*) AS today_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed - FROM batch_execution_log + FROM batch_execution_logs WHERE DATE(start_time) = CURRENT_DATE ), execution_yesterday AS ( SELECT COUNT(*) AS yesterday_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed - FROM batch_execution_log + FROM batch_execution_logs WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day' ) @@ -77,9 +77,9 @@ SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count - FROM batch_execution_log + FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar AND start_time >= NOW() - INTERVAL '24 hours' GROUP BY DATE_TRUNC('hour', start_time) @@ -100,9 +100,9 @@ failed_records, error_message - FROM batch_execution_log + FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar ORDER BY start_time DESC LIMIT 20 diff --git a/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml b/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml index 9b4434e8..dca8cf4f 100644 --- a/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml +++ b/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml @@ -69,7 +69,7 @@ SELECT FROM EXTERNAL_REST_API_CONNECTIONS E - WHERE E.ID = #{id} + WHERE E.ID = #{id}::varchar @@ -133,14 +133,14 @@ SAVE_TO_HISTORY = #{save_to_history}, UPDATED_BY = #{updated_by}, - WHERE ID = #{id} + WHERE ID = #{id}::varchar DELETE FROM EXTERNAL_REST_API_CONNECTIONS - WHERE ID = #{id} + WHERE ID = #{id}::varchar @@ -151,7 +151,7 @@ LAST_TEST_DATE = NOW() , LAST_TEST_RESULT = #{last_test_result} , LAST_TEST_MESSAGE = #{last_test_message} - WHERE ID = #{id} + WHERE ID = #{id}::varchar diff --git a/backend-spring/src/test/java/com/erp/batch/MappingTransformerTest.java b/backend-spring/src/test/java/com/erp/batch/MappingTransformerTest.java new file mode 100644 index 00000000..4b077955 --- /dev/null +++ b/backend-spring/src/test/java/com/erp/batch/MappingTransformerTest.java @@ -0,0 +1,224 @@ +package com.erp.batch; + +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 3 검증 — vexplor_rps L550~617 알고리즘 1:1 이식 결과가 정상 동작하는지. + * + * 외부 의존 없는 순수 함수만 검증. + */ +class MappingTransformerTest { + + // ── evaluateConditional ─────────────────────────────────────────────── + + @Test + void evaluateConditional_단순_매칭() { + MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig(); + cfg.rules.add(new MappingTransformer.ConditionalRule("1", "Y")); + cfg.rules.add(new MappingTransformer.ConditionalRule("0", "N")); + cfg.defaultValue = "?"; + + assertEquals("Y", MappingTransformer.evaluateConditional("1", cfg)); + assertEquals("N", MappingTransformer.evaluateConditional("0", cfg)); + assertEquals("?", MappingTransformer.evaluateConditional("9", cfg)); // 매칭 없음 → default + } + + @Test + void evaluateConditional_null_cfg_안전() { + assertNull(MappingTransformer.evaluateConditional("anything", null)); + } + + @Test + void evaluateConditional_빈_rules_default만() { + MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig(); + cfg.defaultValue = "fallback"; + assertEquals("fallback", MappingTransformer.evaluateConditional("anything", cfg)); + } + + // ── parseConditionalConfig (JSONB normalize) ────────────────────────── + + @Test + void parseConditionalConfig_Map_입력() { + Map raw = new LinkedHashMap<>(); + raw.put("rules", List.of(Map.of("when", "1", "then", "Y"))); + raw.put("default", "?"); + + MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(raw); + assertEquals(1, cfg.rules.size()); + assertEquals("1", cfg.rules.get(0).when); + assertEquals("Y", cfg.rules.get(0).then); + assertEquals("?", cfg.defaultValue); + } + + @Test + void parseConditionalConfig_String_JSON_입력() { + String json = "{\"rules\":[{\"when\":\"J01\",\"then\":\"active\"}],\"default\":\"\"}"; + MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(json); + assertEquals(1, cfg.rules.size()); + assertEquals("J01", cfg.rules.get(0).when); + assertEquals("active", cfg.rules.get(0).then); + assertEquals("", cfg.defaultValue); + } + + @Test + void parseConditionalConfig_null_빈cfg() { + MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(null); + assertNotNull(cfg); + assertTrue(cfg.rules.isEmpty()); + } + + @Test + void parseConditionalConfig_손상된_JSON_빈cfg() { + MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig("{not json"); + assertNotNull(cfg); + assertTrue(cfg.rules.isEmpty()); + } + + // ── getValueByPath (점 표기법) ───────────────────────────────────────── + + @Test + void getValueByPath_단순_키() { + Map obj = Map.of("name", "alice"); + assertEquals("alice", MappingTransformer.getValueByPath(obj, "name")); + } + + @Test + void getValueByPath_중첩_경로() { + Map obj = Map.of("response", Map.of("access_token", "xyz")); + assertEquals("xyz", MappingTransformer.getValueByPath(obj, "response.access_token")); + } + + @Test + void getValueByPath_없는_경로_null() { + Map obj = Map.of("name", "alice"); + assertNull(MappingTransformer.getValueByPath(obj, "missing.path")); + assertNull(MappingTransformer.getValueByPath(obj, "name.deeper")); + } + + @Test + void getValueByPath_null_obj_안전() { + assertNull(MappingTransformer.getValueByPath(null, "anything")); + } + + // ── partitionFixed ──────────────────────────────────────────────────── + + @Test + void partitionFixed_분리() { + List> mappings = List.of( + Map.of("mapping_type", "direct", "to_column_name", "a"), + Map.of("mapping_type", "fixed", "to_column_name", "b"), + Map.of("mapping_type", "conditional", "to_column_name", "c") + ); + MappingTransformer.Partition p = MappingTransformer.partitionFixed(mappings); + assertEquals(2, p.nonFixed.size()); + assertEquals(1, p.fixed.size()); + assertEquals("b", p.fixed.get(0).get("to_column_name")); + } + + // ── transformRow (통합) ─────────────────────────────────────────────── + + @Test + void transformRow_direct_매핑() { + Map row = Map.of("user_id", "alice", "email", "a@x.com"); + List> nonFixed = List.of( + Map.of("mapping_type", "direct", + "from_column_name", "user_id", + "to_column_name", "USER_ID"), + Map.of("mapping_type", "direct", + "from_column_name", "email", + "to_column_name", "EMAIL_ADDR") + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, List.of(), "internal", "COMPANY_1"); + assertEquals("alice", mapped.get("USER_ID")); + assertEquals("a@x.com", mapped.get("EMAIL_ADDR")); + assertEquals("COMPANY_1", mapped.get("company_code")); // 자동 주입 + } + + @Test + void transformRow_conditional_매핑_1을_Y로() { + Map row = Map.of("active_flag", "1"); + List> nonFixed = List.of( + new HashMap<>(Map.of( + "mapping_type", "conditional", + "from_column_name", "active_flag", + "to_column_name", "IS_ACTIVE", + "mapping_config", Map.of( + "rules", List.of( + Map.of("when", "1", "then", "Y"), + Map.of("when", "0", "then", "N")), + "default", "?"))) + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, List.of(), "internal", null); + assertEquals("Y", mapped.get("IS_ACTIVE")); + } + + @Test + void transformRow_conditional_매핑_default_폴백() { + Map row = Map.of("active_flag", "9"); // 어떤 룰에도 매칭 안 됨 + List> nonFixed = List.of( + new HashMap<>(Map.of( + "mapping_type", "conditional", + "from_column_name", "active_flag", + "to_column_name", "IS_ACTIVE", + "mapping_config", Map.of( + "rules", List.of(Map.of("when", "1", "then", "Y")), + "default", "?"))) + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, List.of(), "internal", null); + assertEquals("?", mapped.get("IS_ACTIVE")); + } + + @Test + void transformRow_fixed_매핑_적용() { + Map row = Map.of("user_id", "alice"); + List> nonFixed = List.of( + Map.of("mapping_type", "direct", + "from_column_name", "user_id", + "to_column_name", "USER_ID") + ); + List> fixed = List.of( + Map.of("mapping_type", "fixed", + "from_column_name", "BATCH_001", + "to_column_name", "SOURCE_BATCH") + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, fixed, "internal", null); + assertEquals("alice", mapped.get("USER_ID")); + assertEquals("BATCH_001", mapped.get("SOURCE_BATCH")); + } + + @Test + void transformRow_점_표기법_API_응답() { + Map row = Map.of( + "user", Map.of("profile", Map.of("name", "박창현")) + ); + List> nonFixed = List.of( + Map.of("mapping_type", "direct", + "from_column_name", "user.profile.name", + "to_column_name", "USER_NAME") + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, List.of(), "internal", null); + assertEquals("박창현", mapped.get("USER_NAME")); + } + + @Test + void transformRow_to_가_restapi_면_company_code_자동주입_안함() { + Map row = Map.of("user_id", "alice"); + List> nonFixed = List.of( + Map.of("mapping_type", "direct", + "from_column_name", "user_id", + "to_column_name", "USER_ID") + ); + Map mapped = MappingTransformer.transformRow( + row, nonFixed, List.of(), "restapi", "COMPANY_1"); + assertFalse(mapped.containsKey("company_code")); + } +} diff --git a/db/migrations/RUN_087_MIGRATION.md b/db/migrations/RUN_087_MIGRATION.md new file mode 100644 index 00000000..b5433055 --- /dev/null +++ b/db/migrations/RUN_087_MIGRATION.md @@ -0,0 +1,125 @@ +# 087 마이그레이션 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 + +작성일: 2026-05-13 +작성자: hjjeong +관련: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md` (Phase 1) + +## 목적 + +vexplor_rps 의 conditional 매핑(파이프라인) 기능을 INVYONE 으로 이식하기 위한 첫 단계. +`BATCH_MAPPINGS` 행마다 매핑 규칙(when/then/default) 을 JSONB 로 저장할 컬럼 추가. + +- `mapping_type='direct'` / `'fixed'` → `MAPPING_CONFIG` 는 NULL +- `mapping_type='conditional'` → `MAPPING_CONFIG` 에 `{"rules":[{"when":"1","then":"Y"}],"default":"?"}` 형태 저장 + +Phase 2 (frontend ConditionalEditor + API 확장) 와 Phase 3 (Backend MappingTransformer) 가 +이 컬럼을 읽고 쓰는 전제로 동작한다. + +## 스키마 + +### BATCH_MAPPINGS ALTER + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `MAPPING_CONFIG` | JSONB | NULL 허용 | conditional 평가 규칙. direct/fixed 면 NULL | + +저장 포맷(`mapping_type='conditional'`): + +```json +{ + "rules": [ + { "when": "1", "then": "Y" }, + { "when": "0", "then": "N" } + ], + "default": "?" +} +``` + +## SQL + +```sql +-- ================================================================= +-- 087: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 (idempotent) +-- ================================================================= + +ALTER TABLE BATCH_MAPPINGS + ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB; +``` + +부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 ALTER 를 +`IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음. +별도 환경(콜드 백업 복원 등)에서 수동 실행이 필요할 때 위 SQL 한 줄을 그대로 사용. + +## 사전 점검 + +```sql +-- A. 컬럼 사전 상태 +SELECT column_name, data_type FROM information_schema.columns +WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config'; +-- 빈 결과여야 정상. 이미 있으면 ALTER 의 IF NOT EXISTS 가 안전. + +-- B. 기존 데이터 행수 (마이그레이션 영향 범위 확인) +SELECT COUNT(*) FROM BATCH_MAPPINGS; +-- 컬럼만 추가하므로 기존 행은 MAPPING_CONFIG = NULL 로 유지됨. +``` + +## 사후 검증 + +```sql +-- C. 컬럼 추가 확인 +SELECT column_name, data_type FROM information_schema.columns +WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config'; +-- 기대: data_type = 'jsonb' + +-- D. JSONB 동작 확인 (테스트) +BEGIN; +UPDATE BATCH_MAPPINGS + SET MAPPING_CONFIG = '{"rules":[{"when":"1","then":"Y"}],"default":"?"}'::jsonb + WHERE ID = (SELECT ID FROM BATCH_MAPPINGS LIMIT 1); +SELECT MAPPING_CONFIG->'rules'->0->>'when' AS sample + FROM BATCH_MAPPINGS + WHERE MAPPING_CONFIG IS NOT NULL + LIMIT 1; +-- 기대: sample = '1' +ROLLBACK; +``` + +## 실행 + +```bash +# 1) 메타 DB +psql -h -U postgres -d invyone -f RUN_087.sql + +# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능) +for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do + echo "=== $db ===" + psql -h -U postgres -d "$db" -f RUN_087.sql +done +``` + +`RUN_087.sql` 은 위 "SQL" 섹션의 ALTER 한 줄을 그대로 담은 파일입니다. + +## 롤백 + +```sql +-- MAPPING_CONFIG 컬럼 제거 (저장된 conditional 규칙은 함께 삭제됨) +ALTER TABLE BATCH_MAPPINGS DROP COLUMN IF EXISTS MAPPING_CONFIG; +``` + +## 적용 환경 체크리스트 + +- [ ] 로컬 docker `naengangi-pg` (메타 + 활성 테넌트 전부) +- [ ] wace 개발서버 PostgreSQL +- [ ] 운영 메타 DB (`invyone`) +- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동) + +## 관련 코드 + +- Flyway: `backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql` +- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목) +- Mapper: `backend-spring/src/main/resources/mapper/batch.xml` + - `getBatchMappingsByConfigId` 의 SELECT 절: `MAPPING_CONFIG::TEXT AS MAPPING_CONFIG` + - `insertBatchMapping` 의 VALUES 절: `#{mapping_config,jdbcType=OTHER}::jsonb` +- Service: `backend-spring/src/main/java/com/erp/service/BatchService.java` + - `syncMappings()` 가 `stringifyJsonField(row, "mapping_config")` 로 직렬화 후 INSERT + - `attachMappings()` 가 `parseJsonField(row, "mapping_config")` 로 SELECT 결과 역직렬화 diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index e8b90461..48ae8a79 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -213,7 +213,7 @@ export default function BatchCreatePage() { toast.success("매핑을 삭제했어요"); }; - const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" }); const saveBatchConfig = async () => { if (!batchName.trim()) { toast.error("배치 이름을 입력해주세요"); return; } diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx index d7182fdf..d521da90 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx @@ -25,8 +25,14 @@ import { ConnectionInfo, type NodeFlowInfo, type BatchExecutionType, + type ConditionalConfig, } from "@/lib/api/batch"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; +import { + ConditionalEditor, + emptyConditionalConfig, + normalizeConditionalConfig, +} from "@/components/admin/batch/ConditionalEditor"; const SCHEDULE_PRESETS = [ { label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" }, @@ -165,12 +171,17 @@ export default function BatchEditPage() { const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static"); // 매핑 리스트 (새로운 UI용) + // sourceType: + // - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct) + // - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed) + // - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional) interface MappingItem { id: string; dbColumn: string; - sourceType: "api" | "fixed"; + sourceType: "api" | "fixed" | "conditional"; apiField: string; fixedValue: string; + conditionalConfig?: ConditionalConfig; } const [mappingList, setMappingList] = useState([]); @@ -377,13 +388,27 @@ export default function BatchEditPage() { }); // 기존 매핑을 mappingList로 변환 - const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({ - id: `mapping-${index}-${Date.now()}`, - dbColumn: mapping.to_column_name || "", - sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const, - apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "", - fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "", - })); + // mapping_type 분기: + // "fixed" → from_column_name 자체가 고정값 → fixedValue + // "conditional" → from_column_name 이 평가 필드명 → apiField + conditionalConfig + // 그 외(direct) → from_column_name 이 API 필드명 → apiField + const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => { + const mt = (mapping as any).mapping_type || "direct"; + const sourceType: MappingItem["sourceType"] = + mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api"; + const conditionalConfig = + sourceType === "conditional" + ? normalizeConditionalConfig((mapping as any).mapping_config) + : undefined; + return { + id: `mapping-${index}-${Date.now()}`, + dbColumn: mapping.to_column_name || "", + sourceType, + apiField: sourceType === "fixed" ? "" : mapping.from_column_name || "", + fixedValue: sourceType === "fixed" ? mapping.from_column_name || "" : "", + conditionalConfig, + }; + }); setMappingList(convertedMappingList); console.log("🔄 변환된 mappingList:", convertedMappingList); } @@ -651,7 +676,7 @@ export default function BatchEditPage() { nodeFlowContext: parsedContext, }); toast.success("배치 설정이 저장되었습니다!"); - openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" }); } catch (error) { console.error("배치 저장 실패:", error); toast.error("배치 저장에 실패했습니다."); @@ -679,26 +704,46 @@ export default function BatchEditPage() { const first = batchConfig.batch_mappings[0] as any; finalMappings = mappingList .filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만 - .map((m, index) => ({ - // FROM: REST API (기존 설정 복사) - from_connection_type: "restapi" as any, - from_connection_id: first.from_connection_id, - from_table_name: first.from_table_name, - from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField, - from_column_type: m.sourceType === "fixed" ? "text" : "text", - from_api_url: mappings[0]?.from_api_url || first.from_api_url, - from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key, - from_api_method: mappings[0]?.from_api_method || first.from_api_method, - from_api_body: mappings[0]?.from_api_body || first.from_api_body, - // TO: DB (기존 설정 복사) - to_connection_type: first.to_connection_type as any, - to_connection_id: first.to_connection_id, - to_table_name: toTable || first.to_table_name, - to_column_name: m.dbColumn, - to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text", - mapping_type: m.sourceType === "fixed" ? "fixed" : "direct", - mapping_order: index + 1, - })) as BatchMapping[]; + .map((m, index) => { + // from_column_name 결정: + // fixed → fixedValue 자체가 저장됨 + // conditional → apiField (평가할 API 필드) + // direct(api) → apiField + const fromColumnName = + m.sourceType === "fixed" ? m.fixedValue : m.apiField; + const mappingType: "direct" | "fixed" | "conditional" = + m.sourceType === "fixed" + ? "fixed" + : m.sourceType === "conditional" + ? "conditional" + : "direct"; + return { + // FROM: REST API (기존 설정 복사) + from_connection_type: "restapi" as any, + from_connection_id: first.from_connection_id, + from_table_name: first.from_table_name, + from_column_name: fromColumnName, + from_column_type: "text", + from_api_url: mappings[0]?.from_api_url || first.from_api_url, + from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key, + from_api_method: mappings[0]?.from_api_method || first.from_api_method, + from_api_body: mappings[0]?.from_api_body || first.from_api_body, + // TO: DB (기존 설정 복사) + to_connection_type: first.to_connection_type as any, + to_connection_id: first.to_connection_id, + to_table_name: toTable || first.to_table_name, + to_column_name: m.dbColumn, + to_column_type: + toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text", + mapping_type: mappingType, + // conditional 일 때만 룰 객체를 함께 전송. 백엔드가 JSONB 로 저장. + mapping_config: + m.sourceType === "conditional" && m.conditionalConfig + ? m.conditionalConfig + : null, + mapping_order: index + 1, + }; + }) as BatchMapping[]; } await BatchAPI.updateBatchConfig(batchId, { @@ -714,7 +759,7 @@ export default function BatchEditPage() { }); toast.success("배치 설정이 성공적으로 수정되었습니다."); - openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" }); } catch (error) { console.error("배치 설정 수정 실패:", error); @@ -724,7 +769,7 @@ export default function BatchEditPage() { } }; - const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" }); + const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" }); const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId); if (loading && !batchConfig) { @@ -739,7 +784,7 @@ export default function BatchEditPage() { } return ( -
+
{/* 헤더 */}
- ))} -
+ {/* 배치 타입 + 기본 정보 — 한 행으로 통합 (xl+ 한 줄, 그 미만은 stack) */} +
+ {/* 모드 토글 2개 */} +
+ {batchTypeOptions.map((option) => ( + + ))} +
- {/* 기본 정보 */} -
-
- - 기본 정보 + {/* 배치명 */} +
+ + setBatchName(e.target.value)} placeholder="배치명" className="h-9 text-sm" />
-
-
- - setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" /> -
-
- - setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" /> -
+ + {/* 실행 스케줄 */} +
+ + setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
-
+ + {/* 설명 (textarea 한 줄 높이 — 다른 입력과 정렬) */} +
-