diff --git a/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java b/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java new file mode 100644 index 00000000..30438e01 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/batch/BatchExecutor.java @@ -0,0 +1,391 @@ +package com.erp.batch; + +import com.erp.service.ExternalDbConnectionService; +import com.erp.service.ExternalRestApiConnectionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.session.SqlSession; +import org.springframework.stereotype.Service; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.*; +import java.util.regex.Pattern; + +/** + * 배치 ETL 실행기 — vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식. + * + * 흐름: + * 1. 매핑을 (fixed | non-fixed) 로 partition + * 2. non-fixed 매핑을 (from_connection_type, from_connection_id, from_table_name) 키로 그룹화 + * 3. 그룹별로 FROM 데이터 읽기 → MappingTransformer 로 행 변환 → TO 저장 + * 4. (totalRecords, successRecords, failedRecords) 집계 + * + * FROM 소스 지원: + * - internal : 현 tenant DB 의 테이블 (JDBC 직접 SELECT, LIMIT 1000) + * - external_db : ExternalDbConnectionService.executeQuery (SELECT-only 보안 정책) + * - restapi : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath) + * + * TO 대상 지원: + * - internal : 현 tenant DB INSERT / UPSERT (save_mode + conflict_key) + * - restapi : 행 단위 POST/PUT/DELETE — testConnection 으로 호출 + * - external_db : 미지원 (ExternalDbConnectionService 가 SELECT-only 라 의도적으로 차단) + * + * 미지원 (vexplor_rps 대비 단순화): + * - to_api_body 템플릿 기반 일괄 전송 + * - URL_PATH_PARAM 컬럼 처리 + * - auth_tokens 자동 조회 (inline-mode REST API) + * - row_filter_config + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class BatchExecutor { + + private final SqlSession sqlSession; + private final ExternalDbConnectionService externalDb; + private final ExternalRestApiConnectionService externalRest; + + /** PostgreSQL 식별자 화이트리스트 (영문/숫자/언더스코어만). SQL injection 방어용. */ + private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final int FROM_LIMIT = 1000; + + public ExecutionResult execute(Map config) { + ExecutionResult r = new ExecutionResult(); + Object mappingsRaw = config.get("batch_mappings"); + if (!(mappingsRaw instanceof List)) { + log.warn("배치 매핑이 없습니다: {}", config.get("batch_name")); + return r; + } + @SuppressWarnings("unchecked") + List> mappings = (List>) mappingsRaw; + if (mappings.isEmpty()) { + log.warn("배치 매핑이 없습니다: {}", config.get("batch_name")); + return r; + } + + // 1. fixed 분리 + MappingTransformer.Partition partition = MappingTransformer.partitionFixed(mappings); + + // 2. non-fixed 그룹화 (from_connection 기준) + Map>> tableGroups = new LinkedHashMap<>(); + for (Map m : partition.nonFixed) { + String key = str(m.get("from_connection_type")) + ":" + + (m.get("from_connection_id") == null ? "internal" : m.get("from_connection_id")) + + ":" + str(m.get("from_table_name")); + tableGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + if (tableGroups.isEmpty() && !partition.fixed.isEmpty()) { + log.warn("일반 매핑이 없고 고정값 매핑만 있어 실행 불가"); + return r; + } + + String companyCode = str(config.get("company_code")); + String saveMode = strOr(config.get("save_mode"), "INSERT"); + String conflictKey = str(config.get("conflict_key")); + String dataArrayPath = str(config.get("data_array_path")); + + // 3. 그룹별 처리 + for (Map.Entry>> e : tableGroups.entrySet()) { + String key = e.getKey(); + List> groupMappings = e.getValue(); + Map first = groupMappings.get(0); + try { + log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size()); + + // FROM 읽기 + List> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode); + + r.totalRecords += fromData.size(); + + // Transform + String toConnType = str(first.get("to_connection_type")); + List> mappedRows = new ArrayList<>(fromData.size()); + for (Map row : fromData) { + mappedRows.add(MappingTransformer.transformRow( + row, groupMappings, partition.fixed, toConnType, companyCode)); + } + + // TO 저장 + WriteResult wr = writeTo(first, mappedRows, saveMode, conflictKey, companyCode); + r.successRecords += wr.success; + r.failedRecords += wr.failed; + + } catch (Exception ex) { + log.error("테이블 처리 중 오류: {} — {}", key, ex.getMessage(), ex); + r.errorMessages.add(key + ": " + ex.getMessage()); + } + } + + return r; + } + + // ── FROM 읽기 ─────────────────────────────────────────────────────────── + + private List> readFrom( + Map firstMapping, + List> groupMappings, + String dataArrayPath, + String companyCode + ) { + String type = str(firstMapping.get("from_connection_type")); + String tableName = str(firstMapping.get("from_table_name")); + List columns = new ArrayList<>(); + for (Map m : groupMappings) { + String col = str(m.get("from_column_name")); + if (col != null && !col.isEmpty() && !columns.contains(col)) columns.add(col); + } + + if ("restapi".equals(type)) { + return readFromRestApi(firstMapping, dataArrayPath, companyCode); + } + if ("external".equals(type) || "external_db".equals(type)) { + return readFromExternalDb(firstMapping, columns); + } + // internal (기본) + return readFromInternal(tableName, columns); + } + + /** Internal DB 의 동적 SELECT. sqlSession 의 현 tenant connection 사용. */ + private List> readFromInternal(String tableName, List columns) { + if (tableName == null) throw new IllegalArgumentException("from_table_name 누락"); + if (columns.isEmpty()) throw new IllegalArgumentException("from_column_name 매핑 없음"); + StringBuilder sql = new StringBuilder("SELECT "); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(columns.get(i))); + } + sql.append(" FROM ").append(safeIdent(tableName)); + sql.append(" LIMIT ").append(FROM_LIMIT); + + try (Connection c = sqlSession.getConnection(); + PreparedStatement ps = c.prepareStatement(sql.toString()); + ResultSet rs = ps.executeQuery()) { + return materialize(rs); + } catch (SQLException e) { + throw new RuntimeException("internal SELECT 실패: " + e.getMessage(), e); + } + } + + /** External DB SELECT — ExternalDbConnectionService.executeQuery 경유 (SELECT-only). */ + @SuppressWarnings("unchecked") + private List> readFromExternalDb(Map firstMapping, List columns) { + Object connIdObj = firstMapping.get("from_connection_id"); + if (connIdObj == null) throw new IllegalArgumentException("external_db 인데 from_connection_id 가 비어있음"); + long connId = Long.parseLong(connIdObj.toString()); + String tableName = str(firstMapping.get("from_table_name")); + StringBuilder sql = new StringBuilder("SELECT "); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(columns.get(i))); + } + sql.append(" FROM ").append(safeIdent(tableName)).append(" LIMIT ").append(FROM_LIMIT); + + Map result = externalDb.executeQuery(connId, sql.toString()); + Object data = result.get("data"); + return data instanceof List ? (List>) data : List.of(); + } + + /** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */ + @SuppressWarnings("unchecked") + private List> readFromRestApi( + Map firstMapping, String dataArrayPath, String companyCode + ) { + Object connIdObj = firstMapping.get("from_connection_id"); + if (connIdObj == null) { + throw new UnsupportedOperationException( + "REST API 등록 연결 없는 inline-mode (from_api_url 직접 호출) 는 현재 미지원"); + } + int connId = Integer.parseInt(connIdObj.toString()); + String endpoint = str(firstMapping.get("from_table_name")); + + Map params = new HashMap<>(); + if (companyCode != null) params.put("company_code", companyCode); + Map result = externalRest.fetchData(connId, endpoint, dataArrayPath, params); + + if (!Boolean.TRUE.equals(result.get("success"))) { + throw new RuntimeException("REST API 호출 실패: " + result.getOrDefault("message", "")); + } + Object data = result.get("data"); + if (!(data instanceof Map)) return List.of(); + Object rows = ((Map) data).get("rows"); + if (!(rows instanceof List)) return List.of(); + List raw = (List) rows; + List> out = new ArrayList<>(raw.size()); + for (Object o : raw) if (o instanceof Map) out.add((Map) o); + return out; + } + + // ── TO 저장 ──────────────────────────────────────────────────────────── + + // 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit. + // 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음. + public WriteResult writeTo( + Map firstMapping, + List> rows, + String saveMode, + String conflictKey, + String companyCode + ) { + if (rows == null || rows.isEmpty()) return new WriteResult(); + String type = str(firstMapping.get("to_connection_type")); + String tableName = str(firstMapping.get("to_table_name")); + + if ("restapi".equals(type)) { + return writeToRestApi(firstMapping, rows, companyCode); + } + if ("external".equals(type) || "external_db".equals(type)) { + throw new UnsupportedOperationException( + "external_db TO 쓰기는 현재 미지원 (ExternalDbConnectionService 가 SELECT-only)"); + } + return writeToInternal(tableName, rows, saveMode, conflictKey); + } + + /** Internal DB INSERT / UPSERT — 행 단위 PreparedStatement. */ + private WriteResult writeToInternal(String tableName, List> rows, + String saveMode, String conflictKey) { + WriteResult r = new WriteResult(); + if (tableName == null) throw new IllegalArgumentException("to_table_name 누락"); + safeIdent(tableName); + + try (Connection c = sqlSession.getConnection()) { + for (Map row : rows) { + try { + String sql = buildInsertSql(tableName, row, saveMode, conflictKey); + try (PreparedStatement ps = c.prepareStatement(sql)) { + int idx = 1; + for (Object v : row.values()) { + ps.setObject(idx++, v); + } + ps.executeUpdate(); + r.success++; + } + } catch (SQLException e) { + log.error("INSERT 실패 row={} — {}", row, e.getMessage()); + r.failed++; + } + } + } catch (SQLException e) { + throw new RuntimeException("internal write 실패: " + e.getMessage(), e); + } + return r; + } + + /** INSERT (또는 UPSERT) SQL 생성. row 의 key 순서로 컬럼/플레이스홀더 배열. */ + private String buildInsertSql(String tableName, Map row, + String saveMode, String conflictKey) { + List cols = new ArrayList<>(row.keySet()); + StringBuilder sql = new StringBuilder("INSERT INTO ").append(safeIdent(tableName)).append(" ("); + for (int i = 0; i < cols.size(); i++) { + if (i > 0) sql.append(", "); + sql.append(safeIdent(cols.get(i))); + } + sql.append(") VALUES ("); + for (int i = 0; i < cols.size(); i++) { + if (i > 0) sql.append(", "); + sql.append("?"); + } + sql.append(")"); + + if ("UPSERT".equalsIgnoreCase(saveMode) && conflictKey != null && !conflictKey.isEmpty()) { + safeIdent(conflictKey); + List updateCols = new ArrayList<>(); + for (String col : cols) if (!col.equalsIgnoreCase(conflictKey)) updateCols.add(col); + sql.append(" ON CONFLICT (").append(conflictKey).append(") "); + if (updateCols.isEmpty()) { + sql.append("DO NOTHING"); + } else { + sql.append("DO UPDATE SET "); + for (int i = 0; i < updateCols.size(); i++) { + if (i > 0) sql.append(", "); + String c = safeIdent(updateCols.get(i)); + sql.append(c).append(" = EXCLUDED.").append(c); + } + if (cols.stream().anyMatch(c -> c.equalsIgnoreCase("updated_date"))) { + sql.append(", updated_date = NOW()"); + } + } + } + return sql.toString(); + } + + /** REST API TO — 행 단위로 testConnection 호출 (POST/PUT/DELETE). */ + private WriteResult writeToRestApi(Map firstMapping, + List> rows, String companyCode) { + WriteResult r = new WriteResult(); + String baseUrl = str(firstMapping.get("to_api_url")); + String endpoint = str(firstMapping.get("to_table_name")); + String method = strOr(firstMapping.get("to_api_method"), "POST"); + + for (Map row : rows) { + try { + Map testReq = new LinkedHashMap<>(); + testReq.put("base_url", baseUrl); + testReq.put("endpoint", endpoint); + testReq.put("method", method); + testReq.put("body", row); + testReq.put("auth_type", "none"); + testReq.put("timeout", 30000); + Map result = externalRest.testConnection(testReq, companyCode); + if (Boolean.TRUE.equals(result.get("success"))) r.success++; else r.failed++; + } catch (Exception e) { + log.error("REST API 전송 실패 row={} — {}", row, e.getMessage()); + r.failed++; + } + } + return r; + } + + // ── 유틸 ──────────────────────────────────────────────────────────────── + + private static List> materialize(ResultSet rs) throws SQLException { + ResultSetMetaData md = rs.getMetaData(); + int n = md.getColumnCount(); + List> rows = new ArrayList<>(); + while (rs.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= n; i++) row.put(md.getColumnLabel(i), rs.getObject(i)); + rows.add(row); + } + return rows; + } + + private static String safeIdent(String s) { + if (s == null || !SAFE_IDENT.matcher(s).matches()) { + throw new IllegalArgumentException("Unsafe identifier: " + s); + } + return s; + } + + private static String str(Object v) { return v == null ? null : v.toString(); } + private static String strOr(Object v, String fallback) { + String s = str(v); + return (s == null || s.isEmpty()) ? fallback : s; + } + + // ── 결과 클래스 ──────────────────────────────────────────────────────── + + public static final class ExecutionResult { + public int totalRecords = 0; + public int successRecords = 0; + public int failedRecords = 0; + public final List errorMessages = new ArrayList<>(); + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("total_records", totalRecords); + m.put("success_records", successRecords); + m.put("failed_records", failedRecords); + m.put("error_message", errorMessages.isEmpty() ? null : String.join("\n", errorMessages)); + return m; + } + } + + public static final class WriteResult { + public int success = 0; + public int failed = 0; + } +} diff --git a/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java b/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java new file mode 100644 index 00000000..a9129a3d --- /dev/null +++ b/backend-spring/src/main/java/com/erp/batch/MappingTransformer.java @@ -0,0 +1,179 @@ +package com.erp.batch; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 매핑 변환 유틸리티 — vexplor_rps 의 batchSchedulerService L550~617 .map() 로직 1:1 이식. + * + * BatchExecutor 가 FROM 에서 읽은 row 들을 TO 형태로 변환할 때 사용. 의존성 없는 정적 메서드만. + * + * mapping_type 분기: + * - "direct" : row[from_column_name] → row[to_column_name] 그대로 복사 + * from_column_name 은 점 표기법 지원 (예: "response.access_token") + * - "fixed" : from_column_name 자체가 고정값. transformRow 는 fixed 매핑을 건너뛰고, + * 호출측이 partition 한 뒤 mappedRow 에 적용 (vexplor_rps L598-603 패턴). + * - "conditional" : ConditionalConfig.rules 의 when 과 sourceVal 문자열 동등 비교, 매칭 then 반환. + * 매칭 없으면 default. (단순 문자열 lookup. SpEL/JEXL 등 표현식 평가 안 함) + */ +@Slf4j +public final class MappingTransformer { + + private static final ObjectMapper OM = new ObjectMapper(); + + private MappingTransformer() {} + + /** 단일 row 를 매핑 룰에 따라 변환. mapping_type 별 분기 처리. */ + public static Map transformRow( + Map row, + List> nonFixedMappings, + List> fixedMappings, + String toConnectionType, + String companyCode + ) { + Map mappedRow = new LinkedHashMap<>(); + + for (Map mapping : nonFixedMappings) { + String mt = strOr(mapping.get("mapping_type"), "direct"); + String fromCol = str(mapping.get("from_column_name")); + String toCol = str(mapping.get("to_column_name")); + + if ("conditional".equals(mt)) { + ConditionalConfig cfg = parseConditionalConfig(mapping.get("mapping_config")); + String sourceVal = String.valueOf(getValueByPath(row, fromCol)); + if (sourceVal == null || "null".equals(sourceVal)) sourceVal = ""; + mappedRow.put(toCol, evaluateConditional(sourceVal, cfg)); + continue; + } + + // direct 또는 알 수 없는 type — 그대로 복사 + // DB→REST 의 to_api_body 템플릿 처리는 BatchExecutor 측에서 별도 처리 (vexplor_rps L582~595). + // 여기서는 단순 to_column_name 으로 값 흘림. + Object value = getValueByPath(row, fromCol); + mappedRow.put(toCol, value); + } + + // 고정값 매핑 적용 — from_column_name 자체가 저장값 (vexplor_rps L598-603) + if (fixedMappings != null) { + for (Map fm : fixedMappings) { + mappedRow.put(str(fm.get("to_column_name")), fm.get("from_column_name")); + } + } + + // 멀티테넌시: TO 가 DB 일 때 company_code 자동 주입 (vexplor_rps L605-614) + if (!"restapi".equals(toConnectionType) + && companyCode != null + && !mappedRow.containsKey("company_code")) { + mappedRow.put("company_code", companyCode); + } + + return mappedRow; + } + + /** 점 표기법 path 평가 — "response.access_token" 같은 중첩 키 지원 (vexplor_rps L540-548). */ + @SuppressWarnings("unchecked") + public static Object getValueByPath(Map obj, String path) { + if (obj == null || path == null || path.isEmpty()) return null; + if (!path.contains(".")) return obj.get(path); + Object cur = obj; + for (String part : path.split("\\.")) { + if (!(cur instanceof Map)) return null; + cur = ((Map) cur).get(part); + if (cur == null) return null; + } + return cur; + } + + /** ConditionalConfig 단일 평가 — when/then lookup + default. */ + public static Object evaluateConditional(String sourceVal, ConditionalConfig cfg) { + if (cfg == null || cfg.rules == null) return cfg != null ? cfg.defaultValue : null; + for (ConditionalRule r : cfg.rules) { + String when = r.when == null ? "" : r.when; + if (Objects.equals(when, sourceVal)) return r.then; + } + return cfg.defaultValue; + } + + /** + * mapping_config (JSONB) 의 원시 값 → ConditionalConfig. + * - BatchService.attachMappings 가 이미 파싱한 경우 → Map + * - 직접 SELECT 결과 → String(JSON) 가능 + * - null → 빈 cfg + */ + @SuppressWarnings("unchecked") + public static ConditionalConfig parseConditionalConfig(Object raw) { + if (raw == null) return ConditionalConfig.empty(); + Map map; + try { + if (raw instanceof Map) { + map = (Map) raw; + } else if (raw instanceof String) { + String s = ((String) raw).trim(); + if (s.isEmpty()) return ConditionalConfig.empty(); + map = OM.readValue(s, Map.class); + } else { + return ConditionalConfig.empty(); + } + } catch (Exception e) { + log.warn("[conditional 매핑] JSON 파싱 실패: {}", e.getMessage()); + return ConditionalConfig.empty(); + } + + ConditionalConfig cfg = new ConditionalConfig(); + Object rulesRaw = map.get("rules"); + if (rulesRaw instanceof List) { + for (Object r : (List) rulesRaw) { + if (r instanceof Map) { + Map rm = (Map) r; + cfg.rules.add(new ConditionalRule( + rm.get("when") == null ? "" : String.valueOf(rm.get("when")), + rm.get("then") == null ? null : String.valueOf(rm.get("then")) + )); + } + } + } + Object def = map.get("default"); + cfg.defaultValue = def == null ? null : String.valueOf(def); + return cfg; + } + + /** non-fixed / fixed 매핑 분리. vexplor_rps L265~271 partition 패턴. */ + public static Partition partitionFixed(List> mappings) { + Partition p = new Partition(); + if (mappings == null) return p; + for (Map m : mappings) { + String mt = strOr(m.get("mapping_type"), "direct"); + if ("fixed".equals(mt)) p.fixed.add(m); else p.nonFixed.add(m); + } + return p; + } + + public static final class Partition { + public final List> nonFixed = new ArrayList<>(); + public final List> fixed = new ArrayList<>(); + } + + public static final class ConditionalConfig { + public List rules = new ArrayList<>(); + public String defaultValue; + public static ConditionalConfig empty() { return new ConditionalConfig(); } + } + + public static final class ConditionalRule { + public final String when; + public final String then; + public ConditionalRule(String when, String then) { this.when = when; this.then = then; } + } + + private static String str(Object v) { return v == null ? null : v.toString(); } + private static String strOr(Object v, String fallback) { + String s = str(v); + return (s == null || s.isEmpty()) ? fallback : s; + } +} diff --git a/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java new file mode 100644 index 00000000..59955af4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java @@ -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 USER_SELECTABLE_INPUT_TYPES = Set.of( + "text", "number", "date", "code", "entity", + "numbering", "file", "image" + ); +} diff --git a/backend-spring/src/main/java/com/erp/constants/InputTypeContext.java b/backend-spring/src/main/java/com/erp/constants/InputTypeContext.java new file mode 100644 index 00000000..3eeb66df --- /dev/null +++ b/backend-spring/src/main/java/com/erp/constants/InputTypeContext.java @@ -0,0 +1,8 @@ +package com.erp.constants; + +public enum InputTypeContext { + USER_INSERT, + USER_UPDATE_TYPE, + USER_UPDATE_OTHER, + SYSTEM_NORMALIZE +} diff --git a/backend-spring/src/main/java/com/erp/controller/AdminController.java b/backend-spring/src/main/java/com/erp/controller/AdminController.java index 978ad236..a1c8d745 100644 --- a/backend-spring/src/main/java/com/erp/controller/AdminController.java +++ b/backend-spring/src/main/java/com/erp/controller/AdminController.java @@ -295,7 +295,8 @@ public class AdminController { @PostMapping("/users/reset-password") public ResponseEntity> resetUserPassword(@RequestBody Map 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, "비밀번호 초기화 성공")); } diff --git a/backend-spring/src/main/java/com/erp/controller/ApprovalController.java b/backend-spring/src/main/java/com/erp/controller/ApprovalController.java index adc270a5..0e8d2e9b 100644 --- a/backend-spring/src/main/java/com/erp/controller/ApprovalController.java +++ b/backend-spring/src/main/java/com/erp/controller/ApprovalController.java @@ -190,9 +190,11 @@ public class ApprovalController { public ResponseEntity>> getRequests( @RequestParam Map params, @RequestAttribute("company_code") String companyCode, - @RequestAttribute("user_id") String userId) { + @RequestAttribute("user_id") String userId, + @RequestAttribute(name = "effective_user_ids", required = false) List 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>>> getMyPendingLines( @RequestAttribute("company_code") String companyCode, - @RequestAttribute("user_id") String userId) { + @RequestAttribute("user_id") String userId, + @RequestAttribute(name = "effective_user_ids", required = false) List effectiveUserIds) { Map 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))); } diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index 7ee10c98..7355b011 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -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>>> 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> departments = departmentService.getDepartments(companyCode, includeDeleted); + List> 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>> 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>> updateDepartment( diff --git a/backend-spring/src/main/java/com/erp/controller/FavoritesController.java b/backend-spring/src/main/java/com/erp/controller/FavoritesController.java new file mode 100644 index 00000000..f4e8e5b3 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/FavoritesController.java @@ -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>>> getMyFavorites( + @RequestAttribute("user_id") String userId) { + Map 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>> addFavorite( + @RequestAttribute("user_id") String userId, + @RequestBody Map body) { + Object menuObjid = body.get("menu_objid"); + if (menuObjid == null || String.valueOf(menuObjid).isBlank()) { + return ResponseEntity.badRequest().body(ApiResponse.error("menu_objid 필수입니다.")); + } + Map 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>> removeFavorite( + @RequestAttribute("user_id") String userId, + @PathVariable String menuObjid) { + Map params = new HashMap<>(); + params.put("user_id", userId); + params.put("menu_objid", menuObjid); + int affected = favoritesService.deleteFavorite(params); + Map result = new HashMap<>(); + result.put("deleted", affected); + return ResponseEntity.ok(ApiResponse.success(result, "즐겨찾기 제거 성공")); + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/SubstituteController.java b/backend-spring/src/main/java/com/erp/controller/SubstituteController.java new file mode 100644 index 00000000..5f5d8844 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/SubstituteController.java @@ -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>> getList( + @RequestParam Map 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>> 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 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>> getMine( + @RequestAttribute("user_id") String userId, + @RequestAttribute("company_code") String companyCode) { + Map params = new HashMap<>(); + params.put("user_id", userId); + params.put("company_code", companyCode); + return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params))); + } + + // ───────────────────────────────────────────────────────────── + // 변경 — 관리자 + // ───────────────────────────────────────────────────────────── + + @PostMapping + public ResponseEntity>> create( + @RequestBody Map 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 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>> update( + @PathVariable("id") Long substituteId, + @RequestBody Map 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> delete( + @PathVariable("id") Long substituteId, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + Map 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>> checkOverlap( + @RequestBody Map body, + @RequestAttribute("company_code") String companyCode) { + body.put("company_code", companyCode); + int cnt = substituteService.checkOverlap(body); + Map result = new HashMap<>(); + result.put("overlap", cnt > 0); + result.put("count", cnt); + return ResponseEntity.ok(ApiResponse.success(result)); + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java index 46c1e909..9e531cf7 100644 --- a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java +++ b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java @@ -75,7 +75,11 @@ public class TableManagementController { @PutMapping("/tables/{tableName}/label") public ResponseEntity> updateTableLabel( @PathVariable String tableName, - @RequestBody Map body) { + @RequestBody Map 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 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 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> updateAllColumnSettingsPost( @PathVariable String tableName, @RequestBody List> 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> updateAllColumnSettingsBatch( @PathVariable String tableName, @RequestBody List> 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> updateColumnWebType( @PathVariable String tableName, @PathVariable String columnName, - @RequestBody Map body) { + @RequestBody Map 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 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> setTablePrimaryKey( @PathVariable String tableName, - @RequestBody Map body) { + @RequestBody Map body, + @RequestAttribute("role") String role) { + if (!isSuperAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); + } @SuppressWarnings("unchecked") List columns = (List) body.get("columns"); if (tableName == null || columns == null || columns.isEmpty()) { @@ -256,7 +288,11 @@ public class TableManagementController { @PostMapping("/tables/{tableName}/indexes") public ResponseEntity> toggleTableIndex( @PathVariable String tableName, - @RequestBody Map body) { + @RequestBody Map 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 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 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>> addTableData( @PathVariable String tableName, @RequestBody Map 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> editTableData( @PathVariable String tableName, @RequestBody Map body, + @RequestAttribute("role") String role, @RequestAttribute("company_code") String companyCode) { + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } @SuppressWarnings("unchecked") Map originalData = (Map) body.get("original_data"); @SuppressWarnings("unchecked") @@ -484,7 +536,11 @@ public class TableManagementController { @DeleteMapping("/tables/{tableName}/delete") public ResponseEntity> 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> dataList; if (body instanceof List) { @SuppressWarnings("unchecked") @@ -508,7 +564,11 @@ public class TableManagementController { @PostMapping("/tables/{tableName}/log") public ResponseEntity> createLogTable( @PathVariable String tableName, - @RequestBody Map body) { + @RequestBody Map body, + @RequestAttribute("role") String role) { + if (!isSuperAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); + } @SuppressWarnings("unchecked") List logColumns = (List) 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> toggleLogTable( @PathVariable String tableName, - @RequestBody Map body) { + @RequestBody Map 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>> multiTableSave( @RequestBody Map 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); + } } diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index 44ec7040..4f0c05bf 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -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 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); } diff --git a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java index 7a88baee..7f8859f5 100644 --- a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java +++ b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java @@ -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(); } diff --git a/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java new file mode 100644 index 00000000..a9e947f2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java @@ -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 effectiveIds = new ArrayList<>(); + effectiveIds.add(userId); + + try { + List 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); + } +} diff --git a/backend-spring/src/main/java/com/erp/service/AdminService.java b/backend-spring/src/main/java/com/erp/service/AdminService.java index a83fc7a6..749ed51e 100644 --- a/backend-spring/src/main/java/com/erp/service/AdminService.java +++ b/backend-spring/src/main/java/com/erp/service/AdminService.java @@ -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 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); } diff --git a/backend-spring/src/main/java/com/erp/service/ApprovalService.java b/backend-spring/src/main/java/com/erp/service/ApprovalService.java index d3933f02..964e5669 100644 --- a/backend-spring/src/main/java/com/erp/service/ApprovalService.java +++ b/backend-spring/src/main/java/com/erp/service/ApprovalService.java @@ -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 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 getRequests(Map 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> getMyPendingLines(Map 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 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()); + } } // ═══════════════════════════════════════════════════════════════ diff --git a/backend-spring/src/main/java/com/erp/service/AuditLogService.java b/backend-spring/src/main/java/com/erp/service/AuditLogService.java index 0a8d08ec..ca006c8a 100644 --- a/backend-spring/src/main/java/com/erp/service/AuditLogService.java +++ b/backend-spring/src/main/java/com/erp/service/AuditLogService.java @@ -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 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 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); } diff --git a/backend-spring/src/main/java/com/erp/service/BatchManagementService.java b/backend-spring/src/main/java/com/erp/service/BatchManagementService.java index 4fc7f9d8..2ee2786c 100644 --- a/backend-spring/src/main/java/com/erp/service/BatchManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/BatchManagementService.java @@ -1,5 +1,6 @@ package com.erp.service; +import com.erp.batch.BatchExecutor; import com.erp.common.BaseService; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -24,8 +25,11 @@ public class BatchManagementService extends BaseService { private CommonService commonService; @Autowired private ObjectMapper objectMapper; + @Autowired + private BatchExecutor batchExecutor; private static final String NS = "batchManagement."; + private static final String EXEC_LOG_NS = "batchExecutionLog."; // ── Stats ───────────────────────────────────────────────────────────────── @@ -113,24 +117,102 @@ public class BatchManagementService extends BaseService { Map batchConfig = batchService.getBatchInfo(params); if (batchConfig == null) throw new RuntimeException("배치 설정을 찾을 수 없습니다."); - long startTime = System.currentTimeMillis(); + long startMs = System.currentTimeMillis(); String batchName = str(batchConfig.get("batch_name")); + String companyCode = str(batchConfig.get("company_code")); log.info("배치 수동 실행: id={}, name={}", id, batchName); - long duration = System.currentTimeMillis() - startTime; + // 1. 실행 로그 INSERT — RUNNING 상태로 먼저 박아두면 도중 비정상 종료해도 추적 가능 + Map logRow = new LinkedHashMap<>(); + logRow.put("batch_config_id", id); + logRow.put("company_code", companyCode); + logRow.put("execution_status", "RUNNING"); + logRow.put("server_name", safeHostName()); + logRow.put("process_id", String.valueOf(ProcessHandle.current().pid())); + try { + sqlSession.insert(EXEC_LOG_NS + "insertBatchExecutionLog", logRow); + } catch (Exception e) { + log.warn("실행 로그 INSERT 실패 (실행은 계속 진행): {}", e.getMessage()); + } + Object logId = logRow.get("id"); + + // 2. 실제 ETL 실행 — 예외는 로그에 기록 후 다시 throw (controller 의 에러 응답 위해) + BatchExecutor.ExecutionResult execResult = null; + String status = "SUCCESS"; + String errorMessage = null; + try { + execResult = batchExecutor.execute(batchConfig); + if (execResult.failedRecords > 0) { + status = execResult.successRecords > 0 ? "PARTIAL" : "FAILED"; + } + if (!execResult.errorMessages.isEmpty()) { + errorMessage = String.join("\n", execResult.errorMessages); + } + } catch (Exception e) { + status = "FAILED"; + errorMessage = e.getMessage(); + log.error("배치 실행 중 예외: id={} — {}", id, e.getMessage(), e); + } + + long duration = System.currentTimeMillis() - startMs; + + // 3. 실행 로그 UPDATE — 최종 상태/카운트/duration 마무리 + // 주의: batch_execution_logs 의 duration_ms / *_records 컬럼은 운영 DB 에서 VARCHAR + // (V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 로 자동 변환하지 못할 수 있어 + // 명시적으로 String 으로 보낸다. mapper 의 COALESCE default 도 '0' (문자열) 이라 일관됨. + if (logId != null) { + Map updateLog = new LinkedHashMap<>(); + updateLog.put("id", logId); + updateLog.put("execution_status", status); + updateLog.put("end_time", new java.sql.Timestamp(System.currentTimeMillis())); + updateLog.put("duration_ms", String.valueOf(duration)); + updateLog.put("total_records", String.valueOf(execResult != null ? execResult.totalRecords : 0)); + updateLog.put("success_records", String.valueOf(execResult != null ? execResult.successRecords : 0)); + updateLog.put("failed_records", String.valueOf(execResult != null ? execResult.failedRecords : 0)); + if (errorMessage != null) updateLog.put("error_message", errorMessage); + try { + sqlSession.update(EXEC_LOG_NS + "updateBatchExecutionLog", updateLog); + } catch (Exception e) { + log.warn("실행 로그 UPDATE 실패: {}", e.getMessage()); + } + } Map result = new LinkedHashMap<>(); result.put("batch_name", batchName); - result.put("total_records", 0); - result.put("success_records", 0); - result.put("failed_records", 0); + result.put("execution_status", status); + result.put("total_records", execResult != null ? execResult.totalRecords : 0); + result.put("success_records", execResult != null ? execResult.successRecords : 0); + result.put("failed_records", execResult != null ? execResult.failedRecords : 0); result.put("execution_time", duration); + if (errorMessage != null) result.put("error_message", errorMessage); return result; } + /** 실행 로그 server_name 컬럼용 — hostname resolve 실패 시 "unknown". */ + private static String safeHostName() { + try { + return java.net.InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return "unknown"; + } + } + // ── REST API Preview / Save ─────────────────────────────────────────────── public Map previewRestApiData(Map body) { + // 프론트(batchManagement.ts)는 camelCase 로 키를 보내고 백엔드는 snake_case 로 읽음. + // 기존 convertCamelToSnake() 는 batch_configs 전용 remap 이라 여기엔 효과 없음. + // → previewRestApiData 전용으로 사용하는 키만 직접 remap. + remap(body, "apiUrl", "api_url"); + remap(body, "apiKey", "api_key"); + remap(body, "requestBody", "request_body"); + remap(body, "dataArrayPath", "data_array_path"); + remap(body, "paramType", "param_type"); + remap(body, "paramName", "param_name"); + remap(body, "paramValue", "param_value"); + remap(body, "paramSource", "param_source"); + remap(body, "authServiceName", "auth_service_name"); + String apiUrl = str(body.get("api_url")); String endpoint = str(body.get("endpoint")); String method = body.get("method") != null ? str(body.get("method")) : "GET"; diff --git a/backend-spring/src/main/java/com/erp/service/BatchService.java b/backend-spring/src/main/java/com/erp/service/BatchService.java index 83e5cd71..eed8679d 100644 --- a/backend-spring/src/main/java/com/erp/service/BatchService.java +++ b/backend-spring/src/main/java/com/erp/service/BatchService.java @@ -1,6 +1,7 @@ package com.erp.service; import com.erp.common.BaseService; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -15,6 +16,9 @@ public class BatchService extends BaseService { @Autowired private CommonService commonService; + @Autowired + private ObjectMapper objectMapper; + private static final String NS = "batch."; private static final String EXT_NS = "externalDbConnection."; @@ -29,7 +33,11 @@ public class BatchService extends BaseService { public Map getBatchInfo(Map params) { commonService.applyCompanyCodeFilter(params); - return sqlSession.selectOne(NS + "getBatchInfo", params); + Map batch = sqlSession.selectOne(NS + "getBatchInfo", params); + if (batch != null) { + attachMappings(batch); + } + return batch; } @Transactional @@ -37,9 +45,18 @@ public class BatchService extends BaseService { sqlSession.insert(NS + "insertBatch", params); Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null; if (id != null) { + // batch_configs INSERT 직후 mappings 동기화 (params 에 mappings 키가 있을 때만) + if (params.containsKey("mappings")) { + syncMappings(id, + toStr(params.get("company_code")), + toMappingList(params.get("mappings")), + toStr(params.get("created_by"))); + } Map infoParams = new HashMap<>(); infoParams.put("id", id); - return sqlSession.selectOne(NS + "getBatchInfo", infoParams); + Map result = sqlSession.selectOne(NS + "getBatchInfo", infoParams); + if (result != null) attachMappings(result); + return result; } return params; } @@ -48,9 +65,89 @@ public class BatchService extends BaseService { public Map updateBatch(Map params) { commonService.applyCompanyCodeFilter(params); sqlSession.update(NS + "updateBatch", params); + Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null; + // replace-all: body 에 mappings 키가 들어왔으면 (빈 배열 포함) 매핑 전체 교체 + if (id != null && params.containsKey("mappings")) { + syncMappings(id, + toStr(params.get("company_code")), + toMappingList(params.get("mappings")), + toStr(params.get("updated_by") != null ? params.get("updated_by") : params.get("created_by"))); + } Map infoParams = new HashMap<>(); infoParams.put("id", params.get("id")); - return sqlSession.selectOne(NS + "getBatchInfo", infoParams); + Map result = sqlSession.selectOne(NS + "getBatchInfo", infoParams); + if (result != null) attachMappings(result); + return result; + } + + // ── batch_mappings replace-all 동기화 ───────────────────────────────────── + + /** batch_config_id 의 매핑을 전부 지우고 mappings 리스트로 다시 채운다. */ + private void syncMappings(Long batchConfigId, String companyCode, + List> mappings, String userId) { + Map delParams = new HashMap<>(); + delParams.put("batch_config_id", batchConfigId); + sqlSession.delete(NS + "deleteBatchMappingsByConfigId", delParams); + + if (mappings == null || mappings.isEmpty()) return; + + for (int i = 0; i < mappings.size(); i++) { + Map row = new HashMap<>(mappings.get(i)); + row.put("batch_config_id", batchConfigId); + if (row.get("company_code") == null) row.put("company_code", companyCode); + if (row.get("created_by") == null) row.put("created_by", userId); + if (row.get("mapping_order") == null) row.put("mapping_order", i + 1); + stringifyJsonField(row, "mapping_config"); + sqlSession.insert(NS + "insertBatchMapping", row); + } + } + + /** getBatchInfo 결과에 batch_mappings 리스트 attach. */ + private void attachMappings(Map batch) { + Object idObj = batch.get("id"); + if (idObj == null) return; + Map params = new HashMap<>(); + params.put("batch_config_id", idObj); + List> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params); + if (mappings != null) { + for (Map row : mappings) parseJsonField(row, "mapping_config"); + } + batch.put("batch_mappings", mappings != null ? mappings : new ArrayList<>()); + } + + /** JSONB → 객체. SELECT 결과의 TEXT cast 값을 파싱해 Map/List 로 되돌린다. */ + private void parseJsonField(Map row, String key) { + Object val = row.get(key); + if (val instanceof String && !((String) val).isEmpty()) { + try { + row.put(key, objectMapper.readValue((String) val, Object.class)); + } catch (Exception e) { + log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage()); + } + } + } + + /** 객체 → JSON 문자열. INSERT 전 ::jsonb 캐스팅을 위해 직렬화한다. null 은 그대로 둠. */ + private void stringifyJsonField(Map params, String key) { + Object val = params.get(key); + if (val == null || val instanceof String) return; + try { + params.put(key, objectMapper.writeValueAsString(val)); + } catch (Exception e) { + log.warn("Failed to stringify field '{}': {}", key, e.getMessage()); + params.put(key, null); + } + } + + @SuppressWarnings("unchecked") + private List> toMappingList(Object raw) { + if (raw == null) return new ArrayList<>(); + if (raw instanceof List) return (List>) raw; + return new ArrayList<>(); + } + + private String toStr(Object v) { + return v != null ? v.toString() : null; } @Transactional diff --git a/backend-spring/src/main/java/com/erp/service/DdlService.java b/backend-spring/src/main/java/com/erp/service/DdlService.java index 182174d5..745b810e 100644 --- a/backend-spring/src/main/java/com/erp/service/DdlService.java +++ b/backend-spring/src/main/java/com/erp/service/DdlService.java @@ -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 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"; }; } diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index e774d84c..46719035 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -20,17 +20,22 @@ public class DepartmentService extends BaseService { // ────────────────────────────────────────────────── public List> getDepartments(String companyCode) { - return getDepartments(companyCode, false); + return getDepartments(companyCode, false, null); } /** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */ public List> getDepartments(String companyCode, boolean includeDeleted) { + return getDepartments(companyCode, includeDeleted, null); + } + + /** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */ + public List> getDepartments(String companyCode, boolean includeDeleted, String baseDate) { Map params = new HashMap<>(); params.put("company_code", companyCode); params.put("include_deleted", includeDeleted); + params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip List> departments = sqlSession.selectList("department.selectDepartments", params); - // member_count를 int로 변환 for (Map 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 으로 파싱 + 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 getDepartment(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); - return sqlSession.selectOne("department.selectDepartmentByCode", params); + Map 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 getDepartmentIncludingDeleted(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); - return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params); + Map 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 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 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 dept, String key) { + Object raw = dept.get(key); + if (raw == null) { + dept.put(key, new java.util.ArrayList>()); + 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>()); + return; + } + try { + @SuppressWarnings("unchecked") + java.util.List> parsed = JSON_MAPPER.readValue(s, + new com.fasterxml.jackson.core.type.TypeReference>>() {}); + 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>()); + } + } + + /** + * 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴. + * body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders". + * 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출. + * 최대 10명 검증 + 빈 user_id 무시. + */ + private void syncManagers(String deptCode, String companyCode, Map 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 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 vParams = new HashMap<>(); + vParams.put("user_ids", userIds); + vParams.put("company_code", companyCode); + List validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams); + if (validUserIds == null || validUserIds.size() != userIds.size()) { + Set invalid = new HashSet<>(userIds); + if (validUserIds != null) invalid.removeAll(validUserIds); + throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid); + } + } + // delete-all + Map delParams = new HashMap<>(); + delParams.put("dept_code", deptCode); + delParams.put("role", role); + sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams); + // insert-all + if (!userIds.isEmpty()) { + Map 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 { diff --git a/backend-spring/src/main/java/com/erp/service/FavoritesService.java b/backend-spring/src/main/java/com/erp/service/FavoritesService.java new file mode 100644 index 00000000..0fed9f67 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/FavoritesService.java @@ -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> getFavoriteMenuList(Map params) { + return sqlSession.selectList("favorites.selectFavoriteMenuList", params); + } + + @Transactional + public Map insertFavorite(Map params) { + sqlSession.insert("favorites.insertFavorite", params); + Map 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 params) { + return sqlSession.delete("favorites.deleteFavorite", params); + } + + public boolean exists(Map params) { + Integer cnt = sqlSession.selectOne("favorites.selectFavoriteExists", params); + return cnt != null && cnt > 0; + } +} diff --git a/backend-spring/src/main/java/com/erp/service/SubstituteService.java b/backend-spring/src/main/java/com/erp/service/SubstituteService.java new file mode 100644 index 00000000..91dd408f --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/SubstituteService.java @@ -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 getSubstituteList(Map params) { + requireAdmin(params); + + List> list = sqlSession.selectList(NS + "selectSubstituteList", params); + Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params); + + Map 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 getMySubstitutes(Map params) { + if (params.get("user_id") == null) { + throw new IllegalArgumentException("user_id 가 필요합니다."); + } + + List> rows = sqlSession.selectList(NS + "selectMySubstitutes", params); + + List> proxyingForMe = new ArrayList<>(); + List> myProxies = new ArrayList<>(); + for (Map 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 result = new HashMap<>(); + result.put("proxying_for_me", proxyingForMe); + result.put("my_proxies", myProxies); + return result; + } + + /** + * SubstituteContextFilter 핫 패스. 트랜잭션 없이 가볍게. + * 반환: B 가 현재 대무 중인 A 의 ID 목록 (없으면 빈 리스트). + */ + public List getActiveOriginalUserIds(String proxyUserId, String companyCode) { + if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) { + return List.of(); + } + Map p = new HashMap<>(); + p.put("proxy_user_id", proxyUserId); + p.put("company_code", companyCode); + List ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p); + return ids == null ? List.of() : ids; + } + + public Map getSubstituteInfo(Map params) { + Map row = sqlSession.selectOne(NS + "selectSubstituteInfo", params); + if (row == null) { + throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다."); + } + return row; + } + + /** + * ApprovalService 어댑터: B 가 A 의 대무자로 활성 상태인지 검증. + * 결재 처리 중 호출. + */ + public Map getActiveProxyForLine(Map params) { + return sqlSession.selectOne(NS + "selectActiveProxyForLine", params); + } + + public int checkOverlap(Map params) { + Integer cnt = sqlSession.selectOne(NS + "countOverlap", params); + return cnt == null ? 0 : cnt; + } + + // ───────────────────────────────────────────────────────────── + // 변경 + // ───────────────────────────────────────────────────────────── + + public Map insertSubstitute(Map params) { + requireAdmin(params); + validateInsertParams(params); + + sqlSession.insert(NS + "insertSubstitute", params); + + Map info = new HashMap<>(); + info.put("substitute_id", params.get("substitute_id")); + info.put("company_code", params.get("company_code")); + return getSubstituteInfo(info); + } + + public Map updateSubstitute(Map params) { + requireAdmin(params); + + Map 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 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 params) { + requireAdmin(params); + getSubstituteInfo(params); // 존재 확인 + sqlSession.delete(NS + "deleteSubstitute", params); + } + + // ───────────────────────────────────────────────────────────── + // 검증 + // ───────────────────────────────────────────────────────────── + + private void validateInsertParams(Map 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 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 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 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 params) { + String role = (String) params.get("role"); + if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) { + throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다."); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/service/TableManagementService.java b/backend-spring/src/main/java/com/erp/service/TableManagementService.java index 4e67c56d..8733a6c9 100644 --- a/backend-spring/src/main/java/com/erp/service/TableManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/TableManagementService.java @@ -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 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 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 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 detailSettings) { - String finalType = normalizeInputType(inputType); + String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE); Map 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 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 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 targetCols = (logColumns != null && !logColumns.isEmpty()) - ? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList()) + List 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 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 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; diff --git a/backend-spring/src/main/resources/db/migration/V020__create_user_menu_favorites.sql b/backend-spring/src/main/resources/db/migration/V020__create_user_menu_favorites.sql new file mode 100644 index 00000000..4c0e5cd4 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V020__create_user_menu_favorites.sql @@ -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); diff --git a/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql new file mode 100644 index 00000000..2ee3c7da --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql @@ -0,0 +1,7 @@ +-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가 +-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다. +-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도 +-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다. + +ALTER TABLE BATCH_MAPPINGS + ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB; diff --git a/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql b/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql new file mode 100644 index 00000000..f44f1db1 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql @@ -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); diff --git a/backend-spring/src/main/resources/mapper/approval.xml b/backend-spring/src/main/resources/mapper/approval.xml index 1259b5e9..aad08c1e 100644 --- a/backend-spring/src/main/resources/mapper/approval.xml +++ b/backend-spring/src/main/resources/mapper/approval.xml @@ -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 + + #{uid} + AND L.STATUS = 'pending' AND L.COMPANY_CODE = R.COMPANY_CODE ) - ORDER BY R.CREATED_DATE DESC + ORDER BY R.CREATED_AT DESC LIMIT #{page_limit} OFFSET #{page_offset} @@ -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 + + #{uid} + 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 + + #{uid} + 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 - + 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} ) + + + diff --git a/backend-spring/src/main/resources/mapper/batch.xml b/backend-spring/src/main/resources/mapper/batch.xml index dc599c6a..5793e2f4 100644 --- a/backend-spring/src/main/resources/mapper/batch.xml +++ b/backend-spring/src/main/resources/mapper/batch.xml @@ -102,6 +102,117 @@ + + + + + + INSERT INTO BATCH_MAPPINGS ( + BATCH_CONFIG_ID + , COMPANY_CODE + , FROM_CONNECTION_TYPE + , FROM_CONNECTION_ID + , FROM_TABLE_NAME + , FROM_COLUMN_NAME + , FROM_COLUMN_TYPE + , FROM_API_URL + , FROM_API_KEY + , FROM_API_METHOD + , FROM_API_PARAM_TYPE + , FROM_API_PARAM_NAME + , FROM_API_PARAM_VALUE + , FROM_API_PARAM_SOURCE + , FROM_API_BODY + , TO_CONNECTION_TYPE + , TO_CONNECTION_ID + , TO_TABLE_NAME + , TO_COLUMN_NAME + , TO_COLUMN_TYPE + , TO_API_URL + , TO_API_KEY + , TO_API_METHOD + , TO_API_BODY + , MAPPING_ORDER + , MAPPING_TYPE + , MAPPING_CONFIG + , CREATED_BY + , CREATED_DATE + ) VALUES ( + #{batch_config_id}::varchar + , #{company_code} + , #{from_connection_type} + , #{from_connection_id} + , #{from_table_name} + , #{from_column_name} + , #{from_column_type} + , #{from_api_url} + , #{from_api_key} + , #{from_api_method} + , #{from_api_param_type} + , #{from_api_param_name} + , #{from_api_param_value} + , #{from_api_param_source} + , #{from_api_body} + , #{to_connection_type} + , #{to_connection_id} + , #{to_table_name} + , #{to_column_name} + , #{to_column_type} + , #{to_api_url} + , #{to_api_key} + , #{to_api_method} + , #{to_api_body} + , #{mapping_order} + , + #{mapping_type} + 'direct' + + , #{mapping_config,jdbcType=OTHER}::jsonb + , #{created_by} + , NOW() + ) + + + + + DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar + + SELECT * FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar ORDER BY start_time DESC LIMIT 1 @@ -106,7 +106,7 @@ WHERE 1=1 - AND batch_config_id = #{batch_config_id} + AND batch_config_id = #{batch_config_id}::varchar AND start_time >= #{start_date}::timestamp @@ -123,7 +123,7 @@ total_records, success_records, failed_records, error_message, error_details, server_name, process_id ) VALUES ( - #{batch_config_id}, #{company_code}, #{execution_status}, + #{batch_config_id}::varchar, #{company_code}, #{execution_status}, COALESCE(#{start_time}::timestamp, NOW()), #{end_time}::timestamp, #{duration_ms}, diff --git a/backend-spring/src/main/resources/mapper/batchManagement.xml b/backend-spring/src/main/resources/mapper/batchManagement.xml index 414a215a..304987ec 100644 --- a/backend-spring/src/main/resources/mapper/batchManagement.xml +++ b/backend-spring/src/main/resources/mapper/batchManagement.xml @@ -15,14 +15,14 @@ execution_today AS ( SELECT COUNT(*) AS today_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed - FROM batch_execution_log + FROM batch_execution_logs WHERE DATE(start_time) = CURRENT_DATE ), execution_yesterday AS ( SELECT COUNT(*) AS yesterday_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed - FROM batch_execution_log + FROM batch_execution_logs WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day' ) @@ -77,9 +77,9 @@ SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count - FROM batch_execution_log + FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar AND start_time >= NOW() - INTERVAL '24 hours' GROUP BY DATE_TRUNC('hour', start_time) @@ -100,9 +100,9 @@ failed_records, error_message - FROM batch_execution_log + FROM batch_execution_logs - WHERE batch_config_id = #{batch_config_id} + WHERE batch_config_id = #{batch_config_id}::varchar ORDER BY start_time DESC LIMIT 20 diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index be469617..054a75d5 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -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 = '*') AND D.DELETED_AT IS NULL + + AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date) + AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date) + 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} @@ -302,4 +324,27 @@ AND DEPT_CODE = #{dept_code} + + + DELETE FROM DEPT_MANAGERS + WHERE DEPT_CODE = #{dept_code} + AND ROLE = #{role} + + + + + INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES + + (#{dept_code}, #{uid}, #{role}, #{idx} + 1) + + + + + + diff --git a/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml b/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml index 9b4434e8..dca8cf4f 100644 --- a/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml +++ b/backend-spring/src/main/resources/mapper/externalRestApiConnection.xml @@ -69,7 +69,7 @@ SELECT FROM EXTERNAL_REST_API_CONNECTIONS E - WHERE E.ID = #{id} + WHERE E.ID = #{id}::varchar @@ -133,14 +133,14 @@ SAVE_TO_HISTORY = #{save_to_history}, UPDATED_BY = #{updated_by}, - WHERE ID = #{id} + WHERE ID = #{id}::varchar DELETE FROM EXTERNAL_REST_API_CONNECTIONS - WHERE ID = #{id} + WHERE ID = #{id}::varchar @@ -151,7 +151,7 @@ LAST_TEST_DATE = NOW() , LAST_TEST_RESULT = #{last_test_result} , LAST_TEST_MESSAGE = #{last_test_message} - WHERE ID = #{id} + WHERE ID = #{id}::varchar diff --git a/backend-spring/src/main/resources/mapper/favorites.xml b/backend-spring/src/main/resources/mapper/favorites.xml new file mode 100644 index 00000000..959c3da6 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/favorites.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + 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 + + + + + DELETE FROM USER_MENU_FAVORITES + WHERE USER_ID = #{user_id} + AND MENU_OBJID = #{menu_objid} + + + + + + diff --git a/backend-spring/src/main/resources/mapper/substitute.xml b/backend-spring/src/main/resources/mapper/substitute.xml new file mode 100644 index 00000000..92eefa6f --- /dev/null +++ b/backend-spring/src/main/resources/mapper/substitute.xml @@ -0,0 +1,294 @@ + + + + + + + + + AND S.IS_ACTIVE = TRUE + AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE) + AND S.END_DATE >= CURRENT_DATE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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() + ) + + + + UPDATE USER_SUBSTITUTES + + + PROXY_USER_ID = #{proxy_user_id}, + + + START_DATE = CAST(#{start_date} AS DATE), + + + START_DATE = NULL, + + + END_DATE = CAST(#{end_date} AS DATE), + + + REASON = #{reason}, + + + IS_ACTIVE = #{is_active}, + + UPDATED_BY = #{updated_by}, + UPDATED_DATE = NOW() + + WHERE SUBSTITUTE_ID = #{substitute_id} + AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') + + + + DELETE FROM USER_SUBSTITUTES + WHERE SUBSTITUTE_ID = #{substitute_id} + AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') + + + diff --git a/backend-spring/src/main/resources/mapper/tableManagement.xml b/backend-spring/src/main/resources/mapper/tableManagement.xml index 239f6c8d..845fc12a 100644 --- a/backend-spring/src/main/resources/mapper/tableManagement.xml +++ b/backend-spring/src/main/resources/mapper/tableManagement.xml @@ -389,7 +389,7 @@ + onValueChange={(value: "api" | "fixed" | "conditional") => updateMappingListItem(mapping.id, { sourceType: value, - apiField: value === "fixed" ? "" : mapping.apiField, - fixedValue: value === "api" ? "" : mapping.fixedValue, + // 모드 전환 시 입력값 정리 + apiField: + value === "api" || value === "conditional" + ? mapping.apiField + : "", + fixedValue: value === "fixed" ? mapping.fixedValue : "", + conditionalConfig: + value === "conditional" + ? mapping.conditionalConfig || emptyConditionalConfig() + : mapping.conditionalConfig, }) } > @@ -1634,13 +1687,14 @@ export default function BatchEditPage() { API 필드 고정값 + 조건 변환 - {/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */} + {/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
- {mapping.sourceType === "api" ? ( + {mapping.sourceType === "api" && ( - ) : ( + )} + {mapping.sourceType === "fixed" && ( updateMappingListItem(mapping.id, { fixedValue: e.target.value })} @@ -1675,6 +1730,19 @@ export default function BatchEditPage() { className="h-9" /> )} + {mapping.sourceType === "conditional" && ( + + updateMappingListItem(mapping.id, { apiField: v }) + } + onConfigChange={(cfg) => + updateMappingListItem(mapping.id, { conditionalConfig: cfg }) + } + /> + )}
{/* 삭제 버튼 */} diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index 6defe312..d73484b7 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -427,12 +427,12 @@ export default function BatchManagementPage() { setIsBatchTypeModalOpen(false); if (type === "db-to-db") { sessionStorage.setItem("batch_create_type", "mapping"); - openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" }); + openTab({ type: "admin", title: "배치 생성 (DB→DB)", admin_url: "/admin/automaticMng/batchmngList/create" }); } else if (type === "restapi-to-db") { - openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" }); + openTab({ type: "admin", title: "배치 생성 (API→DB)", admin_url: "/admin/batch-management-new" }); } else { sessionStorage.setItem("batch_create_type", "node_flow"); - openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" }); + openTab({ type: "admin", title: "배치 생성 (노드플로우)", admin_url: "/admin/automaticMng/batchmngList/create" }); } }; @@ -450,7 +450,7 @@ export default function BatchManagementPage() { return (
-
+
{/* 헤더 */}
@@ -564,7 +564,7 @@ export default function BatchManagementPage() { const isSuccess = lastStatus === "SUCCESS"; return ( -
+
{/* 행 */}
handleRowClick(batchId)}> {/* 토글 */} @@ -638,7 +638,7 @@ export default function BatchManagementPage() { - ))} -
+ {/* 배치 타입 + 기본 정보 — 한 행으로 통합 (xl+ 한 줄, 그 미만은 stack) */} +
+ {/* 모드 토글 2개 */} +
+ {batchTypeOptions.map((option) => ( + + ))} +
- {/* 기본 정보 */} -
-
- - 기본 정보 + {/* 배치명 */} +
+ + setBatchName(e.target.value)} placeholder="배치명" className="h-9 text-sm" />
-
-
- - setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" /> -
-
- - setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" /> -
+ + {/* 실행 스케줄 */} +
+ + setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
-
+ + {/* 설명 (textarea 한 줄 높이 — 다른 입력과 정렬) */} +
-