style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소 #12

Merged
hjjeong merged 22 commits from hjjeong into main 2026-05-13 08:23:51 +00:00
21 changed files with 2310 additions and 361 deletions
@@ -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<String, Object> 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<Map<String, Object>> mappings = (List<Map<String, Object>>) 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<String, List<Map<String, Object>>> tableGroups = new LinkedHashMap<>();
for (Map<String, Object> 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<String, List<Map<String, Object>>> e : tableGroups.entrySet()) {
String key = e.getKey();
List<Map<String, Object>> groupMappings = e.getValue();
Map<String, Object> first = groupMappings.get(0);
try {
log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size());
// FROM 읽기
List<Map<String, Object>> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode);
r.totalRecords += fromData.size();
// Transform
String toConnType = str(first.get("to_connection_type"));
List<Map<String, Object>> mappedRows = new ArrayList<>(fromData.size());
for (Map<String, Object> 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<Map<String, Object>> readFrom(
Map<String, Object> firstMapping,
List<Map<String, Object>> groupMappings,
String dataArrayPath,
String companyCode
) {
String type = str(firstMapping.get("from_connection_type"));
String tableName = str(firstMapping.get("from_table_name"));
List<String> columns = new ArrayList<>();
for (Map<String, Object> 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<Map<String, Object>> readFromInternal(String tableName, List<String> 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<Map<String, Object>> readFromExternalDb(Map<String, Object> firstMapping, List<String> 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<String, Object> result = externalDb.executeQuery(connId, sql.toString());
Object data = result.get("data");
return data instanceof List ? (List<Map<String, Object>>) data : List.of();
}
/** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> readFromRestApi(
Map<String, Object> 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<String, Object> params = new HashMap<>();
if (companyCode != null) params.put("company_code", companyCode);
Map<String, Object> 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<String, Object>) data).get("rows");
if (!(rows instanceof List)) return List.of();
List<Object> raw = (List<Object>) rows;
List<Map<String, Object>> out = new ArrayList<>(raw.size());
for (Object o : raw) if (o instanceof Map) out.add((Map<String, Object>) o);
return out;
}
// ── TO 저장 ────────────────────────────────────────────────────────────
// 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit.
// 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음.
public WriteResult writeTo(
Map<String, Object> firstMapping,
List<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> row,
String saveMode, String conflictKey) {
List<String> 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<String> 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<String, Object> firstMapping,
List<Map<String, Object>> 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<String, Object> row : rows) {
try {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> materialize(ResultSet rs) throws SQLException {
ResultSetMetaData md = rs.getMetaData();
int n = md.getColumnCount();
List<Map<String, Object>> rows = new ArrayList<>();
while (rs.next()) {
Map<String, Object> 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<String> errorMessages = new ArrayList<>();
public Map<String, Object> toMap() {
Map<String, Object> 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;
}
}
@@ -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<String, Object> transformRow(
Map<String, Object> row,
List<Map<String, Object>> nonFixedMappings,
List<Map<String, Object>> fixedMappings,
String toConnectionType,
String companyCode
) {
Map<String, Object> mappedRow = new LinkedHashMap<>();
for (Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object>) 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<String,Object>
* - 직접 SELECT 결과 → String(JSON) 가능
* - null → 빈 cfg
*/
@SuppressWarnings("unchecked")
public static ConditionalConfig parseConditionalConfig(Object raw) {
if (raw == null) return ConditionalConfig.empty();
Map<String, Object> map;
try {
if (raw instanceof Map) {
map = (Map<String, Object>) 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<Object>) rulesRaw) {
if (r instanceof Map) {
Map<String, Object> rm = (Map<String, Object>) 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<Map<String, Object>> mappings) {
Partition p = new Partition();
if (mappings == null) return p;
for (Map<String, Object> 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<Map<String, Object>> nonFixed = new ArrayList<>();
public final List<Map<String, Object>> fixed = new ArrayList<>();
}
public static final class ConditionalConfig {
public List<ConditionalRule> 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;
}
}
@@ -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)
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> previewRestApiData(Map<String, Object> 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";
@@ -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<String, Object> getBatchInfo(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectOne(NS + "getBatchInfo", params);
Map<String, Object> 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<String, Object> infoParams = new HashMap<>();
infoParams.put("id", id);
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
Map<String, Object> 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<String, Object> updateBatch(Map<String, Object> 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<String, Object> infoParams = new HashMap<>();
infoParams.put("id", params.get("id"));
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
Map<String, Object> 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<Map<String, Object>> mappings, String userId) {
Map<String, Object> 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<String, Object> 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<String, Object> batch) {
Object idObj = batch.get("id");
if (idObj == null) return;
Map<String, Object> params = new HashMap<>();
params.put("batch_config_id", idObj);
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params);
if (mappings != null) {
for (Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> toMappingList(Object raw) {
if (raw == null) return new ArrayList<>();
if (raw instanceof List) return (List<Map<String, Object>>) raw;
return new ArrayList<>();
}
private String toStr(Object v) {
return v != null ? v.toString() : null;
}
@Transactional
@@ -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;
@@ -102,6 +102,117 @@
<include refid="common.companyCodeFilter"/>
</delete>
<!-- batch_mappings: 특정 batch_config_id 의 매핑 행들 조회 -->
<select id="getBatchMappingsByConfigId" parameterType="map" resultType="map">
SELECT
ID
, 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::TEXT AS MAPPING_CONFIG
, CREATED_BY
, CREATED_DATE
FROM BATCH_MAPPINGS
WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
ORDER BY MAPPING_ORDER, ID
</select>
<!-- batch_mappings: 단건 INSERT (replace-all 패턴이라 INSERT/DELETE 만 사용) -->
<insert id="insertBatchMapping" parameterType="map" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
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}
, <choose>
<when test="mapping_type != null and mapping_type != ''">#{mapping_type}</when>
<otherwise>'direct'</otherwise>
</choose>
, #{mapping_config,jdbcType=OTHER}::jsonb
, #{created_by}
, NOW()
)
</insert>
<!-- batch_mappings: 특정 batch_config_id 의 매핑 전부 삭제 (replace-all 의 앞단계) -->
<delete id="deleteBatchMappingsByConfigId" parameterType="map">
DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
</delete>
<!-- 내부 DB 테이블 목록 조회 -->
<select id="getInternalTables" resultType="map">
SELECT
@@ -5,7 +5,7 @@
<sql id="batchExecutionLogSearchCondition">
<if test="batch_config_id != null">
AND bel.batch_config_id = #{batch_config_id}
AND bel.batch_config_id = #{batch_config_id}::varchar
</if>
<if test="execution_status != null and execution_status != ''">
AND bel.execution_status = #{execution_status}
@@ -84,7 +84,7 @@
<select id="getBatchExecutionLogLatest" parameterType="map" resultType="map">
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
<if test="batch_config_id != null">
AND batch_config_id = #{batch_config_id}
AND batch_config_id = #{batch_config_id}::varchar
</if>
<if test="start_date != null and start_date != ''">
AND start_time &gt;= #{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},
@@ -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
<include refid="common.companyCodeFilter"/>
),
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'
<include refid="common.companyCodeFilter"/>
)
@@ -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
@@ -69,7 +69,7 @@
SELECT
<include refid="selectColumns"/>
FROM EXTERNAL_REST_API_CONNECTIONS E
WHERE E.ID = #{id}
WHERE E.ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</select>
@@ -133,14 +133,14 @@
<if test="save_to_history != null">SAVE_TO_HISTORY = #{save_to_history},</if>
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
</set>
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</update>
<!-- 연결 삭제 -->
<delete id="deleteExternalRestApiConnection" parameterType="map">
DELETE FROM EXTERNAL_REST_API_CONNECTIONS
WHERE ID = #{id}
WHERE ID = #{id}::varchar
<include refid="common.companyCodeFilter"/>
</delete>
@@ -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
</update>
<!-- DB 토큰 조회 (db-token auth type) -->
@@ -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<String, Object> 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<String, Object> obj = Map.of("name", "alice");
assertEquals("alice", MappingTransformer.getValueByPath(obj, "name"));
}
@Test
void getValueByPath_중첩_경로() {
Map<String, Object> obj = Map.of("response", Map.of("access_token", "xyz"));
assertEquals("xyz", MappingTransformer.getValueByPath(obj, "response.access_token"));
}
@Test
void getValueByPath_없는_경로_null() {
Map<String, Object> 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<Map<String, Object>> 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<String, Object> row = Map.of("user_id", "alice", "email", "a@x.com");
List<Map<String, Object>> 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<String, Object> 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<String, Object> row = Map.of("active_flag", "1");
List<Map<String, Object>> 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<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("Y", mapped.get("IS_ACTIVE"));
}
@Test
void transformRow_conditional_매핑_default_폴백() {
Map<String, Object> row = Map.of("active_flag", "9"); // 어떤 룰에도 매칭 안 됨
List<Map<String, Object>> 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<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("?", mapped.get("IS_ACTIVE"));
}
@Test
void transformRow_fixed_매핑_적용() {
Map<String, Object> row = Map.of("user_id", "alice");
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user_id",
"to_column_name", "USER_ID")
);
List<Map<String, Object>> fixed = List.of(
Map.of("mapping_type", "fixed",
"from_column_name", "BATCH_001",
"to_column_name", "SOURCE_BATCH")
);
Map<String, Object> 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<String, Object> row = Map.of(
"user", Map.of("profile", Map.of("name", "박창현"))
);
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user.profile.name",
"to_column_name", "USER_NAME")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "internal", null);
assertEquals("박창현", mapped.get("USER_NAME"));
}
@Test
void transformRow_to_가_restapi_면_company_code_자동주입_안함() {
Map<String, Object> row = Map.of("user_id", "alice");
List<Map<String, Object>> nonFixed = List.of(
Map.of("mapping_type", "direct",
"from_column_name", "user_id",
"to_column_name", "USER_ID")
);
Map<String, Object> mapped = MappingTransformer.transformRow(
row, nonFixed, List.of(), "restapi", "COMPANY_1");
assertFalse(mapped.containsKey("company_code"));
}
}
+125
View File
@@ -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 <host> -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 <host> -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 결과 역직렬화
@@ -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; }
@@ -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<MappingItem[]>([]);
@@ -377,13 +388,27 @@ export default function BatchEditPage() {
});
// 기존 매핑을 mappingList로 변환
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
// 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: (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 || "" : "",
}));
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,13 +704,26 @@ export default function BatchEditPage() {
const first = batchConfig.batch_mappings[0] as any;
finalMappings = mappingList
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
.map((m, index) => ({
.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: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
from_column_type: m.sourceType === "fixed" ? "text" : "text",
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,
@@ -695,10 +733,17 @@ export default function BatchEditPage() {
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",
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[];
};
}) 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 (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
<div className="h-full w-full space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
@@ -1617,14 +1662,22 @@ export default function BatchEditPage() {
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 소스 타입 선택 */}
<div className="w-24 shrink-0">
<div className="w-28 shrink-0">
<Select
value={mapping.sourceType}
onValueChange={(value: "api" | "fixed") =>
onValueChange={(value: "api" | "fixed" | "conditional") =>
updateMappingListItem(mapping.id, {
sourceType: value,
apiField: value === "fixed" ? "" : mapping.apiField,
fixedValue: value === "api" ? "" : mapping.fixedValue,
// 모드 전환 시 입력값 정리
apiField:
value === "api" || value === "conditional"
? mapping.apiField
: "",
fixedValue: value === "fixed" ? mapping.fixedValue : "",
conditionalConfig:
value === "conditional"
? mapping.conditionalConfig || emptyConditionalConfig()
: mapping.conditionalConfig,
})
}
>
@@ -1634,13 +1687,14 @@ export default function BatchEditPage() {
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
<SelectItem value="conditional"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
<div className="min-w-0 flex-1">
{mapping.sourceType === "api" ? (
{mapping.sourceType === "api" && (
<Select
value={mapping.apiField || "none"}
onValueChange={(value) =>
@@ -1667,7 +1721,8 @@ export default function BatchEditPage() {
))}
</SelectContent>
</Select>
) : (
)}
{mapping.sourceType === "fixed" && (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMappingListItem(mapping.id, { fixedValue: e.target.value })}
@@ -1675,6 +1730,19 @@ export default function BatchEditPage() {
className="h-9"
/>
)}
{mapping.sourceType === "conditional" && (
<ConditionalEditor
evaluateField={mapping.apiField}
fieldOptions={fromApiFields}
config={mapping.conditionalConfig || emptyConditionalConfig()}
onEvaluateFieldChange={(v) =>
updateMappingListItem(mapping.id, { apiField: v })
}
onConfigChange={(cfg) =>
updateMappingListItem(mapping.id, { conditionalConfig: cfg })
}
/>
)}
</div>
{/* 삭제 버튼 */}
@@ -427,12 +427,12 @@ export default function BatchManagementPage() {
setIsBatchTypeModalOpen(false);
if (type === "db-to-db") {
sessionStorage.setItem("batch_create_type", "mapping");
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
openTab({ type: "admin", title: "배치 생성 (DB→DB)", admin_url: "/admin/automaticMng/batchmngList/create" });
} else if (type === "restapi-to-db") {
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
openTab({ type: "admin", title: "배치 생성 (API→DB)", admin_url: "/admin/batch-management-new" });
} else {
sessionStorage.setItem("batch_create_type", "node_flow");
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
openTab({ type: "admin", title: "배치 생성 (노드플로우)", admin_url: "/admin/automaticMng/batchmngList/create" });
}
};
@@ -450,7 +450,7 @@ export default function BatchManagementPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
@@ -564,7 +564,7 @@ export default function BatchManagementPage() {
const isSuccess = lastStatus === "SUCCESS";
return (
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
<div key={`${batch.company_code ?? "x"}-${batchId}`} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
{/* 행 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
{/* 토글 */}
@@ -638,7 +638,7 @@ export default function BatchManagementPage() {
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, admin_url: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
title="수정하기"
>
<Pencil className="h-3.5 w-3.5" />
@@ -13,6 +13,15 @@ import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
import type { ConditionalConfig } from "@/lib/api/batch";
import {
ConditionalEditor,
emptyConditionalConfig,
} from "@/components/admin/batch/ConditionalEditor";
import {
ExternalRestApiConnectionAPI,
type ExternalRestApiConnection,
} from "@/lib/api/externalRestApiConnection";
// 타입 정의
type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi";
@@ -36,12 +45,17 @@ interface BatchColumnInfo {
}
// 통합 매핑 아이템 타입
// 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;
}
interface RestApiToDbMappingCardProps {
@@ -117,6 +131,15 @@ export default function BatchManagementNewPage() {
const [fromApiData, setFromApiData] = useState<any[]>([]);
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
// 등록된 REST API 연결 (외부 커넥션 관리에서 등록한 연결 선택)
// - 선택 시 폼(URL/엔드포인트/메서드/Body/인증) 자동 채움
// - 자동으로 API 호출하여 응답 필드 추출 → 매핑 드롭다운 즉시 활성화
const [registeredRestApis, setRegisteredRestApis] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<string>("manual"); // "manual" = 직접 입력
const [rawResponse, setRawResponse] = useState<unknown>(null);
const [rawResponseLoading, setRawResponseLoading] = useState(false);
const [rawResponseError, setRawResponseError] = useState<string>("");
// 통합 매핑 리스트
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
@@ -145,8 +168,110 @@ export default function BatchManagementNewPage() {
useEffect(() => {
loadConnections();
loadAuthServiceNames();
loadRegisteredRestApis();
}, []);
// TO DB 자동 선택 — REST API → DB 모드에서 connections 로드 완료 후 TO 가 비어있으면 internal 자동.
// 사용자가 외부 DB 로 직접 변경하면 toConnection != null 이 되어 더 이상 동작 안 함.
// 대부분의 배치가 internal DB 적재라 디폴트로 들어가는 게 UX 상 자연스러움.
useEffect(() => {
if (batchType === "restapi-to-db" && !toConnection && connections.length > 0) {
handleToConnectionChange("internal");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchType, connections, toConnection]);
// 등록된 REST API 연결 목록 로드
const loadRegisteredRestApis = async () => {
try {
const list = await ExternalRestApiConnectionAPI.getConnections();
setRegisteredRestApis(Array.isArray(list) ? list : []);
} catch (e) {
console.error("등록된 REST API 연결 목록 로드 실패:", e);
}
};
// 등록된 연결 선택 시 폼 자동 채우기 + API 호출 + 응답 필드 추출 (자동 매핑 준비).
// vexplor_rps 의 applyRegisteredRestApi 에서 회사 전용 프리셋(Amaranth) 분기는 의도적으로 제외.
const applyRegisteredRestApi = async (id: string) => {
setSelectedRestApiId(id);
if (id === "manual") return;
const conn = registeredRestApis.find((c) => String(c.id) === id);
if (!conn) return;
// 폼 자동 채움
setFromApiUrl(conn.base_url || "");
setFromEndpoint(conn.endpoint_path || "");
setFromApiMethod((conn.default_method as "GET" | "POST" | "PUT" | "DELETE") || "GET");
setFromApiBody(conn.default_body || "");
// 인증 토큰 자동 채움 (직접 입력 모드)
setAuthTokenMode("direct");
setAuthServiceName("");
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
setFromApiKey(`Bearer ${conn.auth_config.token}`);
} else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) {
setFromApiKey(conn.auth_config.keyValue);
} else {
// wehago 등 백엔드 자동 서명 타입은 토큰 입력 불필요 — 비워둠
setFromApiKey("");
}
// 자동으로 API 호출 → 응답 본문 + 필드 추출하여 매핑 드롭다운 즉시 활성화
setRawResponseError("");
setRawResponseLoading(true);
setRawResponse(null);
try {
const result = await ExternalRestApiConnectionAPI.testConnectionById(
Number(id),
conn.endpoint_path || undefined,
);
if (result.success) {
setRawResponse(result.response_data);
// 응답 안에서 배열을 자동 탐색 (dataArrayPath 가 아직 안 박혀도 동작)
const findArr = (o: unknown, depth = 0): unknown[] | null => {
if (Array.isArray(o)) return o;
if (depth >= 4 || typeof o !== "object" || o === null) return null;
for (const v of Object.values(o)) {
const a = findArr(v, depth + 1);
if (a) return a;
}
return null;
};
const arr = findArr(result.response_data);
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
const fields = Object.keys(arr[0] as Record<string, unknown>);
setFromApiFields(fields);
setFromApiData(arr as Record<string, unknown>[]);
toast.success(
`'${conn.connection_name}' API 호출 완료 — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출`,
);
} else if (
result.response_data &&
typeof result.response_data === "object" &&
!Array.isArray(result.response_data)
) {
const fields = Object.keys(result.response_data as Record<string, unknown>);
setFromApiFields(fields);
setFromApiData([result.response_data as Record<string, unknown>]);
toast.success(`'${conn.connection_name}' API 호출 완료 — 필드 ${fields.length}개 추출`);
} else {
toast.success(`'${conn.connection_name}' API 호출 완료 — 응답을 받았어요`);
}
} else {
const msg = result.message || result.error_details || "API 호출 실패";
setRawResponseError(msg);
toast.error(`'${conn.connection_name}' API 호출 실패: ${msg}`);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setRawResponseError(msg);
toast.error(`API 호출 중 오류: ${msg}`);
} finally {
setRawResponseLoading(false);
}
};
// 인증 서비스명 목록 로드
const loadAuthServiceNames = async () => {
try {
@@ -409,10 +534,21 @@ export default function BatchManagementNewPage() {
// 배치 타입별 검증 및 저장
if (batchType === "restapi-to-db") {
// 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것)
const validMappings = mappingList.filter(
(m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue),
);
// 유효한 매핑만 필터링:
// api → dbColumn + apiField 둘 다 필요
// conditional → dbColumn + apiField (평가 필드) + 최소 1개 룰 또는 default 필요
// fixed → dbColumn + fixedValue 둘 다 필요
const validMappings = mappingList.filter((m) => {
if (!m.dbColumn) return false;
if (m.sourceType === "fixed") return !!m.fixedValue;
if (m.sourceType === "conditional") {
if (!m.apiField) return false;
const cfg = m.conditionalConfig;
if (!cfg) return false;
return cfg.rules.some((r) => r.when || r.then) || !!cfg.default;
}
return !!m.apiField;
});
if (validMappings.length === 0) {
toast.error("최소 하나의 매핑을 설정해주세요.");
@@ -427,10 +563,23 @@ export default function BatchManagementNewPage() {
// 통합 매핑 리스트를 배치 매핑 형태로 변환
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
const apiMappings = validMappings.map((mapping) => ({
from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용
const apiMappings = validMappings.map((mapping) => {
// from_column_name 결정:
// fixed → fixedValue 자체가 저장됨
// conditional → apiField (평가할 API 필드)
// api(direct) → apiField
const fromColumnName =
mapping.sourceType === "fixed" ? mapping.fixedValue : mapping.apiField;
const mappingType: "direct" | "fixed" | "conditional" =
mapping.sourceType === "fixed"
? "fixed"
: mapping.sourceType === "conditional"
? "conditional"
: "direct";
return {
from_connection_type: "restapi" as const,
from_table_name: fromEndpoint,
from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue,
from_column_name: fromColumnName,
from_api_url: fromApiUrl,
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
from_api_method: fromApiMethod,
@@ -444,9 +593,15 @@ export default function BatchManagementNewPage() {
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: mapping.dbColumn,
mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const),
mapping_type: mappingType,
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
}));
// conditional 일 때만 룰 객체를 함께 전송 — 백엔드가 JSONB 직렬화 처리
mapping_config:
mapping.sourceType === "conditional" && mapping.conditionalConfig
? mapping.conditionalConfig
: null,
};
});
// 실제 API 호출
try {
@@ -465,7 +620,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -556,7 +711,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -573,10 +728,10 @@ export default function BatchManagementNewPage() {
toast.error("지원하지 않는 배치 타입입니다.");
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
return (
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
<div className="h-full w-full space-y-6 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -590,49 +745,48 @@ export default function BatchManagementNewPage() {
</div>
</div>
{/* 배치 타입 선택 */}
{/* 배치 타입 + 기본 정보 — 한 행으로 통합 (xl+ 한 줄, 그 미만은 stack) */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(28rem,1.4fr)_1fr_1fr_1.5fr]">
{/* 모드 토글 2개 */}
<div className="grid grid-cols-2 gap-3">
{batchTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setBatchType(option.value)}
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
className={`group relative flex items-center gap-2 rounded-lg border p-3 text-left transition-all ${
batchType === option.value
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-4 w-4" /> : <Database className="h-4 w-4" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{option.label}</div>
<div className="text-[11px] text-muted-foreground">{option.description}</div>
<div className="text-sm font-medium leading-tight">{option.label}</div>
<div className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{option.description}</div>
</div>
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
{batchType === option.value && <div className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-primary" />}
</button>
))}
</div>
{/* 기본 정보 */}
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4 text-muted-foreground" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
{/* 배치명 */}
<div className="space-y-1">
<Label htmlFor="batchName" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명" className="h-9 text-sm" />
</div>
<div className="space-y-1.5">
{/* 실행 스케줄 */}
<div className="space-y-1">
<Label htmlFor="cronSchedule" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
</div>
</div>
<div className="space-y-1.5">
{/* 설명 (textarea 한 줄 높이 — 다른 입력과 정렬) */}
<div className="space-y-1">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
<Input id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="설명 (선택)" className="h-9 text-sm" />
</div>
</div>
@@ -659,125 +813,130 @@ export default function BatchManagementNewPage() {
{/* REST API 설정 (REST API → DB) */}
{batchType === "restapi-to-db" && (
<div className="space-y-4">
{/* API 서버 URL */}
{/* 등록된 연결 선택 — 외부 커넥션 관리에 등록한 REST API 연결을 골라 자동 호출 */}
<div>
<Label htmlFor="fromApiUrl">API URL *</Label>
<Label htmlFor="registeredRestApi" className="flex items-center gap-1.5 text-xs">
<span>🔗 </span>
{rawResponseLoading && (
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</Label>
<Select
value={selectedRestApiId}
onValueChange={applyRegisteredRestApi}
>
<SelectTrigger id="registeredRestApi" className="h-9 text-sm">
<SelectValue placeholder="직접 입력 (등록된 연결 사용 안 함)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual"> ( )</SelectItem>
{registeredRestApis.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
<div className="flex items-center gap-2">
<span>{c.connection_name}</span>
<span className="text-[10px] text-muted-foreground">
[{c.auth_type || "none"}]
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{rawResponseError && (
<p className="mt-1 text-[11px] text-destructive">{rawResponseError}</p>
)}
</div>
{/* API 서버 URL + HTTP 메서드 — 한 행, URL 이 길어 7:3 비율 */}
<div className="grid grid-cols-[3fr_1fr] gap-3">
<div>
<Label htmlFor="fromApiUrl" className="text-xs">API URL *</Label>
<Input
id="fromApiUrl"
value={fromApiUrl}
onChange={(e) => setFromApiUrl(e.target.value)}
placeholder="https://api.example.com"
className="h-9 text-sm"
/>
</div>
{/* 인증 토큰 설정 */}
<div>
<Label> (Authorization)</Label>
{/* 토큰 설정 방식 선택 */}
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="authTokenMode"
value="direct"
checked={authTokenMode === "direct"}
onChange={() => {
setAuthTokenMode("direct");
setAuthServiceName("");
}}
className="h-3.5 w-3.5"
/>
<span className="text-xs"> </span>
</label>
<label className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="authTokenMode"
value="db"
checked={authTokenMode === "db"}
onChange={() => setAuthTokenMode("db")}
className="h-3.5 w-3.5"
/>
<span className="text-xs">DB에서 </span>
</label>
</div>
{/* 직접 입력 모드 */}
{authTokenMode === "direct" && (
<Input
id="fromApiKey"
value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)}
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
className="mt-2"
/>
)}
{/* DB 선택 모드 */}
{authTokenMode === "db" && (
<Select
value={authServiceName || "none"}
onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}
>
<SelectTrigger className="mt-2">
<SelectValue placeholder="서비스명 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{authServiceNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="mt-1 text-xs text-muted-foreground">
{authTokenMode === "direct"
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
</p>
</div>
{/* 엔드포인트 */}
<div>
<Label htmlFor="fromEndpoint"> *</Label>
<Input
id="fromEndpoint"
value={fromEndpoint}
onChange={(e) => setFromEndpoint(e.target.value)}
placeholder="/api/users"
/>
</div>
{/* HTTP 메서드 */}
<div>
<Label>HTTP </Label>
<Label className="text-xs"></Label>
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
<SelectTrigger>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 데이터 배열 경로 */}
{/* 엔드포인트 + 데이터 배열 경로 — 한 행, 50:50 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="dataArrayPath"> </Label>
<Label htmlFor="fromEndpoint" className="text-xs"> *</Label>
<Input
id="fromEndpoint"
value={fromEndpoint}
onChange={(e) => setFromEndpoint(e.target.value)}
placeholder="/api/users"
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="dataArrayPath" className="text-xs"> </Label>
<Input
id="dataArrayPath"
value={dataArrayPath}
onChange={(e) => setDataArrayPath(e.target.value)}
placeholder="response (예: data.items, results)"
placeholder="resultData (비우면 자동 탐색)"
className="h-9 text-sm"
/>
<p className="mt-1 text-xs text-muted-foreground">
API . .
<br />
예시: response, data.items, result.list
</p>
</div>
</div>
{/* 인증 토큰 — 라디오 + 입력을 한 행으로 압축 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1.5 flex items-center gap-2">
<div className="flex shrink-0 overflow-hidden rounded-md border">
<button
type="button"
onClick={() => { setAuthTokenMode("direct"); setAuthServiceName(""); }}
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "direct" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
> </button>
<button
type="button"
onClick={() => setAuthTokenMode("db")}
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "db" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
>DB에서 </button>
</div>
{authTokenMode === "direct" ? (
<Input
id="fromApiKey"
value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)}
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
className="h-9 flex-1 text-sm"
/>
) : (
<Select value={authServiceName || "none"} onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}>
<SelectTrigger className="h-9 flex-1 text-sm">
<SelectValue placeholder="서비스명 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{authServiceNames.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* Request Body (POST/PUT/DELETE용) */}
@@ -796,17 +955,20 @@ export default function BatchManagementNewPage() {
</div>
)}
{/* API 파라미터 설정 */}
<div className="space-y-4">
<div className="border-t pt-4">
<Label className="text-base font-medium">API </Label>
<p className="mt-1 text-sm text-muted-foreground"> .</p>
</div>
{/* API 파라미터 설정 — 기본 접힘. 필요할 때만 펼침 */}
<details className="rounded-lg border bg-muted/20 [&[open]>summary>svg]:rotate-90">
<summary className="flex cursor-pointer list-none items-center gap-2 p-3 text-xs font-medium hover:bg-muted/30">
<svg className="h-3 w-3 transition-transform" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4z" />
</svg>
API
<span className="text-[10px] font-normal text-muted-foreground"> / </span>
</summary>
<div className="space-y-3 border-t p-3">
<div>
<Label> </Label>
<Label className="text-xs"> </Label>
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
<SelectTrigger>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -887,6 +1049,7 @@ export default function BatchManagementNewPage() {
</>
)}
</div>
</details>
{/* API 호출 미리보기 정보 */}
{fromApiUrl && fromEndpoint && (
@@ -1133,7 +1296,10 @@ export default function BatchManagementNewPage() {
{/* 1. 커넥션 선택 - 항상 활성화 */}
<div>
<Label> *</Label>
<Select onValueChange={handleToConnectionChange}>
<Select
value={toConnection ? (toConnection.type === "internal" ? "internal" : String(toConnection.id)) : ""}
onValueChange={handleToConnectionChange}
>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
@@ -1153,7 +1319,7 @@ export default function BatchManagementNewPage() {
{/* 2. 테이블 선택 - 커넥션 선택 후 활성화 */}
<div className={toTables.length === 0 ? "pointer-events-none opacity-50" : ""}>
<Label> *</Label>
<Select onValueChange={handleToTableChange} disabled={toTables.length === 0}>
<Select value={toTable} onValueChange={handleToTableChange} disabled={toTables.length === 0}>
<SelectTrigger>
<SelectValue
placeholder={toTables.length === 0 ? "먼저 커넥션을 선택하세요" : "테이블을 선택하세요"}
@@ -1477,30 +1643,30 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>DB API .</CardDescription>
<CardHeader className="px-4 pb-2 pt-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[11px]">DB API .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<CardContent className="px-4 pb-3 pt-0">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{/* 왼쪽: 샘플 데이터 */}
<div className="flex flex-col">
<div className="mb-3 flex h-8 items-center">
<h4 className="text-sm font-semibold"> ( 3)</h4>
<div className="mb-2 flex h-7 items-center">
<h4 className="text-xs font-semibold"> ( 3)</h4>
</div>
{sampleJsonList.length > 0 ? (
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
<div className="space-y-2">
<div className="bg-muted/30 h-[300px] overflow-y-auto rounded-lg border p-2">
<div className="space-y-1.5">
{sampleJsonList.map((json, index) => (
<div key={index} className="bg-background rounded border p-2">
<pre className="font-mono text-xs whitespace-pre-wrap">{json}</pre>
<div key={index} className="bg-background rounded border p-1.5">
<pre className="font-mono text-[11px] whitespace-pre-wrap">{json}</pre>
</div>
))}
</div>
</div>
) : (
<div className="flex h-[360px] items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-sm">
<div className="flex h-[300px] items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
API .
</p>
</div>
@@ -1509,39 +1675,39 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
{/* 오른쪽: 매핑 영역 (스크롤) */}
<div className="flex flex-col">
<div className="mb-3 flex h-8 items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
<Plus className="h-4 w-4" />
<div className="mb-2 flex h-7 items-center justify-between">
<h4 className="text-xs font-semibold"> </h4>
<Button variant="outline" size="sm" onClick={addMapping} className="h-7 gap-1 px-2 text-[11px]">
<Plus className="h-3 w-3" />
</Button>
</div>
{mappingList.length === 0 ? (
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
<p className="text-muted-foreground text-sm"> .</p>
<Button variant="link" onClick={addMapping} className="mt-2">
<div className="flex h-[300px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
<p className="text-muted-foreground text-xs"> .</p>
<Button variant="link" size="sm" onClick={addMapping} className="mt-1 h-auto text-xs">
</Button>
</div>
) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
<div className="bg-muted/30 h-[300px] space-y-2 overflow-y-auto rounded-lg border p-2">
{mappingList.map((mapping, index) => (
<div key={mapping.id} className="bg-background flex items-center gap-2 rounded-lg border p-3">
<div key={mapping.id} className="bg-background flex items-center gap-1.5 rounded-lg border p-2">
{/* 순서 표시 */}
<div className="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
<div className="bg-primary/10 text-primary flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
{index + 1}
</div>
{/* DB 컬럼 선택 (좌측 - TO) */}
<div className="w-36 shrink-0">
<div className="w-32 shrink-0">
<Select
value={mapping.dbColumn || "none"}
onValueChange={(value) =>
updateMapping(mapping.id, { dbColumn: value === "none" ? "" : value })
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="DB 컬럼" />
</SelectTrigger>
<SelectContent>
@@ -1563,40 +1729,49 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
</div>
{/* 화살표 */}
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
<ArrowLeft className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
{/* 소스 타입 선택 */}
<div className="w-24 shrink-0">
<Select
value={mapping.sourceType}
onValueChange={(value: "api" | "fixed") =>
onValueChange={(value: "api" | "fixed" | "conditional") =>
updateMapping(mapping.id, {
sourceType: value,
apiField: value === "fixed" ? "" : mapping.apiField,
fixedValue: value === "api" ? "" : mapping.fixedValue,
// 모드 전환 시 입력값 정리
apiField:
value === "api" || value === "conditional"
? mapping.apiField
: "",
fixedValue: value === "fixed" ? mapping.fixedValue : "",
conditionalConfig:
value === "conditional"
? mapping.conditionalConfig || emptyConditionalConfig()
: mapping.conditionalConfig,
})
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
<SelectItem value="conditional"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
<div className="min-w-0 flex-1">
{mapping.sourceType === "api" ? (
{mapping.sourceType === "api" && (
<Select
value={mapping.apiField || "none"}
onValueChange={(value) =>
updateMapping(mapping.id, { apiField: value === "none" ? "" : value })
}
>
<SelectTrigger className="h-9">
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="API 필드" />
</SelectTrigger>
<SelectContent>
@@ -1606,7 +1781,7 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
<div className="flex items-center gap-2">
<span>{field}</span>
{firstSample && firstSample[field] !== undefined && (
<span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-[10px]">
(: {String(firstSample[field]).substring(0, 15)}
{String(firstSample[field]).length > 15 ? "..." : ""})
</span>
@@ -1616,12 +1791,26 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
))}
</SelectContent>
</Select>
) : (
)}
{mapping.sourceType === "fixed" && (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
placeholder="고정값 입력"
className="h-9"
className="h-7 text-xs"
/>
)}
{mapping.sourceType === "conditional" && (
<ConditionalEditor
evaluateField={mapping.apiField}
fieldOptions={fromApiFields}
config={mapping.conditionalConfig || emptyConditionalConfig()}
onEvaluateFieldChange={(v) =>
updateMapping(mapping.id, { apiField: v })
}
onConfigChange={(cfg) =>
updateMapping(mapping.id, { conditionalConfig: cfg })
}
/>
)}
</div>
@@ -1631,9 +1820,9 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
variant="ghost"
size="icon"
onClick={() => removeMapping(mapping.id)}
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
className="text-muted-foreground hover:text-destructive h-6 w-6 shrink-0"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
@@ -516,40 +516,40 @@ export default function RolesPage() {
return (
<React.Fragment key={String(node.objid)}>
<tr className="border-t hover:bg-muted/30 transition-colors">
<td className="py-2 pr-2" style={{ paddingLeft: `${node.level * 20 + 12}px` }}>
<div className="flex items-center gap-1.5">
<td className="py-1.5 pr-2" style={{ paddingLeft: `${node.level * 16 + 10}px` }}>
<div className="flex items-center gap-1">
{hasChildren ? (
<button
onClick={() => toggleExpand(String(node.objid))}
className="hover:bg-muted rounded p-0.5"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
) : (
<span className="inline-block w-4" />
<span className="inline-block w-3" />
)}
<span className={cn("text-sm", hasChildren && "font-semibold")}>
<span className={cn("text-xs", hasChildren && "font-semibold")}>
{node.menu_name}
</span>
</div>
</td>
<td className="py-2 text-center">
<td className="py-1.5 text-center">
<Checkbox
checked={editChecked}
onCheckedChange={(c) => handleEditCol(String(node.objid), c === true)}
/>
</td>
<td className="py-2 text-center">
<td className="py-1.5 text-center">
<Checkbox
checked={perm.delete_yn === "Y"}
onCheckedChange={(c) => handleDeleteCol(String(node.objid), c === true)}
/>
</td>
<td className="py-2 text-center">
<td className="py-1.5 text-center">
<Checkbox
checked={perm.read_yn === "Y"}
onCheckedChange={(c) => handleReadCol(String(node.objid), c === true)}
@@ -617,34 +617,34 @@ export default function RolesPage() {
)}
{/* 상단 4분할: 권한목록 | 권한있는직원 | 이동버튼 | 권한없는직원 */}
<div className="grid shrink-0 grid-cols-1 gap-4 xl:grid-cols-[260px_1fr_auto_1fr]">
<div className="grid shrink-0 grid-cols-1 gap-3 xl:grid-cols-[220px_1fr_auto_1fr]">
{/* 권한 목록 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="flex items-center justify-between border-b p-3">
<div className="flex items-center gap-2">
<Shield className="text-primary h-4 w-4" />
<h2 className="text-sm font-semibold"> </h2>
<div className="flex items-center justify-between border-b p-2.5">
<div className="flex items-center gap-1.5">
<Shield className="text-primary h-3.5 w-3.5" />
<h2 className="text-xs font-semibold"> </h2>
</div>
<Button size="sm" onClick={handleCreateRole} className="h-7 gap-1 px-2 text-xs">
<Button size="sm" onClick={handleCreateRole} className="h-6 gap-1 px-2 text-[11px]">
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2 border-b p-2">
<div className="space-y-1.5 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<Input
placeholder="검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 pl-8 text-xs"
className="h-7 pl-7 text-[11px]"
/>
</div>
{isSuperAdmin && (
<div className="flex items-center gap-1">
<Filter className="text-muted-foreground h-3.5 w-3.5 flex-shrink-0" />
<Filter className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
@@ -661,7 +661,7 @@ export default function RolesPage() {
</div>
<div
className="flex-1 overflow-y-auto"
style={{ maxHeight: "clamp(220px, 32vh, 320px)" }}
style={{ maxHeight: "clamp(200px, 28vh, 280px)" }}
>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
@@ -682,7 +682,7 @@ export default function RolesPage() {
key={`${role.company_code ?? "_"}-${role.objid}`}
onClick={() => setSelectedRole(role)}
className={cn(
"group cursor-pointer p-2.5 transition-colors",
"group cursor-pointer p-2 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-muted/50",
)}
>
@@ -690,7 +690,7 @@ export default function RolesPage() {
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm font-semibold",
"truncate text-xs font-semibold",
isSelected && "text-primary",
)}
>
@@ -738,12 +738,12 @@ export default function RolesPage() {
{/* 권한있는 직원 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="border-b p-3">
<div className="mb-2 flex items-center gap-2">
<Users className="text-primary h-4 w-4" />
<h2 className="text-sm font-semibold"> ({members.length})</h2>
<div className="border-b p-2.5">
<div className="mb-1.5 flex items-center gap-1.5">
<Users className="text-primary h-3.5 w-3.5" />
<h2 className="text-xs font-semibold"> ({members.length})</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Checkbox
checked={
filteredMembers.length > 0 &&
@@ -752,14 +752,14 @@ export default function RolesPage() {
onCheckedChange={(c) => handleMembersSelectAll(c === true)}
disabled={!selectedRole}
/>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-[11px]"></span>
<div className="relative ml-auto flex-1 max-w-[180px]">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<Input
placeholder="검색"
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="h-7 pl-7 text-xs"
className="h-6 pl-6 text-[11px]"
disabled={!selectedRole}
/>
</div>
@@ -767,7 +767,7 @@ export default function RolesPage() {
</div>
<div
className="flex-1 overflow-y-auto"
style={{ height: "clamp(220px, 32vh, 320px)" }}
style={{ height: "clamp(200px, 28vh, 280px)" }}
>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
@@ -788,7 +788,7 @@ export default function RolesPage() {
key={u.user_id}
onClick={() => toggleMemberCheck(u.user_id)}
className={cn(
"flex cursor-pointer items-center gap-2 p-2 transition-colors",
"flex cursor-pointer items-center gap-1.5 p-1.5 transition-colors",
checkedMembers.has(u.user_id) ? "bg-muted" : "hover:bg-muted/50",
)}
>
@@ -798,7 +798,7 @@ export default function RolesPage() {
onClick={(e) => e.stopPropagation()}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
<div className="truncate text-xs">{u.user_name || u.user_id}</div>
{u.dept_name && (
<div className="text-muted-foreground truncate text-[10px]">
{u.dept_name}
@@ -813,13 +813,13 @@ export default function RolesPage() {
</div>
{/* 이동 버튼: --> 삭제 / <-- 추가 */}
<div className="flex flex-row items-center justify-center gap-2 xl:flex-col">
<div className="flex flex-row items-center justify-center gap-1.5 xl:flex-col">
<Button
variant="outline"
size="sm"
onClick={handleRemoveMembers}
disabled={!selectedRole || checkedMembers.size === 0}
className="gap-1 text-xs"
className="h-7 gap-1 px-2 text-[11px]"
>
<span>--&gt;</span>
<span></span>
@@ -829,7 +829,7 @@ export default function RolesPage() {
size="sm"
onClick={handleAddMembers}
disabled={!selectedRole || checkedNonMembers.size === 0}
className="gap-1 text-xs"
className="h-7 gap-1 px-2 text-[11px]"
>
<span>&lt;--</span>
<span></span>
@@ -838,12 +838,12 @@ export default function RolesPage() {
{/* 권한없는 직원 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="border-b p-3">
<div className="mb-2 flex items-center gap-2">
<Users className="text-muted-foreground h-4 w-4" />
<h2 className="text-sm font-semibold"> ({nonMembers.length})</h2>
<div className="border-b p-2.5">
<div className="mb-1.5 flex items-center gap-1.5">
<Users className="text-muted-foreground h-3.5 w-3.5" />
<h2 className="text-xs font-semibold"> ({nonMembers.length})</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Checkbox
checked={
filteredNonMembers.length > 0 &&
@@ -852,14 +852,14 @@ export default function RolesPage() {
onCheckedChange={(c) => handleNonMembersSelectAll(c === true)}
disabled={!selectedRole}
/>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-[11px]"></span>
<div className="relative ml-auto flex-1 max-w-[180px]">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<Input
placeholder="검색"
value={nonMemberSearch}
onChange={(e) => setNonMemberSearch(e.target.value)}
className="h-7 pl-7 text-xs"
className="h-6 pl-6 text-[11px]"
disabled={!selectedRole}
/>
</div>
@@ -867,7 +867,7 @@ export default function RolesPage() {
</div>
<div
className="flex-1 overflow-y-auto"
style={{ height: "clamp(220px, 32vh, 320px)" }}
style={{ height: "clamp(200px, 28vh, 280px)" }}
>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
@@ -888,7 +888,7 @@ export default function RolesPage() {
key={u.user_id}
onClick={() => toggleNonMemberCheck(u.user_id)}
className={cn(
"flex cursor-pointer items-center gap-2 p-2 transition-colors",
"flex cursor-pointer items-center gap-1.5 p-1.5 transition-colors",
checkedNonMembers.has(u.user_id) ? "bg-muted" : "hover:bg-muted/50",
)}
>
@@ -898,7 +898,7 @@ export default function RolesPage() {
onClick={(e) => e.stopPropagation()}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
<div className="truncate text-xs">{u.user_name || u.user_id}</div>
{u.dept_name && (
<div className="text-muted-foreground truncate text-[10px]">
{u.dept_name}
@@ -918,14 +918,14 @@ export default function RolesPage() {
className="bg-card flex min-h-0 flex-1 flex-col rounded-lg border shadow-sm"
style={{ maxHeight: "clamp(280px, 40vh, 430px)" }}
>
<div className="shrink-0 border-b p-3">
<h2 className="text-sm font-semibold">
<div className="shrink-0 border-b p-2.5">
<h2 className="text-xs font-semibold">
{" "}
{selectedRole && (
<span className="text-muted-foreground text-xs">({selectedRole.auth_name})</span>
<span className="text-muted-foreground text-[11px]">({selectedRole.auth_name})</span>
)}
</h2>
<p className="text-muted-foreground mt-0.5 text-[11px]">
<p className="text-muted-foreground mt-0.5 text-[10px]">
·
</p>
</div>
@@ -943,14 +943,14 @@ export default function RolesPage() {
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead className="bg-muted sticky top-0 z-10">
<tr>
<th className="py-2.5 pl-3 text-left text-xs font-semibold w-[40%]">
<th className="py-1.5 pl-2.5 text-left text-[11px] font-semibold w-[40%]">
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<th className="py-1.5 text-center text-[11px] font-semibold w-[20%]">
<div className="flex flex-col items-center gap-0.5">
<span>/</span>
<Checkbox
checked={isColumnAllChecked("edit")}
@@ -958,8 +958,8 @@ export default function RolesPage() {
/>
</div>
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<th className="py-1.5 text-center text-[11px] font-semibold w-[20%]">
<div className="flex flex-col items-center gap-0.5">
<span></span>
<Checkbox
checked={isColumnAllChecked("delete")}
@@ -967,8 +967,8 @@ export default function RolesPage() {
/>
</div>
</th>
<th className="py-2.5 text-center text-xs font-semibold w-[20%]">
<div className="flex flex-col items-center gap-1">
<th className="py-1.5 text-center text-[11px] font-semibold w-[20%]">
<div className="flex flex-col items-center gap-0.5">
<span></span>
<Checkbox
checked={isColumnAllChecked("read")}
@@ -0,0 +1,163 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ConditionalConfig, ConditionalRule } from "@/lib/api/batch";
interface ConditionalEditorProps {
// 평가 대상 필드명 (REST API 응답 필드명 또는 DB 컬럼명)
evaluateField: string;
// 선택 가능한 필드 후보 — REST API 응답 필드 목록 등
fieldOptions: string[];
// 현재 conditional 규칙
config: ConditionalConfig;
onEvaluateFieldChange: (field: string) => void;
onConfigChange: (cfg: ConditionalConfig) => void;
}
// vexplor_rps 의 ConditionalEditor 1:1 포팅.
// 예: enrlFg 가 "J01" → "active", "J05" → "inactive", 나머지 → "" 식 lookup.
export function ConditionalEditor({
evaluateField,
fieldOptions,
config,
onEvaluateFieldChange,
onConfigChange,
}: ConditionalEditorProps) {
const cfg: ConditionalConfig = config?.rules
? config
: { rules: [{ when: "", then: "" }], default: "" };
const isEvaluateFieldMissing = !evaluateField;
const updateRule = (idx: number, patch: Partial<ConditionalRule>) => {
const rules = cfg.rules.map((r, i) => (i === idx ? { ...r, ...patch } : r));
onConfigChange({ ...cfg, rules });
};
const addRule = () =>
onConfigChange({ ...cfg, rules: [...cfg.rules, { when: "", then: "" }] });
const removeRule = (idx: number) =>
onConfigChange({ ...cfg, rules: cfg.rules.filter((_, i) => i !== idx) });
return (
<div className="space-y-1.5 rounded border bg-muted/30 p-2">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Select
value={evaluateField || "none"}
onValueChange={(v) => onEvaluateFieldChange(v === "none" ? "" : v)}
>
<SelectTrigger
className={cn(
"h-7 text-xs",
isEvaluateFieldMissing && "border-destructive ring-1 ring-destructive/40",
)}
>
<SelectValue placeholder="조건을 평가할 필드 선택 (필수)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{/* 저장된 evaluateField 가 fieldOptions 에 없을 때 동적 추가 (응답 미리보기 안 한 편집 모드 대응) */}
{evaluateField && !fieldOptions.includes(evaluateField) && (
<SelectItem value={evaluateField}>{evaluateField} ()</SelectItem>
)}
{fieldOptions.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="pl-[60px] text-[10px] text-muted-foreground">
(: status {" "}
<span className="font-mono">enrlFg</span> J01active ={" "}
<span className="font-mono">enrlFg</span>)
</p>
</div>
<div className="space-y-1">
{cfg.rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<Input
value={rule.when}
onChange={(e) => updateRule(idx, { when: e.target.value })}
placeholder="예: J01"
className="h-7 flex-1 text-xs"
/>
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<Input
value={rule.then}
onChange={(e) => updateRule(idx, { then: e.target.value })}
placeholder="저장값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeRule(idx)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addRule}
className="h-6 gap-1 px-2 text-[10px]"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 border-t pt-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> ()</span>
<Input
value={cfg.default}
onChange={(e) => onConfigChange({ ...cfg, default: e.target.value })}
placeholder="예: 0 또는 빈값"
className="h-7 flex-1 text-xs"
/>
</div>
</div>
);
}
// 빈 conditionalConfig 기본값 — page 측에서 모드 전환 시 사용
export const emptyConditionalConfig = (): ConditionalConfig => ({
rules: [{ when: "", then: "" }],
default: "",
});
// 저장된 mapping_config (string|object|null) 를 안전하게 ConditionalConfig 로 normalize
export function normalizeConditionalConfig(raw: unknown): ConditionalConfig {
if (!raw) return emptyConditionalConfig();
let parsed: any = raw;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
return emptyConditionalConfig();
}
}
return {
rules: Array.isArray(parsed?.rules) ? parsed.rules : [],
default: typeof parsed?.default === "string" ? parsed.default : "",
};
}
+19
View File
@@ -64,6 +64,19 @@ export interface RecentLog {
duration_ms: number | null;
}
// 조건 변환 규칙 — mapping_type === 'conditional' 일 때 mapping_config 에 저장.
// 평가: row[from_column_name] 값을 cfg.rules 의 when 과 문자열 동등 비교, 매칭되는 then 반환.
// 매칭 없으면 cfg.default.
export interface ConditionalRule {
when: string;
then: string;
}
export interface ConditionalConfig {
rules: ConditionalRule[];
default: string;
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
@@ -82,6 +95,12 @@ export interface BatchMapping {
to_column_name: string;
to_column_type?: string;
// 매핑 유형 — 'direct' (그대로 복사) / 'fixed' (from_column_name 자체가 고정값) / 'conditional' (when/then 룰)
mapping_type?: 'direct' | 'fixed' | 'conditional';
// conditional 일 때 ConditionalConfig 의 JSON. 백엔드는 JSONB 로 저장.
// 요청 시 string(JSON) 또는 object 둘 다 허용 — 백엔드가 normalize.
mapping_config?: ConditionalConfig | string | null;
mapping_order?: number;
created_date?: Date;
created_by?: string;
+10 -2
View File
@@ -164,7 +164,9 @@ class BatchManagementAPIClass {
BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
// 백엔드는 snake_case (total_count) 로 응답하므로 두 키 모두 옵션으로 받음
total_count?: number;
totalCount?: number;
}>
>(`${this.BASE_PATH}/rest-api/preview`, requestData);
@@ -172,7 +174,13 @@ class BatchManagementAPIClass {
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
}
return response.data.data || { fields: [], samples: [], totalCount: 0 };
const raw = response.data.data;
return {
fields: raw?.fields ?? [],
samples: raw?.samples ?? [],
// 백엔드는 total_count 로 응답 → camelCase totalCount 로 normalize
totalCount: raw?.total_count ?? raw?.totalCount ?? 0,
};
} catch (error) {
console.error("REST API 미리보기 오류:", error);
throw error;
@@ -0,0 +1,280 @@
# 배치/파이프라인 이식 작업 분석 (2026-05-12)
작성자: hjjeong
배경: 박창현 팀장님(`chpark`) 카톡 (2026-05-11)
> *"그 수집관리 쪽은 파이프라인 쪽 가져와야 하는데. 빠이프. 내가 파이프 커밋해둠."*
> *"DB 라던지 restapi 라던지 소스 데이터 가져온뒤에 우리 db에 적재할 때, 원본 소스의 값이 1 인데 우리 시스템은 Y 일경우 → 조건변환을 통해서 변경된 값으로 우리 db에 적재."*
---
## 한 줄 요약
**팀장님이 만들어두신 "파이프" 는 `/Users/jhj/vexplor_rps/` (별도 저장소) 에 있는 완성된 ETL 코드**. INVYONE 으로 옮기는 작업은 **Node.js → Spring 재작성** + 일부 Frontend 보강 + DB 스키마 한 컬럼 추가가 필요하다.
---
## 1. 도메인 매트릭스 — INVYONE 의 현재 상태
INVYONE 안에 비슷한 이름의 도메인이 셋이고, 셋 다 **데이터 이동 실행 로직이 비어있다**.
| 도메인 | 메뉴 | 페이지 | 상태 |
|---|---|---|---|
| 배치관리 (구) | `/admin/batch-management` | [page.tsx](../../frontend/app/(main)/admin/batch-management/page.tsx) 206줄 | 모달 기반 db-to-db 만 |
| 배치관리 (신) | `/admin/automaticMng/batchmngList` | [edit/[id]/page.tsx](../../frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx) 1850줄 | 매핑 UI 풍부 — **저장도 실행도 안 됨** |
| 수집관리 | `/admin/systemMng/collection-managementList` | [page.tsx](../../frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx) | `executeCollection` 이 jobs 테이블에 빈 행 INSERT 후 `records_processed=0` 박고 종료 |
| 제어관리 (파이프라인) | `/admin/systemMng/dataflow/...` | [DataFlowDesigner.tsx](../../frontend/components/dataflow/DataFlowDesigner.tsx) | 노드 그래프 UI. flow_data JSONB 저장만. 실행 엔진 없음 |
→ 팀장님 카톡 의미: **수집관리 + 배치관리 양쪽에 파이프라인(변환/실행) 기능을 가져와야 한다**.
---
## 2. 진짜 파이프라인 코드 위치 — `/Users/jhj/vexplor_rps/`
별도 git 저장소. INVYONE 의 [`_pipeline_backup/`](../../_pipeline_backup/) 폴더와는 무관(그건 mcp-agent-orchestrator 실행 기록).
### Backend — `vexplor_rps/backend-node/` (TypeScript/Node.js)
| 파일 | 역할 |
|---|---|
| `src/services/batchSchedulerService.ts` | **ETL 본체**. `executeMapping()` 함수가 FROM 읽기 → 변환 → TO 저장 3단계 실행 |
| `src/services/batchManagementService.ts` | 외부 DB 커넥터 (PG/MySQL/Oracle/MSSQL 등) |
| `src/services/erpApiClient.ts` (226줄) | Wehago/Amaranth ERP REST API 호출, HMAC-SHA256 서명 |
| `src/services/erpBatchSeedService.ts` (429줄) | 6종 매칭 배치 자동 시드 |
| `src/services/erpPresetSeedService.ts` (158줄) | REST API 연결 사전 설정 |
| `src/services/erpSyncService.ts` (539줄) | 동기화 로직 |
| `src/services/erpTableMigration.ts` (172줄) | Idempotent 마이그레이션 |
#### `executeMapping()` 핵심 흐름 (`batchSchedulerService.ts`)
```
L291~500 FROM 읽기 — 연결별 테이블 그룹화 후 batch fetch
├ internal: PostgreSQL 직접 쿼리
├ external_db: DatabaseConnectorFactory 동적 커넥터
└ restapi: GET/POST 응답 + dataArrayPath 추출
L550~596 매핑 변환 — row.map() 안에서 mapping_type 분기
├ "direct": row[from] → to (그대로 복사)
├ "fixed": from_column_name 자체가 고정값
└ "conditional": when/then 매칭 + default ← 1→Y 변환
L619~ TO 저장
├ DB: INSERT 또는 UPSERT (save_mode 기반)
└ REST: POST/PUT/DELETE + Request Body 템플릿
```
### Frontend — `vexplor_rps/frontend/`
| 파일 | 역할 |
|---|---|
| `app/(main)/admin/batch-management-new/page.tsx` (1865줄) | 배치 에디터 UI 본체. 좌우 2패널 + 매핑 그리드 + ConditionalEditor |
| `lib/api/batch.ts`, `batchManagement.ts` | API 클라이언트 + 타입 정의 |
INVYONE 의 [`batchmngList/edit/[id]/page.tsx`](../../frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx) (1850줄) 가 vexplor_rps 의 그것과 **거의 동일 구조** — 팀장님이 INVYONE 으로 한 번 옮긴 흔적. 다만 `mapping_type='conditional'` 분기와 ConditionalEditor 가 빠져있다.
---
## 3. 조건 변환 자료구조
### 매핑 row 데이터 모델
```typescript
{
id: string,
batch_config_id: string,
from_connection_type: "internal" | "external_db" | "restapi",
from_table_name: string,
from_column_name: string,
to_connection_type: "internal" | "external_db" | "restapi",
to_table_name: string,
to_column_name: string,
mapping_order: number,
mapping_type: "direct" | "fixed" | "conditional", // ← 핵심
mapping_config: ConditionalConfig | null, // ← 신규 컬럼 필요
// ... API 매핑용 (from_api_url, to_api_body 등)
}
```
### `ConditionalConfig` (mapping_type='conditional' 일 때)
```typescript
interface ConditionalConfig {
rules: { when: string; then: string }[];
default: string;
}
```
### 평가 알고리즘 (단순 문자열 매칭)
```ts
function evaluateConditional(sourceVal: string, cfg: ConditionalConfig): string {
for (const rule of cfg.rules) {
if (rule.when === sourceVal) return rule.then;
}
return cfg.default;
}
```
표현식 평가(SpEL/JEXL) 안 씀. Java 로 옮길 때 `Map<String,String>` + `getOrDefault` 한 줄이면 끝.
### 예시 (팀장님 카톡: 1 → Y)
```json
{
"mapping_type": "conditional",
"mapping_config": {
"rules": [
{ "when": "1", "then": "Y" },
{ "when": "0", "then": "N" }
],
"default": "?"
}
}
```
---
## 4. chpark 결정적 커밋 목록 (vexplor_rps)
```
2026-05-12 945b65b8 시퀀스 관리 메뉴 + 테이블 타입관리 코멘트/검증 + 설계 문서
2026-05-11 a61643c2 Merge origin/main
2026-05-08 57509869 배치 편집 conditional 매핑 평가 필드 UX 개선 ⭐
2026-05-08 40070423 ECR · 고객 CS · 결재 + Amaranth + wace_plm 데이터 import
2026-05-07 638543b3 Merge feature/rps-rebrand-pipeline-design ⭐
2026-05-07 97b333dd Amaranth(Wehago) ERP REST API 연계 + 배치 시스템 강화 ⭐
2026-04-30 9a8196a3 RPS 브랜딩 · COMPANY_16 단독 운영 · Pipeline 디자인 채용
```
### 결정적 커밋 분석
| 해시 | 일자 | 내용 |
|---|---|---|
| **97b333dd** | 5/7 | 배치 시스템 본체 강화 (conditional mapping, row_filter, UPSERT) + ERP 연계 |
| **638543b3** | 5/7 | `feature/rps-rebrand-pipeline-design` 브랜치 머지 (Pipeline 디자인 반영) |
| **57509869** | 5/8 | conditional 매핑의 "평가 필드" 필수 표기 UX 개선 (1파일 +27/13) |
**conditional 매핑 본체 구현은 97b333dd (5/7)**, 57509869 는 그 위에 UX 보완.
---
## 5. INVYONE 이식 작업
### 5-1. Backend (Spring 재작성)
| vexplor_rps (Node.js) | INVYONE (Spring) 대응 | 분량 |
|---|---|---|
| `batchSchedulerService.executeMapping()` | 신규 `BatchExecutor.java` (`@Service`, BaseService 상속) | ~2주 |
| 외부 DB 커넥터 | 이미 일부 있음 ([ExternalDbConnectionService](../../backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java)) | ~3일 |
| REST API 호출 | 이미 있음 ([ExternalRestApiConnectionService](../../backend-spring/src/main/java/com/erp/service/ExternalRestApiConnectionService.java)) | 통합만 |
| `evaluateConditional()` | `MappingTransformer.java` (Map 기반 lookup) | ~반나절 |
| Quartz 스케줄링 | INVYONE 에 이미 Quartz 도입됨 (`V012__create_quartz_tables.sql`) | 통합만 |
| ERP 사전 시드 / HMAC | 선택적 (Phase 4) | 1주+ |
### 5-2. Frontend
| 작업 | 위치 | 분량 |
|---|---|---|
| `ConditionalEditor` 컴포넌트 신규 | `components/admin/batch/ConditionalEditor.tsx` (신규) | 1일 |
| 매핑 row 에 `sourceType='conditional'` 옵션 + `mapping_config` 필드 | [`batchmngList/edit/[id]/page.tsx`](../../frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx) | 1일 |
| API 타입 확장 | [`lib/api/batch.ts`](../../frontend/lib/api/batch.ts) | 0.5일 |
### 5-3. DB 스키마 변경
| 컬럼 | 필요 여부 |
|---|---|
| `batch_mappings.mapping_type` | **이미 있음** (현재 'direct' 값만 사용) |
| `batch_mappings.mapping_config JSONB` | **추가 필요** (conditional_config 저장) |
| `batch_mappings.row_filter JSONB` | (선택) row 단위 필터링 룰. vexplor_rps 에 있음 |
마이그레이션: Flyway V021 + StartupSchemaMigrator 양쪽에.
### 5-4. INVYONE 측 선결과제 (Phase 0)
vexplor_rps 의 이식 외에도 INVYONE 자체의 **매핑 path 가 비어있는 문제** 가 별도. 이 Phase 0 가 없으면 conditional 룰을 만들어도 저장 안 됨.
| 작업 | 비고 |
|---|---|
| `batch_mappings` INSERT/UPDATE/DELETE 매퍼 작성 | 현재 0건 |
| `getBatchInfo` 응답에 batch_mappings 포함 (LEFT JOIN 또는 별도 query) | 현재 BATCH_CONFIGS 만 SELECT |
| `BatchManagementService.updateBatchConfig` 가 body 의 mappings 받아 batch_mappings 동기화 | 현재 silently drop |
| `executeBatchConfig``BatchExecutor` 호출 | 현재 stub, 0건 응답 |
---
## 6. Phase 분해 (재추정)
| Phase | 작업 | 분량 |
|---|---|---|
| **0** | INVYONE 의 `batch_mappings` CRUD path 구현 + GET 응답 포함 + PUT 동기화 | 3일 |
| **1** | `mapping_config JSONB` 컬럼 추가 (Flyway V021 + StartupSchemaMigrator) | 0.5일 |
| **2** | Frontend `ConditionalEditor` + sourceType='conditional' 분기 + API 타입 확장 | 2일 |
| **3** | Backend `MappingTransformer` (lookup 엔진) | 0.5일 |
| **4** | Backend `BatchExecutor` 작성 — vexplor_rps `executeMapping` 알고리즘 1:1 이식 (FROM→Transform→TO 3단계, 내부/외부 DB/REST 모두 지원) | 2주 |
| **5** | `executeBatchConfig``BatchExecutor` 호출, 실행 로그 (`batch_execution_logs`) 기록 | 3일 |
| **6** | (선택) ERP 사전 시드, HMAC 서명, 6종 배치 자동 부팅 | 1주+ |
**합계** (Phase 0~5): 약 **3~4주** (1인 기준, 테스트·QA 제외). Phase 6 는 별도.
---
## 7. 팀장님께 확인하면 좋을 항목
1. **vexplor_rps 의 `batchSchedulerService.ts` 알고리즘을 1:1 이식하는 게 맞나** — 아니면 더 단순화/확장된 버전을 원하시는지
2. **conditional 외에 다른 mapping_type 도 필요한가** — row_filter, expression eval 등
3. **ERP 시드 (Phase 6) 도 이번 스프린트 범위인가** — 아니면 Phase 0~5 만
4. **수집관리(`collection-managementList`) 와 배치관리(`batchmngList`) 가 같은 코드 공유해도 되는지** — UI 두 군데에 둘 다 노출인지
5. **`mapping_config` 컬럼명 선호** — 다른 컨벤션이 있다면
---
## 부록 A. 분석 시 사용한 주요 파일
### INVYONE 측
- [`BatchController.java`](../../backend-spring/src/main/java/com/erp/controller/BatchController.java)
- [`BatchManagementController.java`](../../backend-spring/src/main/java/com/erp/controller/BatchManagementController.java)
- [`BatchService.java`](../../backend-spring/src/main/java/com/erp/service/BatchService.java)
- [`BatchManagementService.java`](../../backend-spring/src/main/java/com/erp/service/BatchManagementService.java)
- [`CollectionService.java`](../../backend-spring/src/main/java/com/erp/service/CollectionService.java)
- [`NodeFlowService.java`](../../backend-spring/src/main/java/com/erp/service/NodeFlowService.java)
- [`mapper/batch.xml`](../../backend-spring/src/main/resources/mapper/batch.xml)
- [`mapper/collection.xml`](../../backend-spring/src/main/resources/mapper/collection.xml)
- [`mapper/batchManagement.xml`](../../backend-spring/src/main/resources/mapper/batchManagement.xml)
- [`mapper/nodeFlow.xml`](../../backend-spring/src/main/resources/mapper/nodeFlow.xml)
- [`frontend/app/(main)/admin/automaticMng/batchmngList/`](../../frontend/app/(main)/admin/automaticMng/batchmngList/)
- [`frontend/app/(main)/admin/systemMng/collection-managementList/`](../../frontend/app/(main)/admin/systemMng/collection-managementList/)
- [`frontend/app/(main)/admin/systemMng/dataflow/`](../../frontend/app/(main)/admin/systemMng/dataflow/)
- [`frontend/components/dataflow/DataFlowDesigner.tsx`](../../frontend/components/dataflow/DataFlowDesigner.tsx)
### vexplor_rps 측 (별도 저장소)
- `/Users/jhj/vexplor_rps/backend-node/src/services/batchSchedulerService.ts`
- `/Users/jhj/vexplor_rps/backend-node/src/services/batchManagementService.ts`
- `/Users/jhj/vexplor_rps/backend-node/src/services/erp*.ts`
- `/Users/jhj/vexplor_rps/frontend/app/(main)/admin/batch-management-new/page.tsx`
### 운영 DB
- `batch_configs`, `batch_mappings` (스키마 ok / 운영 데이터 ok / backend 처리 없음), `batch_execution_logs`, `node_flow`, `data_collection_configs`
---
## 부록 B. INVYONE batch_mappings 운영 데이터 현황
```text
batch_config_id | count
----------------+------
5 | 5
10 | 4
18 | 4
20 | 2
21 | 4
28 | 1
30 | 1
31 | 2
32 | 2
37 | 4
```
`created_date` 가 2025-09 등으로 오래된 행 다수. 현 backend 코드는 이 데이터를 읽지도 쓰지도 못함 — 사실상 dead data. vexplor_rps 시절 또는 옛 Node.js 시절의 흔적으로 추정.