Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m34s
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m34s
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.erp.constants;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class InputTypeConstants {
|
||||
private InputTypeConstants() {}
|
||||
|
||||
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
||||
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.erp.constants;
|
||||
|
||||
public enum InputTypeContext {
|
||||
USER_INSERT,
|
||||
USER_UPDATE_TYPE,
|
||||
USER_UPDATE_OTHER,
|
||||
SYSTEM_NORMALIZE
|
||||
}
|
||||
@@ -295,7 +295,8 @@ public class AdminController {
|
||||
@PostMapping("/users/reset-password")
|
||||
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
|
||||
String userId = (String) body.get("user_id");
|
||||
adminService.resetUserPassword(userId);
|
||||
String newPassword = (String) body.get("new_password");
|
||||
adminService.resetUserPassword(userId, newPassword);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -190,9 +190,11 @@ public class ApprovalController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getRequests(
|
||||
@RequestParam Map<String, Object> params,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
params.put("effective_user_ids", effectiveUserIds);
|
||||
return ResponseEntity.ok(ApiResponse.success(approvalService.getRequests(params)));
|
||||
}
|
||||
|
||||
@@ -277,10 +279,12 @@ public class ApprovalController {
|
||||
@GetMapping("/my-pending")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyPendingLines(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
params.put("effective_user_ids", effectiveUserIds);
|
||||
return ResponseEntity.ok(ApiResponse.success(approvalService.getMyPendingLines(params)));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,23 +18,32 @@ public class DepartmentController {
|
||||
|
||||
private final DepartmentService departmentService;
|
||||
|
||||
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
|
||||
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별).
|
||||
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
|
||||
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
|
||||
*/
|
||||
@GetMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
|
||||
@RequestParam(value = "base_date", required = false) String baseDate) {
|
||||
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
|
||||
}
|
||||
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -66,6 +75,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 생성
|
||||
* POST /api/departments/companies/{companyCode}/departments
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
|
||||
@@ -94,6 +104,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 수정
|
||||
* PUT /api/departments/{deptCode}
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PutMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.FavoritesService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/favorites")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FavoritesController {
|
||||
|
||||
private final FavoritesService favoritesService;
|
||||
|
||||
/**
|
||||
* GET /api/favorites/menus
|
||||
* 로그인 사용자의 즐겨찾기 메뉴 목록.
|
||||
*/
|
||||
@GetMapping("/menus")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyFavorites(
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
favoritesService.getFavoriteMenuList(params),
|
||||
"즐겨찾기 메뉴 조회 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites/menus
|
||||
* 즐겨찾기 추가. body: { menu_objid, sort_order? }
|
||||
*/
|
||||
@PostMapping("/menus")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addFavorite(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
Object menuObjid = body.get("menu_objid");
|
||||
if (menuObjid == null || String.valueOf(menuObjid).isBlank()) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error("menu_objid 필수입니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("menu_objid", String.valueOf(menuObjid));
|
||||
params.put("sort_order", body.get("sort_order"));
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
favoritesService.insertFavorite(params),
|
||||
"즐겨찾기 추가 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites/menus/{menuObjid}
|
||||
* 즐겨찾기 제거.
|
||||
*/
|
||||
@DeleteMapping("/menus/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> removeFavorite(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String menuObjid) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("menu_objid", menuObjid);
|
||||
int affected = favoritesService.deleteFavorite(params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("deleted", affected);
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "즐겨찾기 제거 성공"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.SubstituteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 관리 API.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T4)
|
||||
*
|
||||
* 정책:
|
||||
* - GET /mine 은 본인 read-only (누구나 가능)
|
||||
* - 나머지는 관리자(ADMIN/SUPER_ADMIN) 만 — Service 의 requireAdmin 이 2차 방어
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/substitutes")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SubstituteController {
|
||||
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 조회 — 관리자
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getList(
|
||||
@RequestParam Map<String, Object> params,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("role", role);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteList(params)));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getOne(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("substitute_id", substituteId);
|
||||
params.put("company_code", companyCode);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteInfo(params)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 본인 조회 — ProfileModal read-only
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/mine")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getMine(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 변경 — 관리자
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("role", role);
|
||||
body.put("created_by", userId);
|
||||
try {
|
||||
Map<String, Object> created = substituteService.insertSubstitute(body);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(created, "대무자가 지정되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 등록 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 등록 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> update(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
body.put("substitute_id", substituteId);
|
||||
body.put("company_code", companyCode);
|
||||
body.put("role", role);
|
||||
body.put("updated_by", userId);
|
||||
try {
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(substituteService.updateSubstitute(body), "대무 설정이 수정되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 수정 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 수정 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> delete(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("substitute_id", substituteId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("role", role);
|
||||
try {
|
||||
substituteService.deleteSubstitute(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "대무 설정이 해지되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 해지 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 해지 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 사전 검증 — UI 가 등록 직전 호출
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/check-overlap")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkOverlap(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
body.put("company_code", companyCode);
|
||||
int cnt = substituteService.checkOverlap(body);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("overlap", cnt > 0);
|
||||
result.put("count", cnt);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/label")
|
||||
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String displayName = (String) body.get("display_name");
|
||||
String description = (String) body.get("description");
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
@@ -105,7 +109,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -115,7 +123,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -136,7 +148,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -145,7 +161,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -166,7 +186,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String webType = (String) body.get("web_type");
|
||||
if (webType == null || webType.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
|
||||
@@ -183,7 +207,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String inputType = (String) body.get("input_type");
|
||||
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
|
||||
@@ -241,7 +269,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/primary-key")
|
||||
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> columns = (List<String>) body.get("columns");
|
||||
if (tableName == null || columns == null || columns.isEmpty()) {
|
||||
@@ -256,7 +288,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/indexes")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
String columnName = (String) body.get("column_name");
|
||||
String indexType = (String) body.get("index_type");
|
||||
String action = (String) body.get("action");
|
||||
@@ -281,7 +317,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object nullableObj = body.get("nullable");
|
||||
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
|
||||
@@ -299,7 +339,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object uniqueObj = body.get("unique");
|
||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
|
||||
@@ -417,7 +461,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> data,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (data == null || data.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
|
||||
}
|
||||
@@ -450,7 +498,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> editTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -484,7 +536,11 @@ public class TableManagementController {
|
||||
@DeleteMapping("/tables/{tableName}/delete")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Object body) {
|
||||
@RequestBody Object body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
List<Map<String, Object>> dataList;
|
||||
if (body instanceof List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -508,7 +564,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log")
|
||||
public ResponseEntity<ApiResponse<Void>> createLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
@@ -538,7 +598,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log/toggle")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
tableManagementService.toggleLogTable(tableName, isActive);
|
||||
return ResponseEntity.ok(ApiResponse.success(null,
|
||||
@@ -595,7 +659,11 @@ public class TableManagementController {
|
||||
@PostMapping("/multi-table-save")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
|
||||
@RequestBody Map<String, Object> payload,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.multiTableSave(payload, companyCode),
|
||||
"다중 테이블 저장이 완료되었습니다."));
|
||||
@@ -626,4 +694,16 @@ public class TableManagementController {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 권한 헬퍼
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isAdmin(String role) {
|
||||
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(String roleOrCode) {
|
||||
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,121 @@ public class StartupSchemaMigrator {
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE"
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE",
|
||||
|
||||
// V020: 사용자별 메뉴 즐겨찾기 테이블.
|
||||
// 메타 DB 는 Flyway V020 으로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
// CREATE IF NOT EXISTS 로 멱등성 보장.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
|
||||
OBJID BIGSERIAL PRIMARY KEY,
|
||||
USER_ID VARCHAR(100) NOT NULL,
|
||||
MENU_OBJID VARCHAR(50) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)",
|
||||
|
||||
// RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성
|
||||
"CREATE EXTENSION IF NOT EXISTS btree_gist",
|
||||
|
||||
// RUN_086 (2) 대무자(代務者) 관리 테이블
|
||||
// self-위임 차단 (CHECK), 같은 쌍 활성 기간 겹침 차단 (EXCLUDE).
|
||||
// 재실행 시 IF NOT EXISTS 로 안전. EXCLUDE/CHECK 제약은 첫 생성 때만 적용.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES (
|
||||
SUBSTITUTE_ID BIGSERIAL PRIMARY KEY,
|
||||
COMPANY_CODE VARCHAR(50) NOT NULL,
|
||||
ORIGINAL_USER_ID VARCHAR(50) NOT NULL,
|
||||
PROXY_USER_ID VARCHAR(50) NOT NULL,
|
||||
START_DATE DATE NULL,
|
||||
END_DATE DATE NOT NULL,
|
||||
REASON VARCHAR(500),
|
||||
IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CREATED_BY VARCHAR(50),
|
||||
CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UPDATED_BY VARCHAR(50),
|
||||
UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_user_substitutes_self
|
||||
CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID),
|
||||
CONSTRAINT chk_user_substitutes_date
|
||||
CHECK (START_DATE IS NULL OR START_DATE <= END_DATE),
|
||||
CONSTRAINT excl_user_substitutes_overlap
|
||||
EXCLUDE USING gist (
|
||||
COMPANY_CODE WITH =,
|
||||
ORIGINAL_USER_ID WITH =,
|
||||
PROXY_USER_ID WITH =,
|
||||
daterange(START_DATE, END_DATE, '[]') WITH &&
|
||||
) WHERE (IS_ACTIVE = TRUE)
|
||||
)
|
||||
""",
|
||||
|
||||
// RUN_086 (3) USER_SUBSTITUTES 인덱스 — Filter 핫패스 + 조회 가속
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_original ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)",
|
||||
|
||||
// RUN_086 (4) SYSTEM_AUDIT_LOG — 처리자(actual processor) 분리 기록 컬럼
|
||||
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50)",
|
||||
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100)",
|
||||
|
||||
// RUN_086 (5) APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 데이터 복사 (idempotent)
|
||||
// 기존 운영 데이터 보존 + 어댑터 read 경로가 즉시 동작하도록.
|
||||
// IS_ACTIVE 매핑: APPROVAL_PROXY_SETTINGS 의 CHAR('Y'/'N') → BOOLEAN.
|
||||
// 메타데이터(created/updated) 는 원본 컬럼 의존 없이 'migration_086' + NOW() 로 고정
|
||||
// (APPROVAL_PROXY_SETTINGS 의 timestamp 컬럼명이 환경별로 다를 수 있어 안전한 default 채택).
|
||||
"""
|
||||
INSERT INTO USER_SUBSTITUTES (
|
||||
COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID,
|
||||
START_DATE, END_DATE, REASON, IS_ACTIVE,
|
||||
CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
|
||||
)
|
||||
SELECT
|
||||
p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID,
|
||||
CAST(NULLIF(p.START_DATE, '') AS DATE),
|
||||
CAST(NULLIF(p.END_DATE, '') AS DATE),
|
||||
p.REASON,
|
||||
CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END,
|
||||
'migration_086', NOW(),
|
||||
'migration_086', NOW()
|
||||
FROM APPROVAL_PROXY_SETTINGS p
|
||||
WHERE NULLIF(p.END_DATE, '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM USER_SUBSTITUTES s
|
||||
WHERE s.COMPANY_CODE = p.COMPANY_CODE
|
||||
AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID
|
||||
AND s.PROXY_USER_ID = p.PROXY_USER_ID
|
||||
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",
|
||||
|
||||
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
|
||||
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
|
||||
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
|
||||
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -112,9 +226,18 @@ public class StartupSchemaMigrator {
|
||||
}
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
List<String> failedDbs = new java.util.ArrayList<>();
|
||||
for (String db : tenantDbs) {
|
||||
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
|
||||
if (applyTo(db, "tenant")) ok++; else fail++;
|
||||
if (applyTo(db, "tenant")) {
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
failedDbs.add(db);
|
||||
}
|
||||
}
|
||||
if (!failedDbs.isEmpty()) {
|
||||
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
|
||||
}
|
||||
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.erp.security;
|
||||
|
||||
import com.erp.ai.security.AiApiKeyAuthFilter;
|
||||
import com.erp.ai.service.AiAgentApiKeyService;
|
||||
import com.erp.service.SubstituteService;
|
||||
import com.erp.tenant.CompanyResolver;
|
||||
import com.erp.tenant.SubdomainResolverFilter;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
@@ -37,6 +38,7 @@ public class SecurityConfig {
|
||||
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
private final AiAgentApiKeyService aiAgentApiKeyService;
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
/**
|
||||
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com").
|
||||
@@ -76,9 +78,12 @@ public class SecurityConfig {
|
||||
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
|
||||
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
|
||||
JwtAuthenticationFilter.class)
|
||||
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
|
||||
// TenantConsistencyGuardFilter 뒤 — 대무자(代務者) 컨텍스트 effective_user_ids 주입.
|
||||
.addFilterAfter(new SubstituteContextFilter(substituteService),
|
||||
TenantConsistencyGuardFilter.class)
|
||||
// SubstituteContextFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
|
||||
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
|
||||
TenantConsistencyGuardFilter.class);
|
||||
SubstituteContextFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.erp.security;
|
||||
|
||||
import com.erp.service.SubstituteService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 컨텍스트 주입 필터.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T5)
|
||||
*
|
||||
* 동작:
|
||||
* 1. /api/** 가 아니면 통과
|
||||
* 2. JWT user_id / company_code attribute 없으면 통과 (비로그인)
|
||||
* 3. company_code == "*" (SUPER_ADMIN pre-switch) 이면 통과 — 대무 의미 없음
|
||||
* 4. substituteService.getActiveOriginalUserIds(userId, companyCode) 조회
|
||||
* 5. effective_user_ids = [userId, ...originalIds] → request attribute
|
||||
* 6. actual_processor_id = userId → request attribute (의미 명시 alias)
|
||||
*
|
||||
* 예외 처리:
|
||||
* DB 조회 실패 시 effective_user_ids 를 [userId] 만 담아 통과시킨다 — 대무 컨텍스트
|
||||
* 실패가 본 요청을 깨면 안 되기 때문 (가용성 우선). warn 로그 남김.
|
||||
*
|
||||
* 성능:
|
||||
* - request 당 SELECT 1회 (인덱스 (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE) 매치, 보통 <1ms)
|
||||
* - request-scope 자연 캐시 — 한 요청 내에서 attribute 만 참조하면 추가 조회 없음
|
||||
*
|
||||
* 필터 순서:
|
||||
* SubdomainResolver → AiApiKey → Jwt → TenantConsistencyGuard → **여기** → ForcePasswordChangeGuard
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String ATTR_EFFECTIVE_USER_IDS = "effective_user_ids";
|
||||
public static final String ATTR_ACTUAL_PROCESSOR_ID = "actual_processor_id";
|
||||
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
if (path == null || !path.startsWith("/api/")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
String companyCode = (String) request.getAttribute("company_code");
|
||||
|
||||
// 비로그인 또는 SUPER_ADMIN pre-switch → 대무 컨텍스트 의미 없음, 통과
|
||||
if (userId == null || companyCode == null || "*".equals(companyCode)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> effectiveIds = new ArrayList<>();
|
||||
effectiveIds.add(userId);
|
||||
|
||||
try {
|
||||
List<String> originalIds = substituteService.getActiveOriginalUserIds(userId, companyCode);
|
||||
if (originalIds != null && !originalIds.isEmpty()) {
|
||||
effectiveIds.addAll(originalIds);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 대무 컨텍스트 조회 실패는 본 요청을 막지 않음 — 본인 권한만으로 동작
|
||||
log.warn("[SubstituteContext] failed to resolve proxy context for user={}: {}",
|
||||
userId, e.getMessage());
|
||||
}
|
||||
|
||||
request.setAttribute(ATTR_EFFECTIVE_USER_IDS, effectiveIds);
|
||||
request.setAttribute(ATTR_ACTUAL_PROCESSOR_ID, userId);
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -208,10 +208,17 @@ public class AdminService extends BaseService {
|
||||
}
|
||||
|
||||
public void resetUserPassword(String userId) {
|
||||
String defaultPw = passwordEncoder.encode("Welcome1!");
|
||||
resetUserPassword(userId, null);
|
||||
}
|
||||
|
||||
public void resetUserPassword(String userId, String newPassword) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("user_id 는 필수입니다");
|
||||
}
|
||||
String rawPw = (newPassword != null && !newPassword.isBlank()) ? newPassword : "Welcome1!";
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("user_password", defaultPw);
|
||||
params.put("user_password", passwordEncoder.encode(rawPw));
|
||||
sqlSession.update("admin.updateUserPassword", params);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,26 @@ public class ApprovalService extends BaseService {
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* IN (:effective_user_ids) 쿼리용 fallback.
|
||||
* SubstituteContextFilter 가 attribute 를 못 채운 경로(통합 테스트/배치 등) 에서도
|
||||
* 빈 IN () SQL 에러를 막기 위해 항상 최소 [user_id] 가 들어가도록 한다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void ensureEffectiveUserIds(Map<String, Object> params) {
|
||||
Object v = params.get("effective_user_ids");
|
||||
boolean empty = v == null || (v instanceof Collection<?> && ((Collection<?>) v).isEmpty());
|
||||
if (empty) {
|
||||
Object userId = params.get("user_id");
|
||||
if (userId != null) {
|
||||
params.put("effective_user_ids", List.of(userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// approval_definitions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -149,6 +169,7 @@ public class ApprovalService extends BaseService {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getRequests(Map<String, Object> params) {
|
||||
ensureEffectiveUserIds(params);
|
||||
int page = toInt(params.getOrDefault("page", "1"));
|
||||
int limit = toInt(params.getOrDefault("limit", "20"));
|
||||
params.put("page_limit", limit);
|
||||
@@ -359,6 +380,7 @@ public class ApprovalService extends BaseService {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getMyPendingLines(Map<String, Object> params) {
|
||||
ensureEffectiveUserIds(params);
|
||||
return sqlSession.selectList("approval.selectMyPendingLines", params);
|
||||
}
|
||||
|
||||
@@ -456,6 +478,24 @@ public class ApprovalService extends BaseService {
|
||||
activateNextStep(requestId, stepOrder, totalSteps, lineCC, userId, comment);
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 처리 audit log — 대무 시 user_id(A)와 processor_id(B) 분리 기록.
|
||||
// 실패는 본 처리를 막지 않음 (가용성 우선).
|
||||
try {
|
||||
Map<String, Object> auditP = new HashMap<>();
|
||||
auditP.put("company_code", lineCC);
|
||||
auditP.put("user_id", approverId); // 위임자 A
|
||||
auditP.put("user_name", line.get("approver_name"));
|
||||
auditP.put("processor_id", userId); // 실제 처리자 B
|
||||
// processor_name 은 AuditLogService 가 USER_INFO 에서 lookup (T14)
|
||||
auditP.put("action", "approval." + action);
|
||||
auditP.put("resource_type", "approval_line");
|
||||
auditP.put("resource_id", String.valueOf(lineId));
|
||||
auditP.put("summary", "결재 " + action + (proxyFor != null ? " (대무)" : ""));
|
||||
auditLogService.insertAuditLog(auditP);
|
||||
} catch (Exception e) {
|
||||
log.warn("결재 audit log 기록 실패 (line={}): {}", lineId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -73,7 +73,12 @@ public class AuditLogService extends BaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 로그 1건 기록
|
||||
* 감사 로그 1건 기록.
|
||||
*
|
||||
* PROCESSOR 처리 (대무 추적):
|
||||
* - processor_id 미지정 → user_id 로 채움 (평시 = 동일 = 본인 처리)
|
||||
* - processor_id 가 user_id 와 다르고 processor_name 미지정 → USER_INFO 에서 lookup
|
||||
* (대무 이벤트만 USER_INFO 단건 조회 1회 — 평시는 추가 DB 호출 없음)
|
||||
*/
|
||||
public void insertAuditLog(Map<String, Object> params) {
|
||||
// changes가 Map이면 JSON 문자열로 직렬화
|
||||
@@ -86,6 +91,26 @@ public class AuditLogService extends BaseService {
|
||||
params.put("changes", null);
|
||||
}
|
||||
}
|
||||
|
||||
Object processorId = params.get("processor_id");
|
||||
Object userId = params.get("user_id");
|
||||
if (processorId == null) {
|
||||
params.put("processor_id", userId);
|
||||
if (params.get("processor_name") == null) {
|
||||
params.put("processor_name", params.get("user_name"));
|
||||
}
|
||||
} else if (!processorId.equals(userId) && params.get("processor_name") == null) {
|
||||
try {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", processorId);
|
||||
p.put("company_code", params.get("company_code"));
|
||||
String name = sqlSession.selectOne("auditLog.selectUserNameById", p);
|
||||
params.put("processor_name", name);
|
||||
} catch (Exception e) {
|
||||
log.warn("processor_name lookup 실패 (processor_id={}): {}", processorId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
sqlSession.insert("auditLog.insertAuditLog", params);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.erp.constants.InputTypeConstants;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -140,6 +141,12 @@ public class DdlService extends BaseService {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
String inputType = convertToInputType(column);
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
String detailSettings = column.containsKey("detail_settings")
|
||||
? column.get("detail_settings").toString() : "{}";
|
||||
Integer maxOrder = jdbcTemplate.queryForObject(
|
||||
@@ -408,10 +415,17 @@ public class DdlService extends BaseService {
|
||||
// 사용자 정의 컬럼
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
Map<String, Object> col = columns.get(i);
|
||||
String inputType = convertToInputType(col);
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
String detailSettings = col.containsKey("detail_settings")
|
||||
? col.get("detail_settings").toString() : "{}";
|
||||
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
|
||||
convertToInputType(col), detailSettings, i);
|
||||
inputType, detailSettings, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +527,9 @@ public class DdlService extends BaseService {
|
||||
case "radio" -> "radio";
|
||||
case "code" -> "code";
|
||||
case "entity" -> "entity";
|
||||
case "file" -> "file";
|
||||
case "image" -> "image";
|
||||
case "numbering" -> "numbering";
|
||||
default -> "text";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,17 +20,22 @@ public class DepartmentService extends BaseService {
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getDepartments(String companyCode) {
|
||||
return getDepartments(companyCode, false);
|
||||
return getDepartments(companyCode, false, null);
|
||||
}
|
||||
|
||||
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
|
||||
return getDepartments(companyCode, includeDeleted, null);
|
||||
}
|
||||
|
||||
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("include_deleted", includeDeleted);
|
||||
params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip
|
||||
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
|
||||
|
||||
// member_count를 int로 변환
|
||||
for (Map<String, Object> dept : departments) {
|
||||
Object cnt = dept.get("member_count");
|
||||
if (cnt != null) {
|
||||
@@ -38,6 +43,10 @@ public class DepartmentService extends BaseService {
|
||||
} else {
|
||||
dept.put("member_count", 0);
|
||||
}
|
||||
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return departments;
|
||||
}
|
||||
@@ -46,14 +55,26 @@ public class DepartmentService extends BaseService {
|
||||
public Map<String, Object> getDepartment(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
|
||||
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -129,11 +150,15 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
sqlSession.insert("department.insertDepartment", insertParams);
|
||||
|
||||
syncManagers(deptCode, companyCode, body, "approval");
|
||||
syncManagers(deptCode, companyCode, body, "dept");
|
||||
syncManagers(deptCode, companyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
||||
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -196,10 +221,12 @@ public class DepartmentService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncManagers(deptCode, deptCompanyCode, body, "approval");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "dept");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 수정 성공: deptCode={}", deptCode);
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,6 +499,108 @@ public class DepartmentService extends BaseService {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── 관리자 매핑 sync ────────────────────────────────
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
|
||||
|
||||
private void parseManagersJson(Map<String, Object> dept, String key) {
|
||||
Object raw = dept.get(key);
|
||||
if (raw == null) {
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
String s = raw.toString();
|
||||
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
|
||||
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
|
||||
dept.get("dept_code"), key, s.length());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
dept.put(key, parsed);
|
||||
} catch (Exception e) {
|
||||
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
|
||||
dept.get("dept_code"), key, e.getMessage());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴.
|
||||
* body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders".
|
||||
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
||||
* 최대 10명 검증 + 빈 user_id 무시.
|
||||
*/
|
||||
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
|
||||
String bodyKey = switch (role) {
|
||||
case "approval" -> "approval_managers";
|
||||
case "dept" -> "dept_managers";
|
||||
case "org_leader" -> "org_leaders";
|
||||
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
||||
};
|
||||
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
|
||||
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
|
||||
if (!body.containsKey(bodyKey)) {
|
||||
return;
|
||||
}
|
||||
Object raw = body.get(bodyKey);
|
||||
java.util.List<String> userIds = new java.util.ArrayList<>();
|
||||
if (raw instanceof java.util.List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
|
||||
userIds.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userIds.size() > 10) {
|
||||
String roleLabel = switch (role) {
|
||||
case "approval" -> "결재 관리자";
|
||||
case "dept" -> "부서 관리자";
|
||||
case "org_leader" -> "조직장";
|
||||
default -> role;
|
||||
};
|
||||
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
|
||||
}
|
||||
// user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", userIds);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (validUserIds == null || validUserIds.size() != userIds.size()) {
|
||||
Set<String> invalid = new HashSet<>(userIds);
|
||||
if (validUserIds != null) invalid.removeAll(validUserIds);
|
||||
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
|
||||
}
|
||||
}
|
||||
// delete-all
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("dept_code", deptCode);
|
||||
delParams.put("role", role);
|
||||
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
|
||||
// insert-all
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> insParams = new HashMap<>();
|
||||
insParams.put("dept_code", deptCode);
|
||||
insParams.put("role", role);
|
||||
insParams.put("user_ids", userIds);
|
||||
sqlSession.insert("department.insertDeptManagers", insParams);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 중복 예외 클래스 ────────────────────────────────
|
||||
|
||||
public static class DuplicateDeptNameException extends RuntimeException {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class FavoritesService extends BaseService {
|
||||
|
||||
public List<Map<String, Object>> getFavoriteMenuList(Map<String, Object> params) {
|
||||
return sqlSession.selectList("favorites.selectFavoriteMenuList", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertFavorite(Map<String, Object> params) {
|
||||
sqlSession.insert("favorites.insertFavorite", params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("user_id", params.get("user_id"));
|
||||
result.put("menu_objid", params.get("menu_objid"));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteFavorite(Map<String, Object> params) {
|
||||
return sqlSession.delete("favorites.deleteFavorite", params);
|
||||
}
|
||||
|
||||
public boolean exists(Map<String, Object> params) {
|
||||
Integer cnt = sqlSession.selectOne("favorites.selectFavoriteExists", params);
|
||||
return cnt != null && cnt > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 관리 서비스.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T3)
|
||||
*
|
||||
* 핵심 규칙:
|
||||
* - 관리자만 위임 지정/수정/해지. 본인 self-위임 불가.
|
||||
* - 종료일 필수, 시작일 옵션 (비우면 즉시).
|
||||
* - 같은 (COMPANY, ORIGINAL, PROXY) 쌍의 활성 기간 겹침 금지 (DB EXCLUDE + 사전 검증).
|
||||
* - 같은 회사 사용자끼리만. SUPER_ADMIN 은 대무자로 지정 불가.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SubstituteService extends BaseService {
|
||||
|
||||
private static final String NS = "substitute.";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 조회
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> getSubstituteList(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "selectSubstituteList", params);
|
||||
Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", list);
|
||||
result.put("total", total == null ? 0 : total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileModal read-only: 내가 위임한(proxying_for_me) + 나를 대무 중인(my_proxies) 두 방향 한 번에.
|
||||
* 결과를 Java 단에서 partition.
|
||||
*/
|
||||
public Map<String, Object> getMySubstitutes(Map<String, Object> params) {
|
||||
if (params.get("user_id") == null) {
|
||||
throw new IllegalArgumentException("user_id 가 필요합니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectMySubstitutes", params);
|
||||
|
||||
List<Map<String, Object>> proxyingForMe = new ArrayList<>();
|
||||
List<Map<String, Object>> myProxies = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object relation = row.get("relation");
|
||||
if ("proxying_for_me".equals(relation)) {
|
||||
proxyingForMe.add(row);
|
||||
} else if ("my_proxies".equals(relation)) {
|
||||
myProxies.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("proxying_for_me", proxyingForMe);
|
||||
result.put("my_proxies", myProxies);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubstituteContextFilter 핫 패스. 트랜잭션 없이 가볍게.
|
||||
* 반환: B 가 현재 대무 중인 A 의 ID 목록 (없으면 빈 리스트).
|
||||
*/
|
||||
public List<String> getActiveOriginalUserIds(String proxyUserId, String companyCode) {
|
||||
if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("proxy_user_id", proxyUserId);
|
||||
p.put("company_code", companyCode);
|
||||
List<String> ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p);
|
||||
return ids == null ? List.of() : ids;
|
||||
}
|
||||
|
||||
public Map<String, Object> getSubstituteInfo(Map<String, Object> params) {
|
||||
Map<String, Object> row = sqlSession.selectOne(NS + "selectSubstituteInfo", params);
|
||||
if (row == null) {
|
||||
throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다.");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* ApprovalService 어댑터: B 가 A 의 대무자로 활성 상태인지 검증.
|
||||
* 결재 처리 중 호출.
|
||||
*/
|
||||
public Map<String, Object> getActiveProxyForLine(Map<String, Object> params) {
|
||||
return sqlSession.selectOne(NS + "selectActiveProxyForLine", params);
|
||||
}
|
||||
|
||||
public int checkOverlap(Map<String, Object> params) {
|
||||
Integer cnt = sqlSession.selectOne(NS + "countOverlap", params);
|
||||
return cnt == null ? 0 : cnt;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 변경
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> insertSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
validateInsertParams(params);
|
||||
|
||||
sqlSession.insert(NS + "insertSubstitute", params);
|
||||
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
info.put("substitute_id", params.get("substitute_id"));
|
||||
info.put("company_code", params.get("company_code"));
|
||||
return getSubstituteInfo(info);
|
||||
}
|
||||
|
||||
public Map<String, Object> updateSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
|
||||
Map<String, Object> existing = getSubstituteInfo(params);
|
||||
|
||||
// 변경되는 사용자 ID 가 있으면 회사 소속 + SUPER_ADMIN 검증
|
||||
Object newProxy = params.get("proxy_user_id");
|
||||
if (newProxy != null && !newProxy.equals(existing.get("proxy_user_id"))) {
|
||||
validateUserInCompany((String) newProxy, (String) params.get("company_code"), "proxy");
|
||||
rejectSuperAdminAsProxy((String) newProxy);
|
||||
}
|
||||
|
||||
// 기간/대무자 변경 시 겹침 재검증
|
||||
if (params.get("start_date") != null || params.get("end_date") != null
|
||||
|| params.get("clear_start_date") != null || newProxy != null) {
|
||||
Map<String, Object> overlapParams = new HashMap<>();
|
||||
overlapParams.put("company_code", params.get("company_code"));
|
||||
overlapParams.put("original_user_id", existing.get("original_user_id"));
|
||||
overlapParams.put("proxy_user_id",
|
||||
newProxy != null ? newProxy : existing.get("proxy_user_id"));
|
||||
overlapParams.put("start_date",
|
||||
Boolean.TRUE.equals(params.get("clear_start_date")) ? null
|
||||
: (params.get("start_date") != null ? params.get("start_date")
|
||||
: existing.get("start_date")));
|
||||
overlapParams.put("end_date",
|
||||
params.get("end_date") != null ? params.get("end_date")
|
||||
: existing.get("end_date"));
|
||||
overlapParams.put("exclude_substitute_id", params.get("substitute_id"));
|
||||
if (checkOverlap(overlapParams) > 0) {
|
||||
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateSubstitute", params);
|
||||
if (updated == 0) {
|
||||
throw new IllegalArgumentException("대무 설정 수정에 실패했습니다.");
|
||||
}
|
||||
return getSubstituteInfo(params);
|
||||
}
|
||||
|
||||
public void deleteSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
getSubstituteInfo(params); // 존재 확인
|
||||
sqlSession.delete(NS + "deleteSubstitute", params);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 검증
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void validateInsertParams(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String original = (String) params.get("original_user_id");
|
||||
String proxy = (String) params.get("proxy_user_id");
|
||||
Object endDate = params.get("end_date");
|
||||
|
||||
if (companyCode == null || companyCode.isBlank()) {
|
||||
throw new IllegalArgumentException("회사 코드가 필요합니다.");
|
||||
}
|
||||
if (original == null || original.isBlank()) {
|
||||
throw new IllegalArgumentException("위임자(대상 사용자) 가 필요합니다.");
|
||||
}
|
||||
if (proxy == null || proxy.isBlank()) {
|
||||
throw new IllegalArgumentException("대무자가 필요합니다.");
|
||||
}
|
||||
if (original.equals(proxy)) {
|
||||
throw new IllegalArgumentException("본인을 자기 대무자로 지정할 수 없습니다.");
|
||||
}
|
||||
if (endDate == null || (endDate instanceof String && ((String) endDate).isBlank())) {
|
||||
throw new IllegalArgumentException("종료일은 필수입니다 (무기한 대무 금지).");
|
||||
}
|
||||
|
||||
// B3: 같은 회사 소속 검증
|
||||
validateUserInCompany(original, companyCode, "original");
|
||||
validateUserInCompany(proxy, companyCode, "proxy");
|
||||
|
||||
// SUPER_ADMIN 을 대무자로 지정 금지
|
||||
rejectSuperAdminAsProxy(proxy);
|
||||
|
||||
// 사전 겹침 검증
|
||||
Map<String, Object> overlapParams = new HashMap<>();
|
||||
overlapParams.put("company_code", companyCode);
|
||||
overlapParams.put("original_user_id", original);
|
||||
overlapParams.put("proxy_user_id", proxy);
|
||||
overlapParams.put("start_date", params.get("start_date"));
|
||||
overlapParams.put("end_date", endDate);
|
||||
if (checkOverlap(overlapParams) > 0) {
|
||||
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUserInCompany(String userId, String companyCode, String which) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", userId);
|
||||
p.put("company_code", companyCode);
|
||||
Integer cnt = sqlSession.selectOne(NS + "countUserInCompany", p);
|
||||
if (cnt == null || cnt == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"original".equals(which)
|
||||
? "대상 사용자가 회사에 존재하지 않습니다."
|
||||
: "대무자가 회사에 존재하지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void rejectSuperAdminAsProxy(String userId) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", userId);
|
||||
Integer cnt = sqlSession.selectOne(NS + "countSuperAdmin", p);
|
||||
if (cnt != null && cnt > 0) {
|
||||
throw new IllegalArgumentException("SUPER_ADMIN 은 대무자로 지정할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdmin(Map<String, Object> params) {
|
||||
String role = (String) params.get("role");
|
||||
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.erp.constants.InputTypeConstants;
|
||||
import com.erp.constants.InputTypeContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -26,6 +28,16 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
private static final String NS = "tableManagement.";
|
||||
|
||||
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
|
||||
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
|
||||
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
|
||||
"varchar", "text", "char", "character", "character varying",
|
||||
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
|
||||
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
|
||||
"time", "time without time zone", "time with time zone",
|
||||
"uuid", "json", "jsonb", "bytea"
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 테이블 목록
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -145,7 +157,12 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> settings, String companyCode) {
|
||||
ensureTableInLabels(tableName);
|
||||
|
||||
String inputType = normalizeInputType((String) settings.get("input_type"));
|
||||
Object rawInputType = settings.get("input_type");
|
||||
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
|
||||
InputTypeContext ctx = inputTypeChanged
|
||||
? InputTypeContext.USER_UPDATE_TYPE
|
||||
: InputTypeContext.USER_UPDATE_OTHER;
|
||||
String inputType = normalizeInputType((String) rawInputType, ctx);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
@@ -202,7 +219,7 @@ public class TableManagementService extends BaseService {
|
||||
public void updateColumnInputType(String tableName, String columnName,
|
||||
String inputType, String companyCode,
|
||||
Map<String, Object> detailSettings) {
|
||||
String finalType = normalizeInputType(inputType);
|
||||
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
@@ -966,9 +983,14 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
|
||||
String logTableName = tableName + "_log";
|
||||
String safeLog = sanitize(logTableName);
|
||||
String safeOrig = sanitize(tableName);
|
||||
if (safeOrig.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
|
||||
}
|
||||
String safeLog = sanitize(safeOrig + "_log");
|
||||
if (safeLog.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
|
||||
}
|
||||
|
||||
// 원본 테이블 컬럼 정보 조회
|
||||
Map<String, String> colTypes = getColumnTypes(safeOrig);
|
||||
@@ -980,13 +1002,32 @@ public class TableManagementService extends BaseService {
|
||||
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
|
||||
colDefs.add("log_user VARCHAR(100)");
|
||||
|
||||
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
|
||||
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns
|
||||
: new ArrayList<>(colTypes.keySet());
|
||||
|
||||
for (String col : targetCols) {
|
||||
String type = colTypes.getOrDefault(col, "TEXT");
|
||||
colDefs.add(String.format("\"%s\" %s", col, type));
|
||||
// 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
|
||||
List<String> persistedCols = new ArrayList<>();
|
||||
for (String col : requestedCols) {
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank()) continue; // sanitize 결과 빈 식별자 차단
|
||||
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
|
||||
|
||||
String rawType = colTypes.get(col);
|
||||
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
|
||||
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
|
||||
// 알 수 없는 type 은 text 로 fallback (안전 default)
|
||||
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
|
||||
safeOrig, safeCol, rawType);
|
||||
normalized = "text";
|
||||
}
|
||||
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
|
||||
persistedCols.add(safeCol);
|
||||
}
|
||||
|
||||
if (persistedCols.isEmpty()) {
|
||||
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
|
||||
}
|
||||
|
||||
String createSql = String.format(
|
||||
@@ -997,7 +1038,7 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("is_active", isActive);
|
||||
params.put("log_columns", String.join(",", targetCols));
|
||||
params.put("log_columns", String.join(",", persistedCols));
|
||||
sqlSession.update(NS + "upsertLogConfig", params);
|
||||
|
||||
log.info("로그 테이블 생성: {}", safeLog);
|
||||
@@ -1216,7 +1257,7 @@ public class TableManagementService extends BaseService {
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
}
|
||||
|
||||
/** "direct" / "auto" → "text" 변환 */
|
||||
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||
private String normalizeInputType(String inputType) {
|
||||
if ("direct".equals(inputType) || "auto".equals(inputType)) {
|
||||
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
|
||||
@@ -1225,6 +1266,23 @@ public class TableManagementService extends BaseService {
|
||||
return inputType != null ? inputType : "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
||||
*/
|
||||
private String normalizeInputType(String value, InputTypeContext context) {
|
||||
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
|
||||
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + value + ")"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
|
||||
return normalizeInputType(value);
|
||||
}
|
||||
|
||||
private String toJsonString(Object obj) {
|
||||
if (obj == null) return "{}";
|
||||
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- V020: 사용자별 메뉴 즐겨찾기 테이블
|
||||
-- 로그인 사용자가 사이드바 메뉴 항목을 즐겨찾기에 등록/해제하면 한 행씩 쌓이고,
|
||||
-- 사이드바 최상단 '즐겨찾기' 섹션이 이 행들을 읽어 표시한다.
|
||||
-- 테넌트 DB 별로 격리 (회사마다 메뉴가 달라 cross-tenant 공용으로 묶지 않음).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
|
||||
OBJID BIGSERIAL PRIMARY KEY,
|
||||
USER_ID VARCHAR(100) NOT NULL,
|
||||
MENU_OBJID VARCHAR(50) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER
|
||||
ON USER_MENU_FAVORITES (USER_ID);
|
||||
+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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =================================================================
|
||||
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
|
||||
-- =================================================================
|
||||
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
|
||||
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
|
||||
-- 멱등: IF NOT EXISTS 로 재실행 안전.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
|
||||
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
|
||||
@@ -214,12 +214,15 @@
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM APPROVAL_LINES L
|
||||
WHERE L.REQUEST_ID = R.REQUEST_ID
|
||||
AND L.APPROVER_ID = #{user_id}
|
||||
AND L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
)
|
||||
</if>
|
||||
ORDER BY R.CREATED_DATE DESC
|
||||
ORDER BY R.CREATED_AT DESC
|
||||
<if test="page_limit != null">
|
||||
LIMIT #{page_limit} OFFSET #{page_offset}
|
||||
</if>
|
||||
@@ -248,7 +251,10 @@
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM APPROVAL_LINES L
|
||||
WHERE L.REQUEST_ID = R.REQUEST_ID
|
||||
AND L.APPROVER_ID = #{user_id}
|
||||
AND L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
)
|
||||
@@ -459,14 +465,17 @@
|
||||
SELECT L.*,
|
||||
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
|
||||
R.REQUESTER_NAME, R.REQUESTER_DEPT,
|
||||
R.CREATED_DATE AS REQUEST_CREATED_DATE
|
||||
R.CREATED_AT AS REQUEST_CREATED_DATE
|
||||
FROM APPROVAL_LINES L
|
||||
JOIN APPROVAL_REQUESTS R
|
||||
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
WHERE L.APPROVER_ID = #{user_id}
|
||||
WHERE L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||
ORDER BY R.CREATED_DATE ASC
|
||||
ORDER BY R.CREATED_AT ASC
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -536,12 +545,14 @@
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
<!-- 어댑터: USER_SUBSTITUTES 참조 (T7, 086 마이그레이션 이후). -->
|
||||
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
|
||||
SELECT * FROM APPROVAL_PROXY_SETTINGS
|
||||
SELECT *
|
||||
FROM USER_SUBSTITUTES
|
||||
WHERE ORIGINAL_USER_ID = #{original_user_id}
|
||||
AND PROXY_USER_ID = #{proxy_user_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
AND START_DATE <= CURRENT_DATE
|
||||
AND IS_ACTIVE = TRUE
|
||||
AND (START_DATE IS NULL OR START_DATE <= CURRENT_DATE)
|
||||
AND END_DATE >= CURRENT_DATE
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
LIMIT 1
|
||||
|
||||
@@ -143,18 +143,31 @@
|
||||
ORDER BY COUNT DESC, U.USER_NAME ASC
|
||||
</select>
|
||||
|
||||
<!-- 감사 로그 INSERT -->
|
||||
<!-- 감사 로그 INSERT.
|
||||
PROCESSOR_ID/PROCESSOR_NAME 은 대무(代務) 처리 추적용 (086 마이그레이션 이후).
|
||||
평시는 USER_ID == PROCESSOR_ID. -->
|
||||
<insert id="insertAuditLog" parameterType="map">
|
||||
INSERT INTO SYSTEM_AUDIT_LOG (
|
||||
COMPANY_CODE, USER_ID, USER_NAME, ACTION, RESOURCE_TYPE,
|
||||
RESOURCE_ID, RESOURCE_NAME, TABLE_NAME, SUMMARY, CHANGES,
|
||||
IP_ADDRESS, REQUEST_PATH
|
||||
IP_ADDRESS, REQUEST_PATH,
|
||||
PROCESSOR_ID, PROCESSOR_NAME
|
||||
) VALUES (
|
||||
#{company_code}, #{user_id}, #{user_name}, #{action}, #{resource_type},
|
||||
#{resource_id}, #{resource_name}, #{table_name}, #{summary},
|
||||
CAST(#{changes} AS JSONB),
|
||||
#{ip_address}, #{request_path}
|
||||
#{ip_address}, #{request_path},
|
||||
#{processor_id}, #{processor_name}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 처리자 이름 lookup (대무 시 USER_INFO 에서 1회 조회). -->
|
||||
<select id="selectUserNameById" parameterType="map" resultType="string">
|
||||
SELECT USER_NAME
|
||||
FROM USER_INFO
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,13 +23,23 @@
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
D.DELETED_AT,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO D
|
||||
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
|
||||
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND D.DELETED_AT IS NULL
|
||||
</if>
|
||||
<if test="base_date != null and base_date != ''">
|
||||
AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date)
|
||||
AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date)
|
||||
</if>
|
||||
GROUP BY
|
||||
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
|
||||
@@ -57,7 +67,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
@@ -82,7 +98,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
@@ -302,4 +324,27 @@
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</update>
|
||||
|
||||
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
|
||||
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
|
||||
DELETE FROM DEPT_MANAGERS
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND ROLE = #{role}
|
||||
</delete>
|
||||
|
||||
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
|
||||
<insert id="insertDeptManagers" parameterType="map">
|
||||
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
|
||||
<foreach collection="user_ids" item="uid" index="idx" separator=",">
|
||||
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
|
||||
<select id="selectValidUserIds" parameterType="map" resultType="string">
|
||||
SELECT USER_ID FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND USER_ID IN
|
||||
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="favorites">
|
||||
|
||||
<!-- ================================================================
|
||||
사용자별 메뉴 즐겨찾기
|
||||
USER_MENU_FAVORITES (USER_ID, MENU_OBJID) UNIQUE
|
||||
================================================================ -->
|
||||
|
||||
<!-- 내 즐겨찾기 메뉴 목록 (MENU_INFO 와 JOIN, 활성 메뉴만) -->
|
||||
<select id="selectFavoriteMenuList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
UMF.OBJID AS favorite_objid
|
||||
, UMF.USER_ID AS user_id
|
||||
, UMF.MENU_OBJID AS menu_objid
|
||||
, UMF.SORT_ORDER AS sort_order
|
||||
, UMF.CREATED_AT AS created_at
|
||||
, MENU.MENU_NAME_KOR AS menu_name_kor
|
||||
, MENU.MENU_URL AS menu_url
|
||||
, MENU.MENU_ICON AS menu_icon
|
||||
, MENU.PARENT_OBJ_ID AS parent_obj_id
|
||||
, MENU.MENU_TYPE AS menu_type
|
||||
, MENU.COMPANY_CODE AS company_code
|
||||
FROM USER_MENU_FAVORITES UMF
|
||||
JOIN MENU_INFO MENU ON MENU.OBJID = UMF.MENU_OBJID
|
||||
WHERE UMF.USER_ID = #{user_id}
|
||||
AND MENU.STATUS = 'active'
|
||||
ORDER BY UMF.SORT_ORDER ASC, UMF.CREATED_AT ASC
|
||||
</select>
|
||||
|
||||
<!-- 즐겨찾기 추가 (이미 있으면 무시) -->
|
||||
<insert id="insertFavorite" parameterType="map">
|
||||
INSERT INTO USER_MENU_FAVORITES (USER_ID, MENU_OBJID, SORT_ORDER)
|
||||
VALUES (#{user_id}, #{menu_objid}, COALESCE(#{sort_order}, 0))
|
||||
ON CONFLICT (USER_ID, MENU_OBJID) DO NOTHING
|
||||
</insert>
|
||||
|
||||
<!-- 즐겨찾기 제거 -->
|
||||
<delete id="deleteFavorite" parameterType="map">
|
||||
DELETE FROM USER_MENU_FAVORITES
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
</delete>
|
||||
|
||||
<!-- 단건 존재 확인 (toggle 동작에 활용) -->
|
||||
<select id="selectFavoriteExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM USER_MENU_FAVORITES
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,294 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="substitute">
|
||||
|
||||
<!-- ================================================================
|
||||
USER_SUBSTITUTES — 대무자(代務者) 관리
|
||||
|
||||
Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
Plan: .omc/plans/autopilot-impl.md (T2)
|
||||
|
||||
핵심:
|
||||
- 종료일 NOT NULL (무기한 금지), 시작일 NULL = 즉시
|
||||
- 활성 판정: IS_ACTIVE = TRUE AND (START_DATE IS NULL OR
|
||||
START_DATE <= CURRENT_DATE) AND END_DATE >= CURRENT_DATE
|
||||
- 같은 쌍 + 활성 기간 겹침은 EXCLUDE 제약으로 DB 차단
|
||||
================================================================ -->
|
||||
|
||||
<!-- 활성 판정 공통 조건 -->
|
||||
<sql id="activeWindow">
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
</sql>
|
||||
|
||||
<!-- ================================================================
|
||||
조회
|
||||
================================================================ -->
|
||||
|
||||
<select id="selectSubstituteList" parameterType="map" resultType="map">
|
||||
SELECT S.SUBSTITUTE_ID
|
||||
, S.COMPANY_CODE
|
||||
, S.ORIGINAL_USER_ID
|
||||
, U1.USER_NAME AS ORIGINAL_USER_NAME
|
||||
, U1.DEPT_NAME AS ORIGINAL_DEPT_NAME
|
||||
, S.PROXY_USER_ID
|
||||
, U2.USER_NAME AS PROXY_USER_NAME
|
||||
, U2.DEPT_NAME AS PROXY_DEPT_NAME
|
||||
, S.START_DATE
|
||||
, S.END_DATE
|
||||
, S.REASON
|
||||
, S.IS_ACTIVE
|
||||
, CASE
|
||||
WHEN S.IS_ACTIVE = FALSE THEN 'inactive'
|
||||
WHEN S.END_DATE < CURRENT_DATE THEN 'expired'
|
||||
WHEN S.START_DATE IS NOT NULL AND S.START_DATE > CURRENT_DATE THEN 'upcoming'
|
||||
ELSE 'active'
|
||||
END AS STATUS
|
||||
, S.CREATED_BY
|
||||
, S.CREATED_DATE
|
||||
, S.UPDATED_BY
|
||||
, S.UPDATED_DATE
|
||||
FROM USER_SUBSTITUTES S
|
||||
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
|
||||
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
|
||||
WHERE 1=1
|
||||
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
<if test="original_user_id != null and original_user_id != ''">
|
||||
AND S.ORIGINAL_USER_ID = #{original_user_id}
|
||||
</if>
|
||||
<if test="proxy_user_id != null and proxy_user_id != ''">
|
||||
AND S.PROXY_USER_ID = #{proxy_user_id}
|
||||
</if>
|
||||
<choose>
|
||||
<when test='status == "active"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "upcoming"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND S.START_DATE IS NOT NULL
|
||||
AND S.START_DATE > CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "expired"'>
|
||||
AND S.END_DATE < CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "inactive"'>
|
||||
AND S.IS_ACTIVE = FALSE
|
||||
</when>
|
||||
</choose>
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN S.IS_ACTIVE = TRUE
|
||||
AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE THEN 0
|
||||
WHEN S.IS_ACTIVE = TRUE AND S.START_DATE > CURRENT_DATE THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
, S.END_DATE DESC
|
||||
, S.CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="selectSubstituteListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM USER_SUBSTITUTES S
|
||||
WHERE 1=1
|
||||
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
<if test="original_user_id != null and original_user_id != ''">
|
||||
AND S.ORIGINAL_USER_ID = #{original_user_id}
|
||||
</if>
|
||||
<if test="proxy_user_id != null and proxy_user_id != ''">
|
||||
AND S.PROXY_USER_ID = #{proxy_user_id}
|
||||
</if>
|
||||
<choose>
|
||||
<when test='status == "active"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "upcoming"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND S.START_DATE IS NOT NULL
|
||||
AND S.START_DATE > CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "expired"'>
|
||||
AND S.END_DATE < CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "inactive"'>
|
||||
AND S.IS_ACTIVE = FALSE
|
||||
</when>
|
||||
</choose>
|
||||
</select>
|
||||
|
||||
<!-- ProfileModal 본인 조회: 내가 위임한 + 나를 대무 중인 -->
|
||||
<select id="selectMySubstitutes" parameterType="map" resultType="map">
|
||||
SELECT S.SUBSTITUTE_ID
|
||||
, S.COMPANY_CODE
|
||||
, S.ORIGINAL_USER_ID
|
||||
, U1.USER_NAME AS ORIGINAL_USER_NAME
|
||||
, U1.DEPT_NAME AS ORIGINAL_DEPT_NAME
|
||||
, S.PROXY_USER_ID
|
||||
, U2.USER_NAME AS PROXY_USER_NAME
|
||||
, U2.DEPT_NAME AS PROXY_DEPT_NAME
|
||||
, S.START_DATE
|
||||
, S.END_DATE
|
||||
, S.REASON
|
||||
, S.IS_ACTIVE
|
||||
, CASE
|
||||
WHEN S.ORIGINAL_USER_ID = #{user_id} THEN 'proxying_for_me'
|
||||
WHEN S.PROXY_USER_ID = #{user_id} THEN 'my_proxies'
|
||||
END AS RELATION
|
||||
, CASE
|
||||
WHEN S.IS_ACTIVE = FALSE THEN 'inactive'
|
||||
WHEN S.END_DATE < CURRENT_DATE THEN 'expired'
|
||||
WHEN S.START_DATE IS NOT NULL AND S.START_DATE > CURRENT_DATE THEN 'upcoming'
|
||||
ELSE 'active'
|
||||
END AS STATUS
|
||||
, (S.END_DATE - CURRENT_DATE) AS DAYS_REMAINING
|
||||
FROM USER_SUBSTITUTES S
|
||||
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
|
||||
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
|
||||
WHERE (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
AND (S.ORIGINAL_USER_ID = #{user_id} OR S.PROXY_USER_ID = #{user_id})
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
ORDER BY S.END_DATE ASC
|
||||
</select>
|
||||
|
||||
<!-- Filter hot path: B 가 현재 대무 중인 모든 A 의 ID -->
|
||||
<select id="selectActiveOriginalUserIds" parameterType="map" resultType="string">
|
||||
SELECT S.ORIGINAL_USER_ID
|
||||
FROM USER_SUBSTITUTES S
|
||||
WHERE S.PROXY_USER_ID = #{proxy_user_id}
|
||||
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
<include refid="activeWindow"/>
|
||||
</select>
|
||||
|
||||
<select id="selectSubstituteInfo" parameterType="map" resultType="map">
|
||||
SELECT S.*
|
||||
, U1.USER_NAME AS ORIGINAL_USER_NAME
|
||||
, U2.USER_NAME AS PROXY_USER_NAME
|
||||
FROM USER_SUBSTITUTES S
|
||||
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
|
||||
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
|
||||
WHERE S.SUBSTITUTE_ID = #{substitute_id}
|
||||
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<!-- 결재 어댑터: B 가 A 의 대무자로 활성 상태인지 (ApprovalService 가 호출) -->
|
||||
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
|
||||
SELECT S.*
|
||||
FROM USER_SUBSTITUTES S
|
||||
WHERE S.ORIGINAL_USER_ID = #{original_user_id}
|
||||
AND S.PROXY_USER_ID = #{proxy_user_id}
|
||||
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
|
||||
<include refid="activeWindow"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
사전 검증
|
||||
================================================================ -->
|
||||
|
||||
<!-- 같은 쌍의 기간 겹침 사전 카운트 (EXCLUDE 제약 사전 우회용 UX) -->
|
||||
<select id="countOverlap" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM USER_SUBSTITUTES S
|
||||
WHERE S.COMPANY_CODE = #{company_code}
|
||||
AND S.ORIGINAL_USER_ID = #{original_user_id}
|
||||
AND S.PROXY_USER_ID = #{proxy_user_id}
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND DATERANGE(COALESCE(S.START_DATE, CURRENT_DATE), S.END_DATE, '[]')
|
||||
&& DATERANGE(COALESCE(CAST(#{start_date} AS DATE), CURRENT_DATE),
|
||||
CAST(#{end_date} AS DATE), '[]')
|
||||
<if test="exclude_substitute_id != null">
|
||||
AND S.SUBSTITUTE_ID <> #{exclude_substitute_id}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 같은 회사에 속한 사용자인지 검증 (B3: cross-company 차단) -->
|
||||
<select id="countUserInCompany" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM USER_INFO U
|
||||
WHERE U.USER_ID = #{user_id}
|
||||
AND U.COMPANY_CODE = #{company_code}
|
||||
</select>
|
||||
|
||||
<!-- 사용자가 SUPER_ADMIN 인지 확인 (proxy_user_id 로 지정 거부 용도) -->
|
||||
<select id="countSuperAdmin" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM USER_INFO U
|
||||
WHERE U.USER_ID = #{user_id}
|
||||
AND U.USER_TYPE = 'SUPER_ADMIN'
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
변경
|
||||
================================================================ -->
|
||||
|
||||
<insert id="insertSubstitute" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="substitute_id" keyColumn="substitute_id">
|
||||
INSERT INTO USER_SUBSTITUTES (
|
||||
COMPANY_CODE
|
||||
, ORIGINAL_USER_ID
|
||||
, PROXY_USER_ID
|
||||
, START_DATE
|
||||
, END_DATE
|
||||
, REASON
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{company_code}
|
||||
, #{original_user_id}
|
||||
, #{proxy_user_id}
|
||||
, CAST(#{start_date} AS DATE)
|
||||
, CAST(#{end_date} AS DATE)
|
||||
, #{reason}
|
||||
, COALESCE(#{is_active}, TRUE)
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateSubstitute" parameterType="map">
|
||||
UPDATE USER_SUBSTITUTES
|
||||
<set>
|
||||
<if test="proxy_user_id != null">
|
||||
PROXY_USER_ID = #{proxy_user_id},
|
||||
</if>
|
||||
<if test="start_date != null">
|
||||
START_DATE = CAST(#{start_date} AS DATE),
|
||||
</if>
|
||||
<if test="clear_start_date == true">
|
||||
START_DATE = NULL,
|
||||
</if>
|
||||
<if test="end_date != null">
|
||||
END_DATE = CAST(#{end_date} AS DATE),
|
||||
</if>
|
||||
<if test="reason != null">
|
||||
REASON = #{reason},
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
IS_ACTIVE = #{is_active},
|
||||
</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
WHERE SUBSTITUTE_ID = #{substitute_id}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<delete id="deleteSubstitute" parameterType="map">
|
||||
DELETE FROM USER_SUBSTITUTES
|
||||
WHERE SUBSTITUTE_ID = #{substitute_id}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -389,7 +389,7 @@
|
||||
<select id="getTablePrimaryKeyList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.CONNAME AS constraint_name
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_CONSTRAINT TC
|
||||
JOIN PG_CLASS C
|
||||
ON TC.CONRELID = C.OID
|
||||
@@ -411,7 +411,7 @@
|
||||
SELECT
|
||||
I.RELNAME AS index_name
|
||||
, IX.INDISUNIQUE AS is_unique
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_INDEX IX
|
||||
JOIN PG_CLASS T
|
||||
ON IX.INDRELID = T.OID
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user