Merge pull request 'style(rolesList): 다른 메뉴 톤에 맞춰 사이즈/글씨 축소' (#12) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m35s
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m35s
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -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
|
||||
|
||||
+7
@@ -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 >= #{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"));
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
id: `mapping-${index}-${Date.now()}`,
|
||||
dbColumn: mapping.to_column_name || "",
|
||||
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
|
||||
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
|
||||
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
|
||||
}));
|
||||
// mapping_type 분기:
|
||||
// "fixed" → from_column_name 자체가 고정값 → fixedValue
|
||||
// "conditional" → from_column_name 이 평가 필드명 → apiField + conditionalConfig
|
||||
// 그 외(direct) → from_column_name 이 API 필드명 → apiField
|
||||
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => {
|
||||
const mt = (mapping as any).mapping_type || "direct";
|
||||
const sourceType: MappingItem["sourceType"] =
|
||||
mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api";
|
||||
const conditionalConfig =
|
||||
sourceType === "conditional"
|
||||
? normalizeConditionalConfig((mapping as any).mapping_config)
|
||||
: undefined;
|
||||
return {
|
||||
id: `mapping-${index}-${Date.now()}`,
|
||||
dbColumn: mapping.to_column_name || "",
|
||||
sourceType,
|
||||
apiField: sourceType === "fixed" ? "" : mapping.from_column_name || "",
|
||||
fixedValue: sourceType === "fixed" ? mapping.from_column_name || "" : "",
|
||||
conditionalConfig,
|
||||
};
|
||||
});
|
||||
setMappingList(convertedMappingList);
|
||||
console.log("🔄 변환된 mappingList:", convertedMappingList);
|
||||
}
|
||||
@@ -651,7 +676,7 @@ export default function BatchEditPage() {
|
||||
nodeFlowContext: parsedContext,
|
||||
});
|
||||
toast.success("배치 설정이 저장되었습니다!");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
} catch (error) {
|
||||
console.error("배치 저장 실패:", error);
|
||||
toast.error("배치 저장에 실패했습니다.");
|
||||
@@ -679,26 +704,46 @@ export default function BatchEditPage() {
|
||||
const first = batchConfig.batch_mappings[0] as any;
|
||||
finalMappings = mappingList
|
||||
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
|
||||
.map((m, index) => ({
|
||||
// FROM: REST API (기존 설정 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
|
||||
from_column_type: m.sourceType === "fixed" ? "text" : "text",
|
||||
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
|
||||
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
|
||||
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
|
||||
// TO: DB (기존 설정 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: toTable || first.to_table_name,
|
||||
to_column_name: m.dbColumn,
|
||||
to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
|
||||
mapping_type: m.sourceType === "fixed" ? "fixed" : "direct",
|
||||
mapping_order: index + 1,
|
||||
})) as BatchMapping[];
|
||||
.map((m, index) => {
|
||||
// from_column_name 결정:
|
||||
// fixed → fixedValue 자체가 저장됨
|
||||
// conditional → apiField (평가할 API 필드)
|
||||
// direct(api) → apiField
|
||||
const fromColumnName =
|
||||
m.sourceType === "fixed" ? m.fixedValue : m.apiField;
|
||||
const mappingType: "direct" | "fixed" | "conditional" =
|
||||
m.sourceType === "fixed"
|
||||
? "fixed"
|
||||
: m.sourceType === "conditional"
|
||||
? "conditional"
|
||||
: "direct";
|
||||
return {
|
||||
// FROM: REST API (기존 설정 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: fromColumnName,
|
||||
from_column_type: "text",
|
||||
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
|
||||
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
|
||||
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
|
||||
// TO: DB (기존 설정 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: toTable || first.to_table_name,
|
||||
to_column_name: m.dbColumn,
|
||||
to_column_type:
|
||||
toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
|
||||
mapping_type: mappingType,
|
||||
// conditional 일 때만 룰 객체를 함께 전송. 백엔드가 JSONB 로 저장.
|
||||
mapping_config:
|
||||
m.sourceType === "conditional" && m.conditionalConfig
|
||||
? m.conditionalConfig
|
||||
: null,
|
||||
mapping_order: index + 1,
|
||||
};
|
||||
}) as BatchMapping[];
|
||||
}
|
||||
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
@@ -714,7 +759,7 @@ export default function BatchEditPage() {
|
||||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
@@ -724,7 +769,7 @@ export default function BatchEditPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
@@ -739,7 +784,7 @@ export default function BatchEditPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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,26 +563,45 @@ export default function BatchManagementNewPage() {
|
||||
|
||||
// 통합 매핑 리스트를 배치 매핑 형태로 변환
|
||||
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
|
||||
const apiMappings = validMappings.map((mapping) => ({
|
||||
from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용
|
||||
from_table_name: fromEndpoint,
|
||||
from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue,
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
||||
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
|
||||
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: mapping.dbColumn,
|
||||
mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const),
|
||||
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
|
||||
}));
|
||||
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: fromColumnName,
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
||||
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
|
||||
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: mapping.dbColumn,
|
||||
mapping_type: 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>
|
||||
|
||||
{/* 배치 타입 선택 */}
|
||||
<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 ${
|
||||
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>
|
||||
<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>
|
||||
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</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-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-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 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-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 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" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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 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 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>
|
||||
<Input
|
||||
id="fromApiUrl"
|
||||
value={fromApiUrl}
|
||||
onChange={(e) => setFromApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</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>
|
||||
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<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="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<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 className="text-xs">메서드</Label>
|
||||
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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="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="resultData (비우면 자동 탐색)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인증 토큰 — 라디오 + 입력을 한 행으로 압축 */}
|
||||
<div>
|
||||
<Label htmlFor="dataArrayPath">데이터 배열 경로</Label>
|
||||
<Input
|
||||
id="dataArrayPath"
|
||||
value={dataArrayPath}
|
||||
onChange={(e) => setDataArrayPath(e.target.value)}
|
||||
placeholder="response (예: data.items, results)"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
||||
<br />
|
||||
예시: response, data.items, result.list
|
||||
</p>
|
||||
<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>
|
||||
@@ -886,7 +1048,8 @@ export default function BatchManagementNewPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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>--></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><--</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> 의 값을 보고 J01→active 변환 시 평가 필드 ={" "}
|
||||
<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 : "",
|
||||
};
|
||||
}
|
||||
@@ -64,24 +64,43 @@ 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;
|
||||
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
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;
|
||||
|
||||
@@ -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 시절의 흔적으로 추정.
|
||||
Reference in New Issue
Block a user