Merge remote-tracking branch 'origin/main' into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 11m34s

This commit is contained in:
DDD1542
2026-05-14 17:42:17 +09:00
78 changed files with 5968 additions and 754 deletions
@@ -0,0 +1,391 @@
package com.erp.batch;
import com.erp.service.ExternalDbConnectionService;
import com.erp.service.ExternalRestApiConnectionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.*;
import java.util.regex.Pattern;
/**
* 배치 ETL 실행기 — vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식.
*
* 흐름:
* 1. 매핑을 (fixed | non-fixed) 로 partition
* 2. non-fixed 매핑을 (from_connection_type, from_connection_id, from_table_name) 키로 그룹화
* 3. 그룹별로 FROM 데이터 읽기 → MappingTransformer 로 행 변환 → TO 저장
* 4. (totalRecords, successRecords, failedRecords) 집계
*
* FROM 소스 지원:
* - internal : 현 tenant DB 의 테이블 (JDBC 직접 SELECT, LIMIT 1000)
* - external_db : ExternalDbConnectionService.executeQuery (SELECT-only 보안 정책)
* - restapi : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath)
*
* TO 대상 지원:
* - internal : 현 tenant DB INSERT / UPSERT (save_mode + conflict_key)
* - restapi : 행 단위 POST/PUT/DELETE — testConnection 으로 호출
* - external_db : 미지원 (ExternalDbConnectionService 가 SELECT-only 라 의도적으로 차단)
*
* 미지원 (vexplor_rps 대비 단순화):
* - to_api_body 템플릿 기반 일괄 전송
* - URL_PATH_PARAM 컬럼 처리
* - auth_tokens 자동 조회 (inline-mode REST API)
* - row_filter_config
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class BatchExecutor {
private final SqlSession sqlSession;
private final ExternalDbConnectionService externalDb;
private final ExternalRestApiConnectionService externalRest;
/** PostgreSQL 식별자 화이트리스트 (영문/숫자/언더스코어만). SQL injection 방어용. */
private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final int FROM_LIMIT = 1000;
public ExecutionResult execute(Map<String, Object> config) {
ExecutionResult r = new ExecutionResult();
Object mappingsRaw = config.get("batch_mappings");
if (!(mappingsRaw instanceof List)) {
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
return r;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsRaw;
if (mappings.isEmpty()) {
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
return r;
}
// 1. fixed 분리
MappingTransformer.Partition partition = MappingTransformer.partitionFixed(mappings);
// 2. non-fixed 그룹화 (from_connection 기준)
Map<String, List<Map<String, Object>>> tableGroups = new LinkedHashMap<>();
for (Map<String, Object> m : partition.nonFixed) {
String key = str(m.get("from_connection_type")) + ":"
+ (m.get("from_connection_id") == null ? "internal" : m.get("from_connection_id"))
+ ":" + str(m.get("from_table_name"));
tableGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(m);
}
if (tableGroups.isEmpty() && !partition.fixed.isEmpty()) {
log.warn("일반 매핑이 없고 고정값 매핑만 있어 실행 불가");
return r;
}
String companyCode = str(config.get("company_code"));
String saveMode = strOr(config.get("save_mode"), "INSERT");
String conflictKey = str(config.get("conflict_key"));
String dataArrayPath = str(config.get("data_array_path"));
// 3. 그룹별 처리
for (Map.Entry<String, List<Map<String, Object>>> e : tableGroups.entrySet()) {
String key = e.getKey();
List<Map<String, Object>> groupMappings = e.getValue();
Map<String, Object> first = groupMappings.get(0);
try {
log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size());
// FROM 읽기
List<Map<String, Object>> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode);
r.totalRecords += fromData.size();
// Transform
String toConnType = str(first.get("to_connection_type"));
List<Map<String, Object>> mappedRows = new ArrayList<>(fromData.size());
for (Map<String, Object> row : fromData) {
mappedRows.add(MappingTransformer.transformRow(
row, groupMappings, partition.fixed, toConnType, companyCode));
}
// TO 저장
WriteResult wr = writeTo(first, mappedRows, saveMode, conflictKey, companyCode);
r.successRecords += wr.success;
r.failedRecords += wr.failed;
} catch (Exception ex) {
log.error("테이블 처리 중 오류: {} — {}", key, ex.getMessage(), ex);
r.errorMessages.add(key + ": " + ex.getMessage());
}
}
return r;
}
// ── FROM 읽기 ───────────────────────────────────────────────────────────
private List<Map<String, Object>> readFrom(
Map<String, Object> firstMapping,
List<Map<String, Object>> groupMappings,
String dataArrayPath,
String companyCode
) {
String type = str(firstMapping.get("from_connection_type"));
String tableName = str(firstMapping.get("from_table_name"));
List<String> columns = new ArrayList<>();
for (Map<String, Object> m : groupMappings) {
String col = str(m.get("from_column_name"));
if (col != null && !col.isEmpty() && !columns.contains(col)) columns.add(col);
}
if ("restapi".equals(type)) {
return readFromRestApi(firstMapping, dataArrayPath, companyCode);
}
if ("external".equals(type) || "external_db".equals(type)) {
return readFromExternalDb(firstMapping, columns);
}
// internal (기본)
return readFromInternal(tableName, columns);
}
/** Internal DB 의 동적 SELECT. sqlSession 의 현 tenant connection 사용. */
private List<Map<String, Object>> readFromInternal(String tableName, List<String> columns) {
if (tableName == null) throw new IllegalArgumentException("from_table_name 누락");
if (columns.isEmpty()) throw new IllegalArgumentException("from_column_name 매핑 없음");
StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(columns.get(i)));
}
sql.append(" FROM ").append(safeIdent(tableName));
sql.append(" LIMIT ").append(FROM_LIMIT);
try (Connection c = sqlSession.getConnection();
PreparedStatement ps = c.prepareStatement(sql.toString());
ResultSet rs = ps.executeQuery()) {
return materialize(rs);
} catch (SQLException e) {
throw new RuntimeException("internal SELECT 실패: " + e.getMessage(), e);
}
}
/** External DB SELECT — ExternalDbConnectionService.executeQuery 경유 (SELECT-only). */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> readFromExternalDb(Map<String, Object> firstMapping, List<String> columns) {
Object connIdObj = firstMapping.get("from_connection_id");
if (connIdObj == null) throw new IllegalArgumentException("external_db 인데 from_connection_id 가 비어있음");
long connId = Long.parseLong(connIdObj.toString());
String tableName = str(firstMapping.get("from_table_name"));
StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(columns.get(i)));
}
sql.append(" FROM ").append(safeIdent(tableName)).append(" LIMIT ").append(FROM_LIMIT);
Map<String, Object> result = externalDb.executeQuery(connId, sql.toString());
Object data = result.get("data");
return data instanceof List ? (List<Map<String, Object>>) data : List.of();
}
/** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */
@SuppressWarnings("unchecked")
private List<Map<String, Object>> readFromRestApi(
Map<String, Object> firstMapping, String dataArrayPath, String companyCode
) {
Object connIdObj = firstMapping.get("from_connection_id");
if (connIdObj == null) {
throw new UnsupportedOperationException(
"REST API 등록 연결 없는 inline-mode (from_api_url 직접 호출) 는 현재 미지원");
}
int connId = Integer.parseInt(connIdObj.toString());
String endpoint = str(firstMapping.get("from_table_name"));
Map<String, Object> params = new HashMap<>();
if (companyCode != null) params.put("company_code", companyCode);
Map<String, Object> result = externalRest.fetchData(connId, endpoint, dataArrayPath, params);
if (!Boolean.TRUE.equals(result.get("success"))) {
throw new RuntimeException("REST API 호출 실패: " + result.getOrDefault("message", ""));
}
Object data = result.get("data");
if (!(data instanceof Map)) return List.of();
Object rows = ((Map<String, Object>) data).get("rows");
if (!(rows instanceof List)) return List.of();
List<Object> raw = (List<Object>) rows;
List<Map<String, Object>> out = new ArrayList<>(raw.size());
for (Object o : raw) if (o instanceof Map) out.add((Map<String, Object>) o);
return out;
}
// ── TO 저장 ────────────────────────────────────────────────────────────
// 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit.
// 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음.
public WriteResult writeTo(
Map<String, Object> firstMapping,
List<Map<String, Object>> rows,
String saveMode,
String conflictKey,
String companyCode
) {
if (rows == null || rows.isEmpty()) return new WriteResult();
String type = str(firstMapping.get("to_connection_type"));
String tableName = str(firstMapping.get("to_table_name"));
if ("restapi".equals(type)) {
return writeToRestApi(firstMapping, rows, companyCode);
}
if ("external".equals(type) || "external_db".equals(type)) {
throw new UnsupportedOperationException(
"external_db TO 쓰기는 현재 미지원 (ExternalDbConnectionService 가 SELECT-only)");
}
return writeToInternal(tableName, rows, saveMode, conflictKey);
}
/** Internal DB INSERT / UPSERT — 행 단위 PreparedStatement. */
private WriteResult writeToInternal(String tableName, List<Map<String, Object>> rows,
String saveMode, String conflictKey) {
WriteResult r = new WriteResult();
if (tableName == null) throw new IllegalArgumentException("to_table_name 누락");
safeIdent(tableName);
try (Connection c = sqlSession.getConnection()) {
for (Map<String, Object> row : rows) {
try {
String sql = buildInsertSql(tableName, row, saveMode, conflictKey);
try (PreparedStatement ps = c.prepareStatement(sql)) {
int idx = 1;
for (Object v : row.values()) {
ps.setObject(idx++, v);
}
ps.executeUpdate();
r.success++;
}
} catch (SQLException e) {
log.error("INSERT 실패 row={} — {}", row, e.getMessage());
r.failed++;
}
}
} catch (SQLException e) {
throw new RuntimeException("internal write 실패: " + e.getMessage(), e);
}
return r;
}
/** INSERT (또는 UPSERT) SQL 생성. row 의 key 순서로 컬럼/플레이스홀더 배열. */
private String buildInsertSql(String tableName, Map<String, Object> row,
String saveMode, String conflictKey) {
List<String> cols = new ArrayList<>(row.keySet());
StringBuilder sql = new StringBuilder("INSERT INTO ").append(safeIdent(tableName)).append(" (");
for (int i = 0; i < cols.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(safeIdent(cols.get(i)));
}
sql.append(") VALUES (");
for (int i = 0; i < cols.size(); i++) {
if (i > 0) sql.append(", ");
sql.append("?");
}
sql.append(")");
if ("UPSERT".equalsIgnoreCase(saveMode) && conflictKey != null && !conflictKey.isEmpty()) {
safeIdent(conflictKey);
List<String> updateCols = new ArrayList<>();
for (String col : cols) if (!col.equalsIgnoreCase(conflictKey)) updateCols.add(col);
sql.append(" ON CONFLICT (").append(conflictKey).append(") ");
if (updateCols.isEmpty()) {
sql.append("DO NOTHING");
} else {
sql.append("DO UPDATE SET ");
for (int i = 0; i < updateCols.size(); i++) {
if (i > 0) sql.append(", ");
String c = safeIdent(updateCols.get(i));
sql.append(c).append(" = EXCLUDED.").append(c);
}
if (cols.stream().anyMatch(c -> c.equalsIgnoreCase("updated_date"))) {
sql.append(", updated_date = NOW()");
}
}
}
return sql.toString();
}
/** REST API TO — 행 단위로 testConnection 호출 (POST/PUT/DELETE). */
private WriteResult writeToRestApi(Map<String, Object> firstMapping,
List<Map<String, Object>> rows, String companyCode) {
WriteResult r = new WriteResult();
String baseUrl = str(firstMapping.get("to_api_url"));
String endpoint = str(firstMapping.get("to_table_name"));
String method = strOr(firstMapping.get("to_api_method"), "POST");
for (Map<String, Object> row : rows) {
try {
Map<String, Object> testReq = new LinkedHashMap<>();
testReq.put("base_url", baseUrl);
testReq.put("endpoint", endpoint);
testReq.put("method", method);
testReq.put("body", row);
testReq.put("auth_type", "none");
testReq.put("timeout", 30000);
Map<String, Object> result = externalRest.testConnection(testReq, companyCode);
if (Boolean.TRUE.equals(result.get("success"))) r.success++; else r.failed++;
} catch (Exception e) {
log.error("REST API 전송 실패 row={} — {}", row, e.getMessage());
r.failed++;
}
}
return r;
}
// ── 유틸 ────────────────────────────────────────────────────────────────
private static List<Map<String, Object>> materialize(ResultSet rs) throws SQLException {
ResultSetMetaData md = rs.getMetaData();
int n = md.getColumnCount();
List<Map<String, Object>> rows = new ArrayList<>();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= n; i++) row.put(md.getColumnLabel(i), rs.getObject(i));
rows.add(row);
}
return rows;
}
private static String safeIdent(String s) {
if (s == null || !SAFE_IDENT.matcher(s).matches()) {
throw new IllegalArgumentException("Unsafe identifier: " + s);
}
return s;
}
private static String str(Object v) { return v == null ? null : v.toString(); }
private static String strOr(Object v, String fallback) {
String s = str(v);
return (s == null || s.isEmpty()) ? fallback : s;
}
// ── 결과 클래스 ────────────────────────────────────────────────────────
public static final class ExecutionResult {
public int totalRecords = 0;
public int successRecords = 0;
public int failedRecords = 0;
public final List<String> errorMessages = new ArrayList<>();
public Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("total_records", totalRecords);
m.put("success_records", successRecords);
m.put("failed_records", failedRecords);
m.put("error_message", errorMessages.isEmpty() ? null : String.join("\n", errorMessages));
return m;
}
}
public static final class WriteResult {
public int success = 0;
public int failed = 0;
}
}
@@ -0,0 +1,179 @@
package com.erp.batch;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 매핑 변환 유틸리티 — vexplor_rps 의 batchSchedulerService L550~617 .map() 로직 1:1 이식.
*
* BatchExecutor 가 FROM 에서 읽은 row 들을 TO 형태로 변환할 때 사용. 의존성 없는 정적 메서드만.
*
* mapping_type 분기:
* - "direct" : row[from_column_name] → row[to_column_name] 그대로 복사
* from_column_name 은 점 표기법 지원 (예: "response.access_token")
* - "fixed" : from_column_name 자체가 고정값. transformRow 는 fixed 매핑을 건너뛰고,
* 호출측이 partition 한 뒤 mappedRow 에 적용 (vexplor_rps L598-603 패턴).
* - "conditional" : ConditionalConfig.rules 의 when 과 sourceVal 문자열 동등 비교, 매칭 then 반환.
* 매칭 없으면 default. (단순 문자열 lookup. SpEL/JEXL 등 표현식 평가 안 함)
*/
@Slf4j
public final class MappingTransformer {
private static final ObjectMapper OM = new ObjectMapper();
private MappingTransformer() {}
/** 단일 row 를 매핑 룰에 따라 변환. mapping_type 별 분기 처리. */
public static Map<String, Object> transformRow(
Map<String, Object> row,
List<Map<String, Object>> nonFixedMappings,
List<Map<String, Object>> fixedMappings,
String toConnectionType,
String companyCode
) {
Map<String, Object> mappedRow = new LinkedHashMap<>();
for (Map<String, Object> mapping : nonFixedMappings) {
String mt = strOr(mapping.get("mapping_type"), "direct");
String fromCol = str(mapping.get("from_column_name"));
String toCol = str(mapping.get("to_column_name"));
if ("conditional".equals(mt)) {
ConditionalConfig cfg = parseConditionalConfig(mapping.get("mapping_config"));
String sourceVal = String.valueOf(getValueByPath(row, fromCol));
if (sourceVal == null || "null".equals(sourceVal)) sourceVal = "";
mappedRow.put(toCol, evaluateConditional(sourceVal, cfg));
continue;
}
// direct 또는 알 수 없는 type — 그대로 복사
// DB→REST 의 to_api_body 템플릿 처리는 BatchExecutor 측에서 별도 처리 (vexplor_rps L582~595).
// 여기서는 단순 to_column_name 으로 값 흘림.
Object value = getValueByPath(row, fromCol);
mappedRow.put(toCol, value);
}
// 고정값 매핑 적용 — from_column_name 자체가 저장값 (vexplor_rps L598-603)
if (fixedMappings != null) {
for (Map<String, Object> fm : fixedMappings) {
mappedRow.put(str(fm.get("to_column_name")), fm.get("from_column_name"));
}
}
// 멀티테넌시: TO 가 DB 일 때 company_code 자동 주입 (vexplor_rps L605-614)
if (!"restapi".equals(toConnectionType)
&& companyCode != null
&& !mappedRow.containsKey("company_code")) {
mappedRow.put("company_code", companyCode);
}
return mappedRow;
}
/** 점 표기법 path 평가 — "response.access_token" 같은 중첩 키 지원 (vexplor_rps L540-548). */
@SuppressWarnings("unchecked")
public static Object getValueByPath(Map<String, Object> obj, String path) {
if (obj == null || path == null || path.isEmpty()) return null;
if (!path.contains(".")) return obj.get(path);
Object cur = obj;
for (String part : path.split("\\.")) {
if (!(cur instanceof Map)) return null;
cur = ((Map<String, Object>) cur).get(part);
if (cur == null) return null;
}
return cur;
}
/** ConditionalConfig 단일 평가 — when/then lookup + default. */
public static Object evaluateConditional(String sourceVal, ConditionalConfig cfg) {
if (cfg == null || cfg.rules == null) return cfg != null ? cfg.defaultValue : null;
for (ConditionalRule r : cfg.rules) {
String when = r.when == null ? "" : r.when;
if (Objects.equals(when, sourceVal)) return r.then;
}
return cfg.defaultValue;
}
/**
* mapping_config (JSONB) 의 원시 값 → ConditionalConfig.
* - BatchService.attachMappings 가 이미 파싱한 경우 → Map<String,Object>
* - 직접 SELECT 결과 → String(JSON) 가능
* - null → 빈 cfg
*/
@SuppressWarnings("unchecked")
public static ConditionalConfig parseConditionalConfig(Object raw) {
if (raw == null) return ConditionalConfig.empty();
Map<String, Object> map;
try {
if (raw instanceof Map) {
map = (Map<String, Object>) raw;
} else if (raw instanceof String) {
String s = ((String) raw).trim();
if (s.isEmpty()) return ConditionalConfig.empty();
map = OM.readValue(s, Map.class);
} else {
return ConditionalConfig.empty();
}
} catch (Exception e) {
log.warn("[conditional 매핑] JSON 파싱 실패: {}", e.getMessage());
return ConditionalConfig.empty();
}
ConditionalConfig cfg = new ConditionalConfig();
Object rulesRaw = map.get("rules");
if (rulesRaw instanceof List) {
for (Object r : (List<Object>) rulesRaw) {
if (r instanceof Map) {
Map<String, Object> rm = (Map<String, Object>) r;
cfg.rules.add(new ConditionalRule(
rm.get("when") == null ? "" : String.valueOf(rm.get("when")),
rm.get("then") == null ? null : String.valueOf(rm.get("then"))
));
}
}
}
Object def = map.get("default");
cfg.defaultValue = def == null ? null : String.valueOf(def);
return cfg;
}
/** non-fixed / fixed 매핑 분리. vexplor_rps L265~271 partition 패턴. */
public static Partition partitionFixed(List<Map<String, Object>> mappings) {
Partition p = new Partition();
if (mappings == null) return p;
for (Map<String, Object> m : mappings) {
String mt = strOr(m.get("mapping_type"), "direct");
if ("fixed".equals(mt)) p.fixed.add(m); else p.nonFixed.add(m);
}
return p;
}
public static final class Partition {
public final List<Map<String, Object>> nonFixed = new ArrayList<>();
public final List<Map<String, Object>> fixed = new ArrayList<>();
}
public static final class ConditionalConfig {
public List<ConditionalRule> rules = new ArrayList<>();
public String defaultValue;
public static ConditionalConfig empty() { return new ConditionalConfig(); }
}
public static final class ConditionalRule {
public final String when;
public final String then;
public ConditionalRule(String when, String then) { this.when = when; this.then = then; }
}
private static String str(Object v) { return v == null ? null : v.toString(); }
private static String strOr(Object v, String fallback) {
String s = str(v);
return (s == null || s.isEmpty()) ? fallback : s;
}
}
@@ -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&lt;Map&gt; 형태이며 각 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);
@@ -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 &lt;= CURRENT_DATE
AND IS_ACTIVE = TRUE
AND (START_DATE IS NULL OR START_DATE &lt;= CURRENT_DATE)
AND END_DATE &gt;= 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 &gt;= #{start_date}::timestamp
@@ -123,7 +123,7 @@
total_records, success_records, failed_records,
error_message, error_details, server_name, process_id
) VALUES (
#{batch_config_id}, #{company_code}, #{execution_status},
#{batch_config_id}::varchar, #{company_code}, #{execution_status},
COALESCE(#{start_time}::timestamp, NOW()),
#{end_time}::timestamp,
#{duration_ms},
@@ -15,14 +15,14 @@
execution_today AS (
SELECT COUNT(*) AS today_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed
FROM batch_execution_log
FROM batch_execution_logs
WHERE DATE(start_time) = CURRENT_DATE
<include refid="common.companyCodeFilter"/>
),
execution_yesterday AS (
SELECT COUNT(*) AS yesterday_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed
FROM batch_execution_log
FROM batch_execution_logs
WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day'
<include refid="common.companyCodeFilter"/>
)
@@ -77,9 +77,9 @@
SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count
FROM batch_execution_log
FROM batch_execution_logs
WHERE batch_config_id = #{batch_config_id}
WHERE batch_config_id = #{batch_config_id}::varchar
AND start_time >= NOW() - INTERVAL '24 hours'
GROUP BY DATE_TRUNC('hour', start_time)
@@ -100,9 +100,9 @@
failed_records,
error_message
FROM batch_execution_log
FROM batch_execution_logs
WHERE batch_config_id = #{batch_config_id}
WHERE batch_config_id = #{batch_config_id}::varchar
ORDER BY start_time DESC
LIMIT 20
@@ -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 &lt;= #{base_date}::date)
AND (D.END_DATE IS NULL OR D.END_DATE &gt;= #{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 &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= 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 &lt; CURRENT_DATE THEN 'expired'
WHEN S.START_DATE IS NOT NULL AND S.START_DATE &gt; 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 &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE
</when>
<when test='status == "upcoming"'>
AND S.IS_ACTIVE = TRUE
AND S.START_DATE IS NOT NULL
AND S.START_DATE &gt; CURRENT_DATE
</when>
<when test='status == "expired"'>
AND S.END_DATE &lt; 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 &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE THEN 0
WHEN S.IS_ACTIVE = TRUE AND S.START_DATE &gt; 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 &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE
</when>
<when test='status == "upcoming"'>
AND S.IS_ACTIVE = TRUE
AND S.START_DATE IS NOT NULL
AND S.START_DATE &gt; CURRENT_DATE
</when>
<when test='status == "expired"'>
AND S.END_DATE &lt; 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 &lt; CURRENT_DATE THEN 'expired'
WHEN S.START_DATE IS NOT NULL AND S.START_DATE &gt; 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 &gt;= 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, '[]')
&amp;&amp; DATERANGE(COALESCE(CAST(#{start_date} AS DATE), CURRENT_DATE),
CAST(#{end_date} AS DATE), '[]')
<if test="exclude_substitute_id != null">
AND S.SUBSTITUTE_ID &lt;&gt; #{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"));
}
}