Compare commits
101 Commits
aeddd7dc2a
...
johngreen
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cd8e72bf0 | |||
| 387a1ae611 | |||
| eeb130e3a8 | |||
| 3ffa5c8ff5 | |||
| acbab68a12 | |||
| db63ba6901 | |||
| ff95c1950e | |||
| 8a9285f13e | |||
| 88b0549a6d | |||
| 33f0647c61 | |||
| 8606f0aaa3 | |||
| 24106929fa | |||
| f530b3cf31 | |||
| 99487049fb | |||
| 6233877029 | |||
| 4031fe8b60 | |||
| a5288647c9 | |||
| 7dbeccc182 | |||
| c857e4f715 | |||
| cf5f7ef9af | |||
| 7e71730015 | |||
| 2d39d17428 | |||
| 30ebb14023 | |||
| 895cb48ee0 | |||
| 067193efa9 | |||
| 318cac4f68 | |||
| 2f398ae0b3 | |||
| 58ede650ae | |||
| 4c5b672f40 | |||
| 904fdd33e7 | |||
| f73e468f66 | |||
| b25a6324f8 | |||
| 8a10edd8e1 | |||
| fc615a70be | |||
| 947b31eff5 | |||
| 46707bd116 | |||
| 467a41a3a8 | |||
| 75f6883497 | |||
| d306ac2865 | |||
| 78c5e3e358 | |||
| 6b204806b6 | |||
| d8877b243a | |||
| 90787d837f | |||
| 752e4fb644 | |||
| 14832a28ab | |||
| a0a4dc3bf5 | |||
| 8fff53b165 | |||
| c530a67cee | |||
| 34060d9534 | |||
| 2348800e68 | |||
| d61777ab5f | |||
| d5f9814865 | |||
| 824a3100ce | |||
| 387a5c2bd7 | |||
| 3883031c0b | |||
| 2f52d9587e | |||
| 4f13d2e440 | |||
| 1613fae8fb | |||
| c350ebe86a | |||
| 5335dc78b0 | |||
| ecad2915ce | |||
| 0552425f47 | |||
| ca241c017d | |||
| ec679ac640 | |||
| 1e1b3e103c | |||
| 35d5a00b20 | |||
| 0365b743f5 | |||
| ff3d4c2cc5 | |||
| 44f5b134a5 | |||
| ff4033b927 | |||
| efea906ead | |||
| 420b92bc7b | |||
| 0328f618b9 | |||
| f53307a72e | |||
| cbf94dc90f | |||
| 54a8f97f78 | |||
| b752de23a1 | |||
| 6fcb101f59 | |||
| 47eed68072 | |||
| d8f606ab00 | |||
| e8f517ed18 | |||
| d02bc38f6c | |||
| 0c9e22a679 | |||
| 570b3267ab | |||
| 0bba1836fb | |||
| f70719aecb | |||
| 3ab7deb196 | |||
| d592547242 | |||
| 6f8461a533 | |||
| 17172cf9b3 | |||
| f9a9c67891 | |||
| f31a7f852f | |||
| 2675c82904 | |||
| dce665caea | |||
| c3e04adb23 | |||
| 7bd08dcf9d | |||
| 4a8413000b | |||
| 081feff51f | |||
| 90035dd5c6 | |||
| baffd6affb | |||
| a5bbd1eb7c |
@@ -1,3 +1,82 @@
|
||||
<!-- User customizations -->
|
||||
# 절대 규칙: 검증 없는 주장 금지
|
||||
|
||||
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
|
||||
|
||||
## 위반 사례 (절대 하지 말 것)
|
||||
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
|
||||
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
|
||||
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
|
||||
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
|
||||
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
|
||||
|
||||
## 발화 전 자기 검증
|
||||
한 문장이라도 출력하기 전에 다음을 확인:
|
||||
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
|
||||
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
|
||||
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
|
||||
|
||||
## 모를 때의 정답
|
||||
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
|
||||
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
|
||||
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
|
||||
|
||||
## 어겼을 때
|
||||
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
|
||||
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
|
||||
- 변명·재포장 금지
|
||||
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
|
||||
|
||||
|
||||
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
|
||||
|
||||
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
|
||||
|
||||
## 원칙
|
||||
|
||||
1. **변경 제안은 무조건 Before / After 두 그림**
|
||||
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
|
||||
3. **평어, 한국어, 짧은 문장**
|
||||
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
|
||||
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
|
||||
|
||||
## 나쁜 예시 ❌
|
||||
|
||||
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
|
||||
|
||||
(사용자: "뭐라는지 모르겠어")
|
||||
|
||||
## 좋은 예시 ⭕
|
||||
|
||||
> **지금 모양:**
|
||||
> ```
|
||||
> 라벨·컬럼명 │ 참조/설정 │ 타입
|
||||
> 거래처명 │ — │ 텍스트 ← 빈 칸
|
||||
> 거래처ID │ customer_mng → ... │ 테이블참조
|
||||
> ```
|
||||
>
|
||||
> **바꿔서:**
|
||||
> ```
|
||||
> 라벨·컬럼명 │ 타입
|
||||
> 거래처명 │ 텍스트
|
||||
> 거래처ID │ 테이블참조
|
||||
> → customer_mng.id ← 정보 있을 때만 작게 밑에
|
||||
> ```
|
||||
|
||||
## 옵션 제시할 땐 표로
|
||||
|
||||
```
|
||||
| 옵션 | 핵심 | 단점 |
|
||||
| A안 | 이름만 바꾸기 | 가장 가벼움 |
|
||||
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
|
||||
```
|
||||
|
||||
## 우선 순위
|
||||
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
|
||||
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
|
||||
|
||||
---
|
||||
|
||||
# INVYONE — Claude 작업 컨벤션
|
||||
|
||||
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
||||
|
||||
@@ -33,6 +33,11 @@ dependencies {
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
|
||||
implementation 'org.postgresql:postgresql'
|
||||
// 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용)
|
||||
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
|
||||
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
|
||||
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
|
||||
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package com.erp.batch;
|
||||
|
||||
import com.erp.service.ExternalDbConnectionService;
|
||||
import com.erp.service.ExternalRestApiConnectionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 배치 ETL 실행기 — vexplor_rps batchSchedulerService.executeBatchMappings 의 1:1 이식.
|
||||
*
|
||||
* 흐름:
|
||||
* 1. 매핑을 (fixed | non-fixed) 로 partition
|
||||
* 2. non-fixed 매핑을 (from_connection_type, from_connection_id, from_table_name) 키로 그룹화
|
||||
* 3. 그룹별로 FROM 데이터 읽기 → MappingTransformer 로 행 변환 → TO 저장
|
||||
* 4. (totalRecords, successRecords, failedRecords) 집계
|
||||
*
|
||||
* FROM 소스 지원:
|
||||
* - internal : 현 tenant DB 의 테이블 (JDBC 직접 SELECT, LIMIT 1000)
|
||||
* - external_db : ExternalDbConnectionService.executeQuery (SELECT-only 보안 정책)
|
||||
* - restapi : ExternalRestApiConnectionService.fetchData (등록된 연결 + dataArrayPath)
|
||||
*
|
||||
* TO 대상 지원:
|
||||
* - internal : 현 tenant DB INSERT / UPSERT (save_mode + conflict_key)
|
||||
* - restapi : 행 단위 POST/PUT/DELETE — testConnection 으로 호출
|
||||
* - external_db : 미지원 (ExternalDbConnectionService 가 SELECT-only 라 의도적으로 차단)
|
||||
*
|
||||
* 미지원 (vexplor_rps 대비 단순화):
|
||||
* - to_api_body 템플릿 기반 일괄 전송
|
||||
* - URL_PATH_PARAM 컬럼 처리
|
||||
* - auth_tokens 자동 조회 (inline-mode REST API)
|
||||
* - row_filter_config
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class BatchExecutor {
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final ExternalDbConnectionService externalDb;
|
||||
private final ExternalRestApiConnectionService externalRest;
|
||||
|
||||
/** PostgreSQL 식별자 화이트리스트 (영문/숫자/언더스코어만). SQL injection 방어용. */
|
||||
private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
|
||||
private static final int FROM_LIMIT = 1000;
|
||||
|
||||
public ExecutionResult execute(Map<String, Object> config) {
|
||||
ExecutionResult r = new ExecutionResult();
|
||||
Object mappingsRaw = config.get("batch_mappings");
|
||||
if (!(mappingsRaw instanceof List)) {
|
||||
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
|
||||
return r;
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsRaw;
|
||||
if (mappings.isEmpty()) {
|
||||
log.warn("배치 매핑이 없습니다: {}", config.get("batch_name"));
|
||||
return r;
|
||||
}
|
||||
|
||||
// 1. fixed 분리
|
||||
MappingTransformer.Partition partition = MappingTransformer.partitionFixed(mappings);
|
||||
|
||||
// 2. non-fixed 그룹화 (from_connection 기준)
|
||||
Map<String, List<Map<String, Object>>> tableGroups = new LinkedHashMap<>();
|
||||
for (Map<String, Object> m : partition.nonFixed) {
|
||||
String key = str(m.get("from_connection_type")) + ":"
|
||||
+ (m.get("from_connection_id") == null ? "internal" : m.get("from_connection_id"))
|
||||
+ ":" + str(m.get("from_table_name"));
|
||||
tableGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(m);
|
||||
}
|
||||
if (tableGroups.isEmpty() && !partition.fixed.isEmpty()) {
|
||||
log.warn("일반 매핑이 없고 고정값 매핑만 있어 실행 불가");
|
||||
return r;
|
||||
}
|
||||
|
||||
String companyCode = str(config.get("company_code"));
|
||||
String saveMode = strOr(config.get("save_mode"), "INSERT");
|
||||
String conflictKey = str(config.get("conflict_key"));
|
||||
String dataArrayPath = str(config.get("data_array_path"));
|
||||
|
||||
// 3. 그룹별 처리
|
||||
for (Map.Entry<String, List<Map<String, Object>>> e : tableGroups.entrySet()) {
|
||||
String key = e.getKey();
|
||||
List<Map<String, Object>> groupMappings = e.getValue();
|
||||
Map<String, Object> first = groupMappings.get(0);
|
||||
try {
|
||||
log.info("테이블 처리 시작: {} → {} 컬럼 매핑", key, groupMappings.size());
|
||||
|
||||
// FROM 읽기
|
||||
List<Map<String, Object>> fromData = readFrom(first, groupMappings, dataArrayPath, companyCode);
|
||||
|
||||
r.totalRecords += fromData.size();
|
||||
|
||||
// Transform
|
||||
String toConnType = str(first.get("to_connection_type"));
|
||||
List<Map<String, Object>> mappedRows = new ArrayList<>(fromData.size());
|
||||
for (Map<String, Object> row : fromData) {
|
||||
mappedRows.add(MappingTransformer.transformRow(
|
||||
row, groupMappings, partition.fixed, toConnType, companyCode));
|
||||
}
|
||||
|
||||
// TO 저장
|
||||
WriteResult wr = writeTo(first, mappedRows, saveMode, conflictKey, companyCode);
|
||||
r.successRecords += wr.success;
|
||||
r.failedRecords += wr.failed;
|
||||
|
||||
} catch (Exception ex) {
|
||||
log.error("테이블 처리 중 오류: {} — {}", key, ex.getMessage(), ex);
|
||||
r.errorMessages.add(key + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── FROM 읽기 ───────────────────────────────────────────────────────────
|
||||
|
||||
private List<Map<String, Object>> readFrom(
|
||||
Map<String, Object> firstMapping,
|
||||
List<Map<String, Object>> groupMappings,
|
||||
String dataArrayPath,
|
||||
String companyCode
|
||||
) {
|
||||
String type = str(firstMapping.get("from_connection_type"));
|
||||
String tableName = str(firstMapping.get("from_table_name"));
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (Map<String, Object> m : groupMappings) {
|
||||
String col = str(m.get("from_column_name"));
|
||||
if (col != null && !col.isEmpty() && !columns.contains(col)) columns.add(col);
|
||||
}
|
||||
|
||||
if ("restapi".equals(type)) {
|
||||
return readFromRestApi(firstMapping, dataArrayPath, companyCode);
|
||||
}
|
||||
if ("external".equals(type) || "external_db".equals(type)) {
|
||||
return readFromExternalDb(firstMapping, columns);
|
||||
}
|
||||
// internal (기본)
|
||||
return readFromInternal(tableName, columns);
|
||||
}
|
||||
|
||||
/** Internal DB 의 동적 SELECT. sqlSession 의 현 tenant connection 사용. */
|
||||
private List<Map<String, Object>> readFromInternal(String tableName, List<String> columns) {
|
||||
if (tableName == null) throw new IllegalArgumentException("from_table_name 누락");
|
||||
if (columns.isEmpty()) throw new IllegalArgumentException("from_column_name 매핑 없음");
|
||||
StringBuilder sql = new StringBuilder("SELECT ");
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
if (i > 0) sql.append(", ");
|
||||
sql.append(safeIdent(columns.get(i)));
|
||||
}
|
||||
sql.append(" FROM ").append(safeIdent(tableName));
|
||||
sql.append(" LIMIT ").append(FROM_LIMIT);
|
||||
|
||||
try (Connection c = sqlSession.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql.toString());
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return materialize(rs);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("internal SELECT 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** External DB SELECT — ExternalDbConnectionService.executeQuery 경유 (SELECT-only). */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> readFromExternalDb(Map<String, Object> firstMapping, List<String> columns) {
|
||||
Object connIdObj = firstMapping.get("from_connection_id");
|
||||
if (connIdObj == null) throw new IllegalArgumentException("external_db 인데 from_connection_id 가 비어있음");
|
||||
long connId = Long.parseLong(connIdObj.toString());
|
||||
String tableName = str(firstMapping.get("from_table_name"));
|
||||
StringBuilder sql = new StringBuilder("SELECT ");
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
if (i > 0) sql.append(", ");
|
||||
sql.append(safeIdent(columns.get(i)));
|
||||
}
|
||||
sql.append(" FROM ").append(safeIdent(tableName)).append(" LIMIT ").append(FROM_LIMIT);
|
||||
|
||||
Map<String, Object> result = externalDb.executeQuery(connId, sql.toString());
|
||||
Object data = result.get("data");
|
||||
return data instanceof List ? (List<Map<String, Object>>) data : List.of();
|
||||
}
|
||||
|
||||
/** REST API → ExternalRestApiConnectionService.fetchData. dataArrayPath 로 배열 추출. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> readFromRestApi(
|
||||
Map<String, Object> firstMapping, String dataArrayPath, String companyCode
|
||||
) {
|
||||
Object connIdObj = firstMapping.get("from_connection_id");
|
||||
if (connIdObj == null) {
|
||||
throw new UnsupportedOperationException(
|
||||
"REST API 등록 연결 없는 inline-mode (from_api_url 직접 호출) 는 현재 미지원");
|
||||
}
|
||||
int connId = Integer.parseInt(connIdObj.toString());
|
||||
String endpoint = str(firstMapping.get("from_table_name"));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
if (companyCode != null) params.put("company_code", companyCode);
|
||||
Map<String, Object> result = externalRest.fetchData(connId, endpoint, dataArrayPath, params);
|
||||
|
||||
if (!Boolean.TRUE.equals(result.get("success"))) {
|
||||
throw new RuntimeException("REST API 호출 실패: " + result.getOrDefault("message", ""));
|
||||
}
|
||||
Object data = result.get("data");
|
||||
if (!(data instanceof Map)) return List.of();
|
||||
Object rows = ((Map<String, Object>) data).get("rows");
|
||||
if (!(rows instanceof List)) return List.of();
|
||||
List<Object> raw = (List<Object>) rows;
|
||||
List<Map<String, Object>> out = new ArrayList<>(raw.size());
|
||||
for (Object o : raw) if (o instanceof Map) out.add((Map<String, Object>) o);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── TO 저장 ────────────────────────────────────────────────────────────
|
||||
|
||||
// 트랜잭션은 의도적으로 걸지 않음 — batch 의 정상 동작은 row 단위 독립 commit.
|
||||
// 일부 row 가 실패해도 다른 row 는 살아야 successCount/failedCount 집계가 의미 있음.
|
||||
public WriteResult writeTo(
|
||||
Map<String, Object> firstMapping,
|
||||
List<Map<String, Object>> rows,
|
||||
String saveMode,
|
||||
String conflictKey,
|
||||
String companyCode
|
||||
) {
|
||||
if (rows == null || rows.isEmpty()) return new WriteResult();
|
||||
String type = str(firstMapping.get("to_connection_type"));
|
||||
String tableName = str(firstMapping.get("to_table_name"));
|
||||
|
||||
if ("restapi".equals(type)) {
|
||||
return writeToRestApi(firstMapping, rows, companyCode);
|
||||
}
|
||||
if ("external".equals(type) || "external_db".equals(type)) {
|
||||
throw new UnsupportedOperationException(
|
||||
"external_db TO 쓰기는 현재 미지원 (ExternalDbConnectionService 가 SELECT-only)");
|
||||
}
|
||||
return writeToInternal(tableName, rows, saveMode, conflictKey);
|
||||
}
|
||||
|
||||
/** Internal DB INSERT / UPSERT — 행 단위 PreparedStatement. */
|
||||
private WriteResult writeToInternal(String tableName, List<Map<String, Object>> rows,
|
||||
String saveMode, String conflictKey) {
|
||||
WriteResult r = new WriteResult();
|
||||
if (tableName == null) throw new IllegalArgumentException("to_table_name 누락");
|
||||
safeIdent(tableName);
|
||||
|
||||
try (Connection c = sqlSession.getConnection()) {
|
||||
for (Map<String, Object> row : rows) {
|
||||
try {
|
||||
String sql = buildInsertSql(tableName, row, saveMode, conflictKey);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int idx = 1;
|
||||
for (Object v : row.values()) {
|
||||
ps.setObject(idx++, v);
|
||||
}
|
||||
ps.executeUpdate();
|
||||
r.success++;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
log.error("INSERT 실패 row={} — {}", row, e.getMessage());
|
||||
r.failed++;
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("internal write 실패: " + e.getMessage(), e);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** INSERT (또는 UPSERT) SQL 생성. row 의 key 순서로 컬럼/플레이스홀더 배열. */
|
||||
private String buildInsertSql(String tableName, Map<String, Object> row,
|
||||
String saveMode, String conflictKey) {
|
||||
List<String> cols = new ArrayList<>(row.keySet());
|
||||
StringBuilder sql = new StringBuilder("INSERT INTO ").append(safeIdent(tableName)).append(" (");
|
||||
for (int i = 0; i < cols.size(); i++) {
|
||||
if (i > 0) sql.append(", ");
|
||||
sql.append(safeIdent(cols.get(i)));
|
||||
}
|
||||
sql.append(") VALUES (");
|
||||
for (int i = 0; i < cols.size(); i++) {
|
||||
if (i > 0) sql.append(", ");
|
||||
sql.append("?");
|
||||
}
|
||||
sql.append(")");
|
||||
|
||||
if ("UPSERT".equalsIgnoreCase(saveMode) && conflictKey != null && !conflictKey.isEmpty()) {
|
||||
safeIdent(conflictKey);
|
||||
List<String> updateCols = new ArrayList<>();
|
||||
for (String col : cols) if (!col.equalsIgnoreCase(conflictKey)) updateCols.add(col);
|
||||
sql.append(" ON CONFLICT (").append(conflictKey).append(") ");
|
||||
if (updateCols.isEmpty()) {
|
||||
sql.append("DO NOTHING");
|
||||
} else {
|
||||
sql.append("DO UPDATE SET ");
|
||||
for (int i = 0; i < updateCols.size(); i++) {
|
||||
if (i > 0) sql.append(", ");
|
||||
String c = safeIdent(updateCols.get(i));
|
||||
sql.append(c).append(" = EXCLUDED.").append(c);
|
||||
}
|
||||
if (cols.stream().anyMatch(c -> c.equalsIgnoreCase("updated_date"))) {
|
||||
sql.append(", updated_date = NOW()");
|
||||
}
|
||||
}
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
/** REST API TO — 행 단위로 testConnection 호출 (POST/PUT/DELETE). */
|
||||
private WriteResult writeToRestApi(Map<String, Object> firstMapping,
|
||||
List<Map<String, Object>> rows, String companyCode) {
|
||||
WriteResult r = new WriteResult();
|
||||
String baseUrl = str(firstMapping.get("to_api_url"));
|
||||
String endpoint = str(firstMapping.get("to_table_name"));
|
||||
String method = strOr(firstMapping.get("to_api_method"), "POST");
|
||||
|
||||
for (Map<String, Object> row : rows) {
|
||||
try {
|
||||
Map<String, Object> testReq = new LinkedHashMap<>();
|
||||
testReq.put("base_url", baseUrl);
|
||||
testReq.put("endpoint", endpoint);
|
||||
testReq.put("method", method);
|
||||
testReq.put("body", row);
|
||||
testReq.put("auth_type", "none");
|
||||
testReq.put("timeout", 30000);
|
||||
Map<String, Object> result = externalRest.testConnection(testReq, companyCode);
|
||||
if (Boolean.TRUE.equals(result.get("success"))) r.success++; else r.failed++;
|
||||
} catch (Exception e) {
|
||||
log.error("REST API 전송 실패 row={} — {}", row, e.getMessage());
|
||||
r.failed++;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 유틸 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<Map<String, Object>> materialize(ResultSet rs) throws SQLException {
|
||||
ResultSetMetaData md = rs.getMetaData();
|
||||
int n = md.getColumnCount();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for (int i = 1; i <= n; i++) row.put(md.getColumnLabel(i), rs.getObject(i));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static String safeIdent(String s) {
|
||||
if (s == null || !SAFE_IDENT.matcher(s).matches()) {
|
||||
throw new IllegalArgumentException("Unsafe identifier: " + s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static String str(Object v) { return v == null ? null : v.toString(); }
|
||||
private static String strOr(Object v, String fallback) {
|
||||
String s = str(v);
|
||||
return (s == null || s.isEmpty()) ? fallback : s;
|
||||
}
|
||||
|
||||
// ── 결과 클래스 ────────────────────────────────────────────────────────
|
||||
|
||||
public static final class ExecutionResult {
|
||||
public int totalRecords = 0;
|
||||
public int successRecords = 0;
|
||||
public int failedRecords = 0;
|
||||
public final List<String> errorMessages = new ArrayList<>();
|
||||
|
||||
public Map<String, Object> toMap() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("total_records", totalRecords);
|
||||
m.put("success_records", successRecords);
|
||||
m.put("failed_records", failedRecords);
|
||||
m.put("error_message", errorMessages.isEmpty() ? null : String.join("\n", errorMessages));
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class WriteResult {
|
||||
public int success = 0;
|
||||
public int failed = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.erp.batch;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 매핑 변환 유틸리티 — vexplor_rps 의 batchSchedulerService L550~617 .map() 로직 1:1 이식.
|
||||
*
|
||||
* BatchExecutor 가 FROM 에서 읽은 row 들을 TO 형태로 변환할 때 사용. 의존성 없는 정적 메서드만.
|
||||
*
|
||||
* mapping_type 분기:
|
||||
* - "direct" : row[from_column_name] → row[to_column_name] 그대로 복사
|
||||
* from_column_name 은 점 표기법 지원 (예: "response.access_token")
|
||||
* - "fixed" : from_column_name 자체가 고정값. transformRow 는 fixed 매핑을 건너뛰고,
|
||||
* 호출측이 partition 한 뒤 mappedRow 에 적용 (vexplor_rps L598-603 패턴).
|
||||
* - "conditional" : ConditionalConfig.rules 의 when 과 sourceVal 문자열 동등 비교, 매칭 then 반환.
|
||||
* 매칭 없으면 default. (단순 문자열 lookup. SpEL/JEXL 등 표현식 평가 안 함)
|
||||
*/
|
||||
@Slf4j
|
||||
public final class MappingTransformer {
|
||||
|
||||
private static final ObjectMapper OM = new ObjectMapper();
|
||||
|
||||
private MappingTransformer() {}
|
||||
|
||||
/** 단일 row 를 매핑 룰에 따라 변환. mapping_type 별 분기 처리. */
|
||||
public static Map<String, Object> transformRow(
|
||||
Map<String, Object> row,
|
||||
List<Map<String, Object>> nonFixedMappings,
|
||||
List<Map<String, Object>> fixedMappings,
|
||||
String toConnectionType,
|
||||
String companyCode
|
||||
) {
|
||||
Map<String, Object> mappedRow = new LinkedHashMap<>();
|
||||
|
||||
for (Map<String, Object> mapping : nonFixedMappings) {
|
||||
String mt = strOr(mapping.get("mapping_type"), "direct");
|
||||
String fromCol = str(mapping.get("from_column_name"));
|
||||
String toCol = str(mapping.get("to_column_name"));
|
||||
|
||||
if ("conditional".equals(mt)) {
|
||||
ConditionalConfig cfg = parseConditionalConfig(mapping.get("mapping_config"));
|
||||
String sourceVal = String.valueOf(getValueByPath(row, fromCol));
|
||||
if (sourceVal == null || "null".equals(sourceVal)) sourceVal = "";
|
||||
mappedRow.put(toCol, evaluateConditional(sourceVal, cfg));
|
||||
continue;
|
||||
}
|
||||
|
||||
// direct 또는 알 수 없는 type — 그대로 복사
|
||||
// DB→REST 의 to_api_body 템플릿 처리는 BatchExecutor 측에서 별도 처리 (vexplor_rps L582~595).
|
||||
// 여기서는 단순 to_column_name 으로 값 흘림.
|
||||
Object value = getValueByPath(row, fromCol);
|
||||
mappedRow.put(toCol, value);
|
||||
}
|
||||
|
||||
// 고정값 매핑 적용 — from_column_name 자체가 저장값 (vexplor_rps L598-603)
|
||||
if (fixedMappings != null) {
|
||||
for (Map<String, Object> fm : fixedMappings) {
|
||||
mappedRow.put(str(fm.get("to_column_name")), fm.get("from_column_name"));
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시: TO 가 DB 일 때 company_code 자동 주입 (vexplor_rps L605-614)
|
||||
if (!"restapi".equals(toConnectionType)
|
||||
&& companyCode != null
|
||||
&& !mappedRow.containsKey("company_code")) {
|
||||
mappedRow.put("company_code", companyCode);
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
}
|
||||
|
||||
/** 점 표기법 path 평가 — "response.access_token" 같은 중첩 키 지원 (vexplor_rps L540-548). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Object getValueByPath(Map<String, Object> obj, String path) {
|
||||
if (obj == null || path == null || path.isEmpty()) return null;
|
||||
if (!path.contains(".")) return obj.get(path);
|
||||
Object cur = obj;
|
||||
for (String part : path.split("\\.")) {
|
||||
if (!(cur instanceof Map)) return null;
|
||||
cur = ((Map<String, Object>) cur).get(part);
|
||||
if (cur == null) return null;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** ConditionalConfig 단일 평가 — when/then lookup + default. */
|
||||
public static Object evaluateConditional(String sourceVal, ConditionalConfig cfg) {
|
||||
if (cfg == null || cfg.rules == null) return cfg != null ? cfg.defaultValue : null;
|
||||
for (ConditionalRule r : cfg.rules) {
|
||||
String when = r.when == null ? "" : r.when;
|
||||
if (Objects.equals(when, sourceVal)) return r.then;
|
||||
}
|
||||
return cfg.defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* mapping_config (JSONB) 의 원시 값 → ConditionalConfig.
|
||||
* - BatchService.attachMappings 가 이미 파싱한 경우 → Map<String,Object>
|
||||
* - 직접 SELECT 결과 → String(JSON) 가능
|
||||
* - null → 빈 cfg
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static ConditionalConfig parseConditionalConfig(Object raw) {
|
||||
if (raw == null) return ConditionalConfig.empty();
|
||||
Map<String, Object> map;
|
||||
try {
|
||||
if (raw instanceof Map) {
|
||||
map = (Map<String, Object>) raw;
|
||||
} else if (raw instanceof String) {
|
||||
String s = ((String) raw).trim();
|
||||
if (s.isEmpty()) return ConditionalConfig.empty();
|
||||
map = OM.readValue(s, Map.class);
|
||||
} else {
|
||||
return ConditionalConfig.empty();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[conditional 매핑] JSON 파싱 실패: {}", e.getMessage());
|
||||
return ConditionalConfig.empty();
|
||||
}
|
||||
|
||||
ConditionalConfig cfg = new ConditionalConfig();
|
||||
Object rulesRaw = map.get("rules");
|
||||
if (rulesRaw instanceof List) {
|
||||
for (Object r : (List<Object>) rulesRaw) {
|
||||
if (r instanceof Map) {
|
||||
Map<String, Object> rm = (Map<String, Object>) r;
|
||||
cfg.rules.add(new ConditionalRule(
|
||||
rm.get("when") == null ? "" : String.valueOf(rm.get("when")),
|
||||
rm.get("then") == null ? null : String.valueOf(rm.get("then"))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Object def = map.get("default");
|
||||
cfg.defaultValue = def == null ? null : String.valueOf(def);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** non-fixed / fixed 매핑 분리. vexplor_rps L265~271 partition 패턴. */
|
||||
public static Partition partitionFixed(List<Map<String, Object>> mappings) {
|
||||
Partition p = new Partition();
|
||||
if (mappings == null) return p;
|
||||
for (Map<String, Object> m : mappings) {
|
||||
String mt = strOr(m.get("mapping_type"), "direct");
|
||||
if ("fixed".equals(mt)) p.fixed.add(m); else p.nonFixed.add(m);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
public static final class Partition {
|
||||
public final List<Map<String, Object>> nonFixed = new ArrayList<>();
|
||||
public final List<Map<String, Object>> fixed = new ArrayList<>();
|
||||
}
|
||||
|
||||
public static final class ConditionalConfig {
|
||||
public List<ConditionalRule> rules = new ArrayList<>();
|
||||
public String defaultValue;
|
||||
public static ConditionalConfig empty() { return new ConditionalConfig(); }
|
||||
}
|
||||
|
||||
public static final class ConditionalRule {
|
||||
public final String when;
|
||||
public final String then;
|
||||
public ConditionalRule(String when, String then) { this.when = when; this.then = then; }
|
||||
}
|
||||
|
||||
private static String str(Object v) { return v == null ? null : v.toString(); }
|
||||
private static String strOr(Object v, String fallback) {
|
||||
String s = str(v);
|
||||
return (s == null || s.isEmpty()) ? fallback : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.erp.constants;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class InputTypeConstants {
|
||||
private InputTypeConstants() {}
|
||||
|
||||
/**
|
||||
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
|
||||
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
|
||||
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
|
||||
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
|
||||
*/
|
||||
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image",
|
||||
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.erp.constants;
|
||||
|
||||
public enum InputTypeContext {
|
||||
USER_INSERT,
|
||||
USER_UPDATE_TYPE,
|
||||
USER_UPDATE_OTHER,
|
||||
SYSTEM_NORMALIZE
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -30,13 +32,17 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
params.put("is_management_screen",
|
||||
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -49,11 +55,15 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ public class BatchManagementController {
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
|
||||
@GetMapping("/sparkline")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
|
||||
@GetMapping("/batch-configs/{id}/recent-logs")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingAutoFillService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-auto-fill")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingAutoFillController {
|
||||
private final CascadingAutoFillService cascadingAutoFillService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingAutoFillService.getCascadingAutoFillGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.getCascadingAutoFillGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingAutoFillService.insertCascadingAutoFillGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingAutoFillService.updateCascadingAutoFillGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/groups/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingAutoFillService.deleteCascadingAutoFillGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/options/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMasterOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
List<Map<String, Object>> result = cascadingAutoFillService.getAutoFillMasterOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping("/data/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAutoFillData(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestParam(required = false) String masterValue) {
|
||||
if (masterValue == null || masterValue.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("masterValue 파라미터가 필요합니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("master_value", masterValue);
|
||||
Map<String, Object> result = cascadingAutoFillService.getAutoFillData(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("자동 입력 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingConditionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-condition")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingConditionController {
|
||||
private final CascadingConditionService cascadingConditionService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/filtered-options/{relationCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getFilteredOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String relationCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("relation_code", relationCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getFilteredOptions(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingConditionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.getCascadingConditionInfo(params)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.insertCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.updateCascadingCondition(body)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{conditionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingCondition(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long conditionId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("condition_id", conditionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingConditionService.deleteCascadingCondition(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingHierarchyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-hierarchy")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingHierarchyController {
|
||||
private final CascadingHierarchyService cascadingHierarchyService;
|
||||
|
||||
// Pipeline api_test compatibility alias
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(cascadingHierarchyService.getCascadingHierarchyGroupList(params)));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getGroupDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.getCascadingHierarchyGroupDetail(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(cascadingHierarchyService.insertCascadingHierarchyGroup(body)));
|
||||
}
|
||||
|
||||
@PutMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
if (userId != null) body.put("user_id", userId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyGroup(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{groupCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyGroup(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@PostMapping("/{groupCode}/levels")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_code", groupCode);
|
||||
Map<String, Object> result = cascadingHierarchyService.addCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("계층 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PutMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("level_id", levelId);
|
||||
Map<String, Object> result = cascadingHierarchyService.updateCascadingHierarchyLevel(body);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@DeleteMapping("/levels/{levelId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteLevel(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long levelId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("level_id", levelId);
|
||||
boolean deleted = cascadingHierarchyService.deleteCascadingHierarchyLevel(params);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupCode}/options/{levelOrder}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getLevelOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String groupCode,
|
||||
@PathVariable Integer levelOrder,
|
||||
@RequestParam(required = false) String parentValue) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_code", groupCode);
|
||||
params.put("level_order", levelOrder);
|
||||
if (parentValue != null) params.put("parent_value", parentValue);
|
||||
Map<String, Object> result = cascadingHierarchyService.getLevelOptions(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("레벨을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingMutualExclusionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 상호 배제 API
|
||||
* Node.js: app.use("/api/cascading-mutual-exclusion", cascadingMutualExclusionRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-mutual-exclusion")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingMutualExclusionController {
|
||||
private final CascadingMutualExclusionService cascadingMutualExclusionService;
|
||||
|
||||
/** GET /list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET / — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionList(params)));
|
||||
}
|
||||
|
||||
/** GET /{exclusionId} — 상세 조회 */
|
||||
@GetMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingMutualExclusionInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getCascadingMutualExclusionInfo(params)));
|
||||
}
|
||||
|
||||
/** POST / — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.insertCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** PUT /{exclusionId} — 수정 */
|
||||
@PutMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.updateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/** DELETE /{exclusionId} — 하드 삭제 */
|
||||
@DeleteMapping("/{exclusionId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long exclusionId) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", exclusionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.deleteCascadingMutualExclusion(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validate/{exclusionCode} — 상호 배제 검증
|
||||
* body: { "field_values": { "field_a": "val1", "field_b": "val1" } }
|
||||
*/
|
||||
@PostMapping("/validate/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> validateCascadingMutualExclusion(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("code", exclusionCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.validateCascadingMutualExclusion(body)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /options/{exclusionCode} — 배제 옵션 조회
|
||||
* query: selectedValues (콤마 구분된 이미 선택된 값들)
|
||||
*/
|
||||
@GetMapping("/options/{exclusionCode}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getExcludedOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String exclusionCode,
|
||||
@RequestParam(required = false) String currentField,
|
||||
@RequestParam(required = false) String selectedValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", exclusionCode);
|
||||
params.put("current_field", currentField);
|
||||
params.put("selected_values", selectedValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingMutualExclusionService.getExcludedOptions(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CascadingRelationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 연쇄 관계 API
|
||||
* Node.js: app.use("/api/cascading-relation", cascadingRelationRoutes)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cascading-relation")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingRelationController {
|
||||
private final CascadingRelationService cascadingRelationService;
|
||||
|
||||
/** GET /api/cascading-relation/list — 목록 조회 (alias) */
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationListAlias(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation — 목록 조회 */
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationList(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/{id} — 상세 조회 */
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationInfo(params)));
|
||||
}
|
||||
|
||||
/** GET /api/cascading-relation/code/{code} — 코드로 단건 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCascadingRelationByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingRelationByCode(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/parent-options/{code}
|
||||
* 부모 옵션 조회 (parent_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getParentOptions(params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cascading-relation/options/{code}?parentValue=&parentValues=
|
||||
* 연쇄 자식 옵션 조회 (child_table 동적 쿼리)
|
||||
*/
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam(required = false) String parentValue,
|
||||
@RequestParam(required = false) String parentValues) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
params.put("parent_value", parentValue);
|
||||
params.put("parent_values", parentValues);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.getCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** POST /api/cascading-relation — 생성 */
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.insertCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** PUT /api/cascading-relation/{id} — 수정 */
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId != null ? userId : "system");
|
||||
body.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.updateCascadingRelation(body)));
|
||||
}
|
||||
|
||||
/** DELETE /api/cascading-relation/{id} — 소프트 삭제 (is_active = 'N') */
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCascadingRelation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@PathVariable Long id) {
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId != null ? userId : "system");
|
||||
params.put("id", id);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
cascadingRelationService.deleteCascadingRelation(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryTreeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/category-tree")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryTreeController {
|
||||
|
||||
private final CategoryTreeService categoryTreeService;
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/all-category-keys
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* 주의: /test/{tableName}/{columnName} 보다 먼저 매핑되어야 함
|
||||
*/
|
||||
@GetMapping("/test/all-category-keys")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeKeyList(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
|
||||
List<Map<String, Object>> keys = categoryTreeService.getCategoryTreeKeyList(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}
|
||||
* 카테고리 트리 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> tree = categoryTreeService.getCategoryTreeList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(tree));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/{tableName}/{columnName}/flat
|
||||
* 카테고리 플랫 리스트 조회
|
||||
*/
|
||||
@GetMapping("/test/{tableName}/{columnName}/flat")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeFlatList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName) {
|
||||
|
||||
List<Map<String, Object>> list = categoryTreeService.getCategoryTreeFlatList(companyCode, tableName, columnName);
|
||||
return ResponseEntity.ok(ApiResponse.success(list));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 단건 조회
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryTreeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> value = categoryTreeService.getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/category-tree/test/value
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
@PostMapping("/test/value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryTree(
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("table_name") == null || body.get("column_name") == null
|
||||
|| body.get("value_code") == null || body.get("value_label") == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("tableName, columnName, valueCode, valueLabel은 필수입니다"));
|
||||
}
|
||||
|
||||
// 최고 관리자(*) 는 targetCompanyCode 로 회사 코드 오버라이드 가능
|
||||
String companyCode = userCompanyCode;
|
||||
String targetCompanyCode = (String) body.get("target_company_code");
|
||||
if (targetCompanyCode != null && "*".equals(userCompanyCode)) {
|
||||
companyCode = targetCompanyCode;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.insertCategoryTree(body, companyCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 생성 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
@PutMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable int valueId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> value = categoryTreeService.updateCategoryTree(companyCode, valueId, body, userId);
|
||||
if (value == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 수정 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/value/{valueId}/can-delete
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
@GetMapping("/test/value/{valueId}/can-delete")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCanDelete(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
Map<String, Object> result = categoryTreeService.checkCanDelete(companyCode, valueId);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/category-tree/test/value/{valueId}
|
||||
* 카테고리 값 삭제
|
||||
*/
|
||||
@DeleteMapping("/test/value/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCategoryTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable int valueId) {
|
||||
|
||||
try {
|
||||
boolean deleted = categoryTreeService.deleteCategoryTree(companyCode, valueId);
|
||||
if (!deleted) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값을 찾을 수 없습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "삭제되었습니다"));
|
||||
} catch (IllegalStateException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.startsWith("VALIDATION:")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(msg.substring("VALIDATION:".length())));
|
||||
}
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(msg));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 삭제 오류", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/category-tree/test/columns/{tableName}
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
@GetMapping("/test/columns/{tableName}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryTreeColumnList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
|
||||
List<Map<String, Object>> columns = categoryTreeService.getCategoryTreeColumnList(companyCode, tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(columns));
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CategoryValueCascadingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/category-value-cascading")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CategoryValueCascadingController {
|
||||
private final CategoryValueCascadingService categoryValueCascadingService;
|
||||
|
||||
/** GET /groups → 그룹 목록 조회 */
|
||||
@GetMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingGroupList(params)));
|
||||
}
|
||||
|
||||
/** GET /groups/{groupId} → 그룹 상세 조회 (매핑 포함) */
|
||||
@GetMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupInfo(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** GET /code/{code} → 관계 코드로 조회 */
|
||||
@GetMapping("/code/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingGroupByCode(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
Map<String, Object> result = categoryValueCascadingService.getCategoryValueCascadingGroupByCode(params);
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("카테고리 값 연쇄관계를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
/** POST /groups → 그룹 생성 */
|
||||
@PostMapping("/groups")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.insertCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** PUT /groups/{groupId} → 그룹 수정 */
|
||||
@PutMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.updateCategoryValueCascadingGroup(body)));
|
||||
}
|
||||
|
||||
/** DELETE /groups/{groupId} → 그룹 소프트 삭제 */
|
||||
@DeleteMapping("/groups/{groupId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCategoryValueCascadingGroup(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.deleteCategoryValueCascadingGroup(params)));
|
||||
}
|
||||
|
||||
/** POST /groups/{groupId}/mappings → 매핑 일괄 저장 */
|
||||
@PostMapping("/groups/{groupId}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveCategoryValueCascadingMappings(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable Long groupId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("group_id", groupId);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.saveCategoryValueCascadingMappings(body)));
|
||||
}
|
||||
|
||||
/** GET /parent-options/{code} → 부모 카테고리 값 목록 */
|
||||
@GetMapping("/parent-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingParentOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingParentOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /child-options/{code} → 자식 카테고리 값 목록 */
|
||||
@GetMapping("/child-options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingChildOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingChildOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /options/{code} → 연쇄 옵션 조회 (parentValue/parentValues 파라미터) */
|
||||
@GetMapping("/options/{code}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingOptions(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String code,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("code", code);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingOptions(params)));
|
||||
}
|
||||
|
||||
/** GET /table/{tableName}/mappings → 테이블별 매핑 조회 */
|
||||
@GetMapping("/table/{tableName}/mappings")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryValueCascadingMappingsByTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String tableName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
return ResponseEntity.ok(ApiResponse.success(categoryValueCascadingService.getCategoryValueCascadingMappingsByTable(params)));
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.CodeMergeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/code-merge")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CodeMergeController {
|
||||
|
||||
private final CodeMergeService codeMergeService;
|
||||
|
||||
/** POST /api/code-merge/merge-all-tables — columnName 기준 전체 테이블 코드 병합 */
|
||||
@PostMapping("/merge-all-tables")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeAllTables(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeAllTables(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** GET /api/code-merge/tables-with-column/:columnName — 해당 컬럼을 가진 테이블 목록 */
|
||||
@GetMapping("/tables-with-column/{columnName}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTablesWithColumn(
|
||||
@PathVariable String columnName) {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.getTablesWithColumn(columnName)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview — columnName + oldValue 기준 영향 미리보기 */
|
||||
@PostMapping("/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewCodeMerge(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewCodeMerge(body)));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/merge-by-value — 값 기반 전체 테이블/컬럼 코드 병합 */
|
||||
@PostMapping("/merge-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> mergeByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.mergeByValue(body),
|
||||
"코드 병합이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/code-merge/preview-by-value — 값 기반 영향 미리보기 */
|
||||
@PostMapping("/preview-by-value")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> previewByValue(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
body.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
codeMergeService.previewByValue(body)));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,18 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Common Code Controller
|
||||
* Common Code Controller — 마스터-디테일 패턴.
|
||||
*
|
||||
* commonCodeRoutes.ts 포팅 — /api/common-codes 기준 15개 엔드포인트.
|
||||
* /info : code_info (1레벨 그룹 마스터)
|
||||
* /detail : code_detail (2레벨~ 트리)
|
||||
*
|
||||
* NOTE: Spring MVC 는 리터럴 세그먼트를 경로변수보다 우선하므로
|
||||
* /check-duplicate, /reorder 는 별도 선언 순서 없이도 정상 동작하나,
|
||||
* 가독성을 위해 구체적인 경로를 먼저 선언한다.
|
||||
* /check-duplicate 는 /{codeInfo} / /{id} 보다 먼저 매칭된다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/common-codes")
|
||||
@@ -27,151 +28,200 @@ public class CommonCodeController {
|
||||
|
||||
private final CommonCodeService service;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories
|
||||
// Node.js: { success, data: [...], total, message } (flat, not nested)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_INFO — 그룹 마스터
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeCategoryList(
|
||||
/** 그룹 목록 (페이징/검색) */
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<Map<String, Object>> getCodeInfoList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeCategoryList(params);
|
||||
Map<String, Object> svc = service.getCodeInfoList(params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "카테고리 목록 조회 성공");
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
response.put("message", "코드 그룹 목록 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/check-duplicate ← /{categoryCode} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCategoryDuplicate(
|
||||
/** 그룹 중복 체크 — /{codeInfo} 보다 먼저 선언 */
|
||||
@GetMapping("/info/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeInfoDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam(defaultValue = "category_code") String field,
|
||||
@RequestParam(defaultValue = "code_info") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCategoryDuplicate(field, value, excludeCode, companyCode)));
|
||||
ApiResponse.success(service.checkCodeInfoDuplicate(field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 그룹 단건 */
|
||||
@GetMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeInfoInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
@PostMapping("/categories")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCodeCategory(
|
||||
Map<String, Object> info = service.getCodeInfoInfo(codeInfo, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 그룹 생성 */
|
||||
@PostMapping("/info")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("category_code") == null || body.get("category_name") == null) {
|
||||
if (body.get("code_info") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (categoryCode, categoryName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCodeCategory(body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeInfo(body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "카테고리가 성공적으로 생성되었습니다."));
|
||||
.body(ApiResponse.success(created, "코드 그룹이 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 생성 실패", e);
|
||||
log.error("코드 그룹 생성 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 생성에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 생성에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCodeCategory(
|
||||
/** 그룹 수정 */
|
||||
@PutMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeInfo,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCodeCategory(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeInfo(codeInfo, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("카테고리를 찾을 수 없습니다."));
|
||||
.body(ApiResponse.error("코드 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "카테고리가 성공적으로 수정되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드 그룹이 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 수정 실패", e);
|
||||
log.error("코드 그룹 수정 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 수정에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 수정에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCodeCategory(
|
||||
/** 그룹 삭제 (CASCADE 로 code_detail 자식 자동 삭제) */
|
||||
@DeleteMapping("/info/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
@PathVariable String codeInfo) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCodeCategory(categoryCode, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리가 성공적으로 삭제되었습니다."));
|
||||
service.deleteCodeInfo(codeInfo, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드 그룹이 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 삭제 실패", e);
|
||||
log.error("코드 그룹 삭제 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 삭제에 실패했습니다."));
|
||||
.body(ApiResponse.error("코드 그룹 삭제에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// CODE_DETAIL — 디테일 트리
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<Map<String, Object>> getCommonCodeList(
|
||||
/**
|
||||
* 디테일 트리.
|
||||
* - code_info 필수 (어느 그룹)
|
||||
* - parent_detail_id (optional): 지정 시 해당 부모의 자식만, 미지정 시 그룹 전체 트리 (재귀 CTE)
|
||||
* - flat=true 인 경우 동일 (트리는 평탄화된 depth+sort_order 순)
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public ResponseEntity<Map<String, Object>> getCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
Map<String, Object> svcResult = service.getCommonCodeList(categoryCode, params);
|
||||
|
||||
Map<String, Object> response = new java.util.LinkedHashMap<>();
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", svcResult.get("data"));
|
||||
response.put("total", svcResult.get("total"));
|
||||
response.put("message", "코드 목록 조회 성공");
|
||||
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
// 특정 부모 직속 자식만
|
||||
Map<String, Object> svc = service.getCodeDetailList(codeInfo, params);
|
||||
response.put("data", svc.get("data"));
|
||||
response.put("total", svc.get("total"));
|
||||
} else {
|
||||
// 그룹 전체 트리 (재귀 CTE 로 평탄화)
|
||||
List<Map<String, Object>> tree = service.getCodeDetailTree(codeInfo, companyCode);
|
||||
response.put("data", tree);
|
||||
response.put("total", tree.size());
|
||||
}
|
||||
response.put("message", "코드 디테일 조회 성공");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// POST /categories/:categoryCode/codes
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
/** 디테일 중복 체크 — /{id} 보다 먼저 선언 */
|
||||
@GetMapping("/detail/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDetailDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestParam("code_info") String codeInfo,
|
||||
@RequestParam("code_value") String codeValue,
|
||||
@RequestParam(value = "exclude_id", required = false) Long excludeId) {
|
||||
|
||||
@PostMapping("/categories/{categoryCode}/codes")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCommonCode(
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDetailDuplicate(codeInfo, codeValue, excludeId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 단건 */
|
||||
@GetMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeDetailInfo(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
Map<String, Object> info = service.getCodeDetailInfo(codeDetailId, companyCode);
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
}
|
||||
|
||||
/** 디테일 자식 존재 여부 */
|
||||
@GetMapping("/detail/{id}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasCodeDetailChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasCodeDetailChildren(codeDetailId, companyCode)));
|
||||
}
|
||||
|
||||
/** 디테일 생성 */
|
||||
@PostMapping("/detail")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> insertCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (body.get("code_value") == null || body.get("code_name") == null) {
|
||||
Object codeInfoRaw = body.get("code_info");
|
||||
if (codeInfoRaw == null || body.get("code_value") == null || body.get("code_name") == null) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (codeValue, codeName)"));
|
||||
.body(ApiResponse.error("필수 필드가 누락되었습니다. (code_info, code_value, code_name)"));
|
||||
}
|
||||
try {
|
||||
Map<String, Object> created = service.insertCommonCode(categoryCode, body, companyCode, userId);
|
||||
Map<String, Object> created = service.insertCodeDetail(codeInfoRaw.toString(), body, companyCode, userId);
|
||||
return ResponseEntity.status(201)
|
||||
.body(ApiResponse.success(created, "코드가 성공적으로 생성되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -181,122 +231,18 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/check-duplicate ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkCodeDuplicate(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam(defaultValue = "code_value") String field,
|
||||
@RequestParam String value,
|
||||
@RequestParam(required = false) String excludeCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.checkCodeDuplicate(categoryCode, field, value, excludeCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/reorder ← /{codeValue} 보다 먼저
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PutMapping("/categories/{categoryCode}/codes/reorder")
|
||||
public ResponseEntity<ApiResponse<Void>> updateCommonCodeOrder(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
Object codesRaw = body.get("codes");
|
||||
if (!(codesRaw instanceof List)) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("codes 배열이 필요합니다."));
|
||||
}
|
||||
try {
|
||||
service.updateCommonCodeOrder(categoryCode, (List<Map<String, Object>>) codesRaw, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "정렬 순서가 변경되었습니다."));
|
||||
} catch (Exception e) {
|
||||
log.error("코드 정렬 변경 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("정렬 순서 변경에 실패했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/hierarchy
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/hierarchy")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeHierarchicalList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeHierarchicalList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/tree
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/tree")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCommonCodeTree(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeTree(categoryCode, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/options
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/options")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCommonCodeOptionList(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.getCommonCodeOptionList(categoryCode, params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET /categories/:categoryCode/codes/:codeValue/has-children
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/categories/{categoryCode}/codes/{codeValue}/has-children")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> hasChildren(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(service.hasChildren(categoryCode, codeValue, companyCode)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PUT /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PutMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCommonCode(
|
||||
/** 디테일 수정 */
|
||||
@PutMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue,
|
||||
@PathVariable("id") Long codeDetailId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = service.updateCommonCode(categoryCode, codeValue, body, companyCode, userId);
|
||||
Map<String, Object> updated = service.updateCodeDetail(codeDetailId, body, companyCode, userId);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404)
|
||||
.body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("코드를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "코드가 성공적으로 수정되었습니다."));
|
||||
} catch (Exception e) {
|
||||
@@ -306,18 +252,14 @@ public class CommonCodeController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DELETE /categories/:categoryCode/codes/:codeValue
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@DeleteMapping("/categories/{categoryCode}/codes/{codeValue}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCommonCode(
|
||||
/** 디테일 삭제 (CASCADE 로 자식 자동 삭제) */
|
||||
@DeleteMapping("/detail/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCodeDetail(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String categoryCode,
|
||||
@PathVariable String codeValue) {
|
||||
@PathVariable("id") Long codeDetailId) {
|
||||
|
||||
try {
|
||||
service.deleteCommonCode(categoryCode, codeValue, companyCode);
|
||||
service.deleteCodeDetail(codeDetailId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "코드가 성공적으로 삭제되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.CompanyManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -16,6 +18,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class CompanyManagementController {
|
||||
|
||||
private final SuperAdminGuard guard;
|
||||
private final CompanyManagementService companyManagementService;
|
||||
|
||||
/**
|
||||
@@ -24,9 +27,12 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@DeleteMapping("/{companyCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode,
|
||||
@RequestBody(required = false) Map<String, Object> body) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
if (body != null) {
|
||||
@@ -52,7 +58,11 @@ public class CompanyManagementController {
|
||||
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
||||
*/
|
||||
@GetMapping("/disk-usage/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
|
||||
HttpServletRequest request) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
@@ -68,7 +78,11 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@GetMapping("/{companyCode}/disk-usage")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
|
||||
@@ -29,11 +29,12 @@ public class DdlController {
|
||||
@PostMapping("/tables")
|
||||
public ResponseEntity<ApiResponse<?>> createTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -65,11 +66,12 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> addColumn(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -91,6 +93,33 @@ public class DdlController {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
|
||||
*/
|
||||
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
|
||||
public ResponseEntity<ApiResponse<?>> dropColumn(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
||||
|
||||
if (Boolean.TRUE.equals(result.get("success"))) {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"table_name", result.get("table_name"),
|
||||
"column_name", result.get("column_name"),
|
||||
"executed_query", result.get("executed_query")
|
||||
), (String) result.get("message")));
|
||||
}
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제
|
||||
*/
|
||||
@@ -98,10 +127,11 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> dropTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
|
||||
@@ -121,10 +151,11 @@ public class DdlController {
|
||||
@PostMapping("/validate/table")
|
||||
public ResponseEntity<ApiResponse<?>> validateTableCreation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -150,12 +181,13 @@ public class DdlController {
|
||||
@GetMapping("/logs")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String ddlType) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
|
||||
@@ -169,11 +201,12 @@ public class DdlController {
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false) String fromDate,
|
||||
@RequestParam(required = false) String toDate) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
|
||||
@@ -186,10 +219,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/history")
|
||||
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
|
||||
@@ -204,10 +238,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/info")
|
||||
public ResponseEntity<ApiResponse<?>> getTableInfo(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
|
||||
@@ -229,10 +264,11 @@ public class DdlController {
|
||||
@DeleteMapping("/logs/cleanup")
|
||||
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
|
||||
@@ -246,10 +282,11 @@ public class DdlController {
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<ApiResponse<?>> getInfo(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
@@ -292,7 +329,9 @@ public class DdlController {
|
||||
// 내부 유틸
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isSuperAdmin(String companyCode) {
|
||||
return "*".equals(companyCode);
|
||||
private boolean isSuperAdmin(String companyCode, String role) {
|
||||
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
|
||||
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
|
||||
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,23 +18,32 @@ public class DepartmentController {
|
||||
|
||||
private final DepartmentService departmentService;
|
||||
|
||||
private static final java.util.regex.Pattern ISO_DATE_PATTERN =
|
||||
java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별).
|
||||
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
|
||||
* ?base_date=YYYY-MM-DD 시 해당 시점에 active 했던 부서만 반환.
|
||||
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true][&base_date=YYYY-MM-DD]
|
||||
*/
|
||||
@GetMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted,
|
||||
@RequestParam(value = "base_date", required = false) String baseDate) {
|
||||
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
|
||||
}
|
||||
if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) {
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -66,6 +75,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 생성
|
||||
* POST /api/departments/companies/{companyCode}/departments
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
|
||||
@@ -94,6 +104,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 수정
|
||||
* PUT /api/departments/{deptCode}
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PutMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
|
||||
@@ -131,6 +142,135 @@ public class DepartmentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 (read-only validation).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
|
||||
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
|
||||
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
|
||||
}
|
||||
String action = body.get("action") != null ? body.get("action").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> result;
|
||||
switch (action) {
|
||||
case "create":
|
||||
result = departmentService.bulkPreviewCreate(companyCode, rows);
|
||||
break;
|
||||
case "update_department":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
|
||||
break;
|
||||
case "update_manager":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
|
||||
break;
|
||||
default:
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
|
||||
}
|
||||
int ok = 0, err = 0;
|
||||
for (Map<String, Object> r : result) {
|
||||
if ("ok".equals(r.get("result"))) ok++; else err++;
|
||||
}
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("rows", result);
|
||||
data.put("ok_count", ok);
|
||||
data.put("error_count", err);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 적용 (@Transactional, all-or-nothing).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/create
|
||||
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/create")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("inserted", inserted);
|
||||
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/update
|
||||
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/update")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
|
||||
}
|
||||
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("updated", updated);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||
|
||||
@@ -19,21 +19,21 @@ public class EntityReferenceController {
|
||||
private final EntityReferenceService entityReferenceService;
|
||||
|
||||
/**
|
||||
* GET /api/entity-reference/code/:codeCategory
|
||||
* GET /api/entity-reference/code/:codeInfo
|
||||
* 공통 코드 데이터 조회
|
||||
*
|
||||
* NOTE: Spring MVC는 리터럴 경로 세그먼트("code")를 변수 경로({tableName})보다 우선하므로
|
||||
* /code/{codeCategory} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
|
||||
* /code/{codeInfo} 가 /{tableName}/{columnName} 보다 먼저 매핑됨.
|
||||
*/
|
||||
@GetMapping("/code/{codeCategory}")
|
||||
@GetMapping("/code/{codeInfo}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCodeData(
|
||||
@PathVariable String codeCategory,
|
||||
@PathVariable String codeInfo,
|
||||
@RequestParam(required = false, defaultValue = "100") Integer limit,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_category", codeCategory);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("limit", limit);
|
||||
if (search != null) params.put("search", search);
|
||||
@@ -41,7 +41,7 @@ public class EntityReferenceController {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(entityReferenceService.getCodeData(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("공통 코드 데이터 조회 실패: codeCategory={}", codeCategory, e);
|
||||
log.error("공통 코드 데이터 조회 실패: codeInfo={}", codeInfo, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("공통 코드 데이터 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/numbering-rule")
|
||||
@RequestMapping("/api/numbering-rules")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NumberingRuleController {
|
||||
@@ -136,7 +136,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
|
||||
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
@@ -202,7 +202,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String manualInputValue = body != null ? (String) body.get("manual_input_value") : null;
|
||||
String code = numberingRuleService.previewCode(ruleId, companyCode, formData, manualInputValue);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "미리보기 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "미리보기 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/allocate → 코드 할당 (순번 증가) */
|
||||
@@ -215,7 +215,7 @@ public class NumberingRuleController {
|
||||
Map<String, Object> formData = body != null ? (Map<String, Object>) body.get("form_data") : null;
|
||||
String userInputCode = body != null ? (String) body.get("user_input_code") : null;
|
||||
String code = numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 할당이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 할당이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/generate (deprecated) → allocateCode 위임 */
|
||||
@@ -224,18 +224,63 @@ public class NumberingRuleController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@PathVariable String ruleId) {
|
||||
String code = numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("code", code), "코드 생성이 완료되었습니다."));
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("generatedCode", code), "코드 생성이 완료되었습니다."));
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/reset → 순번 초기화 */
|
||||
/** admin 권한 (SUPER_ADMIN / ADMIN / COMPANY_ADMIN) 만 시퀀스 직접 조작 가능 */
|
||||
private boolean isAdminRole(String role) {
|
||||
return "SUPER_ADMIN".equals(role)
|
||||
|| "ADMIN".equals(role)
|
||||
|| "COMPANY_ADMIN".equals(role);
|
||||
}
|
||||
|
||||
/** POST /{ruleId}/reset → 순번 초기화 (admin 전용) */
|
||||
@PostMapping("/{ruleId}/reset")
|
||||
public ResponseEntity<ApiResponse<Void>> resetSequence(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@PathVariable String ruleId) {
|
||||
if (!isAdminRole(role)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 초기화되었습니다."));
|
||||
}
|
||||
|
||||
/** PUT /{ruleId}/sequence → 현재 시퀀스 임의 값으로 수정 (admin 전용) */
|
||||
@PutMapping("/{ruleId}/sequence")
|
||||
public ResponseEntity<ApiResponse<Void>> updateRuleSequence(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@PathVariable String ruleId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
if (!isAdminRole(role)) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
Object seqObj = body.get("sequence");
|
||||
if (seqObj == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 값이 필요합니다."));
|
||||
}
|
||||
Integer newSequence;
|
||||
try {
|
||||
newSequence = (seqObj instanceof Number)
|
||||
? ((Number) seqObj).intValue()
|
||||
: Integer.parseInt(seqObj.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 는 정수여야 합니다."));
|
||||
}
|
||||
if (newSequence < 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("sequence 는 0 이상이어야 합니다."));
|
||||
}
|
||||
numberingRuleService.updateRuleSequence(ruleId, newSequence, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "시퀀스가 수정되었습니다."));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ■ Admin
|
||||
// ================================================================
|
||||
|
||||
@@ -593,10 +593,10 @@ public class ScreenManagementController {
|
||||
}
|
||||
|
||||
@PostMapping("/copy-code-category")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeCategoryAndCodes(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> copyCodeInfoAndCodes(
|
||||
@RequestBody Map<String, Object> body) {
|
||||
try {
|
||||
int count = service.copyCodeCategoryAndCodes(body);
|
||||
int count = service.copyCodeInfoAndCodes(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
|
||||
} catch (Exception e) {
|
||||
log.error("코드 카테고리 복제 실패", e);
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.TableCategoryValueService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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/table-categories")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TableCategoryValueController {
|
||||
|
||||
private final TableCategoryValueService service;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Columns
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/all-columns */
|
||||
@GetMapping("/all-columns")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllCategoryColumns(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getAllCategoryColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("전체 카테고리 컬럼 조회 실패", e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("전체 카테고리 컬럼 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table-categories/{tableName}/columns */
|
||||
@GetMapping("/{tableName}/columns")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryColumns(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 컬럼 조회 실패: tableName={}", tableName, e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 컬럼 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Read
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/{tableName}/{columnName}/values */
|
||||
@GetMapping("/{tableName}/{columnName}/values")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCategoryValues(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestParam(required = false) String menuObjid,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean includeInactive,
|
||||
@RequestParam(required = false) String filterCompanyCode,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
// SUPER_ADMIN 이 특정 회사 기준 필터링 요청 시 해당 companyCode 사용
|
||||
String effectiveCompanyCode = ("*".equals(companyCode) && filterCompanyCode != null)
|
||||
? filterCompanyCode : companyCode;
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("company_code", effectiveCompanyCode);
|
||||
params.put("include_inactive", includeInactive);
|
||||
if (menuObjid != null) params.put("menu_objid", Long.parseLong(menuObjid));
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryValues(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 조회 실패: tableName={}, columnName={}", tableName, columnName, e);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("카테고리 값 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Write
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/table-categories/values */
|
||||
@PostMapping("/values")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addCategoryValue(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
if (body.get("menu_objid") == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("menuObjid는 필수입니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(service.addCategoryValue(body)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "카테고리 값 추가 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 추가 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 추가 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/table-categories/values/{valueId} */
|
||||
@PutMapping("/values/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCategoryValue(
|
||||
@PathVariable Long valueId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
body.put("value_id", valueId);
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(service.updateCategoryValue(body)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 수정 실패: valueId={}", valueId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 수정 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table-categories/values/{valueId} */
|
||||
@DeleteMapping("/values/{valueId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteCategoryValue(
|
||||
@PathVariable Long valueId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("value_id", valueId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
try {
|
||||
service.deleteCategoryValue(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값이 삭제되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 사용 중인 경우 400
|
||||
if (e.getMessage() != null && e.getMessage().contains("삭제할 수 없습니다")) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "카테고리 값 삭제 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 삭제 실패: valueId={}", valueId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/values/bulk-delete */
|
||||
@PostMapping("/values/bulk-delete")
|
||||
public ResponseEntity<ApiResponse<Void>> bulkDeleteCategoryValues(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Object rawIds = body.get("value_ids");
|
||||
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("삭제할 값 ID 목록이 필요합니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
service.bulkDeleteCategoryValues(body);
|
||||
int count = ((List<?>) rawIds).size();
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(null, count + "개의 카테고리 값이 삭제되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 일괄 삭제 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 일괄 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/values/reorder */
|
||||
@PostMapping("/values/reorder")
|
||||
public ResponseEntity<ApiResponse<Void>> reorderCategoryValues(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Object rawIds = body.get("ordered_value_ids");
|
||||
if (!(rawIds instanceof List) || ((List<?>) rawIds).isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("순서 정보가 필요합니다"));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
try {
|
||||
service.reorderCategoryValues(body);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "카테고리 값 순서가 변경되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 값 순서 변경 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 값 순서 변경 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Labels by Codes
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** POST /api/table-categories/labels-by-codes */
|
||||
@PostMapping("/labels-by-codes")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCategoryLabelsByCodes(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Object rawCodes = body.get("value_codes");
|
||||
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(new java.util.LinkedHashMap<>()));
|
||||
}
|
||||
body.put("company_code", companyCode);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getCategoryLabelsByCodes(body)));
|
||||
} catch (Exception e) {
|
||||
log.error("카테고리 라벨 조회 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("카테고리 라벨 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Second-Level Menus (NOTE: 리터럴 경로이므로 variable 경로보다 우선)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/second-level-menus */
|
||||
@GetMapping("/second-level-menus")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getSecondLevelMenus(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getSecondLevelMenus(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("2레벨 메뉴 목록 조회 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("2레벨 메뉴 목록 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Column Mapping
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /api/table-categories/column-mapping/{tableName}/{menuObjid} */
|
||||
@GetMapping("/column-mapping/{tableName}/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getColumnMapping(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable Long menuObjid,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("menu_objid", menuObjid);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getColumnMapping(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/table-categories/logical-columns/{tableName}/{menuObjid} */
|
||||
@GetMapping("/logical-columns/{tableName}/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getLogicalColumns(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable Long menuObjid,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("menu_objid", menuObjid);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(service.getLogicalColumns(params)));
|
||||
} catch (Exception e) {
|
||||
log.error("논리적 컬럼 목록 조회 실패: tableName={}, menuObjid={}", tableName, menuObjid, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("논리적 컬럼 목록 조회 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-categories/column-mapping */
|
||||
@PostMapping("/column-mapping")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createColumnMapping(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
String tableName = (String) body.get("table_name");
|
||||
String logicalColumnName = (String) body.get("logical_column_name");
|
||||
String physicalColumnName = (String) body.get("physical_column_name");
|
||||
Object menuObjid = body.get("menu_objid");
|
||||
|
||||
if (tableName == null || logicalColumnName == null
|
||||
|| physicalColumnName == null || menuObjid == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(
|
||||
"tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다"));
|
||||
}
|
||||
|
||||
body.put("company_code", companyCode);
|
||||
body.put("user_id", userId);
|
||||
// menuObjid를 Long으로 보장
|
||||
body.put("menu_objid", toLong(menuObjid));
|
||||
|
||||
try {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(service.createColumnMapping(body), "컬럼 매핑이 생성되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 생성 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 생성 실패", e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 생성 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/table-categories/column-mapping/{tableName}/{columnName}/all
|
||||
* NOTE: 3-segment 경로이므로 /{mappingId} 1-segment 경로보다 Spring이 우선 매핑.
|
||||
*/
|
||||
@DeleteMapping("/column-mapping/{tableName}/{columnName}/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteColumnMappingsByColumn(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
try {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = service.deleteColumnMappingsByColumn(params);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("deleted_count", deleted);
|
||||
return ResponseEntity.ok(ApiResponse.success(data,
|
||||
deleted + "개의 컬럼 매핑이 삭제되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("테이블+컬럼 기준 매핑 삭제 실패: tableName={}, columnName={}", tableName, columnName, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/table-categories/column-mapping/{mappingId} */
|
||||
@DeleteMapping("/column-mapping/{mappingId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteColumnMapping(
|
||||
@PathVariable Long mappingId,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("mapping_id", mappingId);
|
||||
params.put("company_code", companyCode);
|
||||
try {
|
||||
service.deleteColumnMapping(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 매핑이 삭제되었습니다"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(500).body(ApiResponse.error(
|
||||
e.getMessage() != null ? e.getMessage() : "컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("컬럼 매핑 삭제 실패: mappingId={}", mappingId, e);
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("컬럼 매핑 삭제 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private util ───────────────────────────────────────────────
|
||||
|
||||
private long toLong(Object val) {
|
||||
if (val == null) return 0L;
|
||||
if (val instanceof Number) return ((Number) val).longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/label")
|
||||
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String displayName = (String) body.get("display_name");
|
||||
String description = (String) body.get("description");
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
@@ -105,7 +109,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -115,7 +123,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> settings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
|
||||
}
|
||||
|
||||
@@ -136,7 +148,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -145,7 +161,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody List<Map<String, Object>> columnSettings,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
|
||||
}
|
||||
|
||||
@@ -166,14 +186,20 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
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("웹 타입이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
|
||||
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
|
||||
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
|
||||
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
|
||||
}
|
||||
|
||||
@@ -183,7 +209,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
String inputType = (String) body.get("input_type");
|
||||
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
|
||||
@@ -241,7 +271,11 @@ public class TableManagementController {
|
||||
@PutMapping("/tables/{tableName}/primary-key")
|
||||
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> columns = (List<String>) body.get("columns");
|
||||
if (tableName == null || columns == null || columns.isEmpty()) {
|
||||
@@ -256,7 +290,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/indexes")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
String columnName = (String) body.get("column_name");
|
||||
String indexType = (String) body.get("index_type");
|
||||
String action = (String) body.get("action");
|
||||
@@ -281,7 +319,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
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 +341,11 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
Object uniqueObj = body.get("unique");
|
||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
|
||||
@@ -325,6 +371,57 @@ public class TableManagementController {
|
||||
"테이블 데이터를 성공적으로 조회했습니다."));
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate
|
||||
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
|
||||
* → { value: number }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
|
||||
"테이블 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/aggregate-group
|
||||
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
|
||||
* → { rows: [{ group, value }, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/aggregate-group")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
|
||||
"테이블 그룹 집계를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/select-rows
|
||||
* body: { columns?, filters?, orderBy?, limit?, offset? }
|
||||
* → { rows: [{...}, ...] }
|
||||
*/
|
||||
@PostMapping("/tables/{tableName}/select-rows")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> options) {
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
|
||||
"테이블 row 를 성공적으로 조회했습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
|
||||
@PostMapping("/tables/{tableName}/record")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
|
||||
@@ -366,7 +463,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> data,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (data == null || data.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
|
||||
}
|
||||
@@ -399,7 +500,11 @@ public class TableManagementController {
|
||||
public ResponseEntity<ApiResponse<Void>> editTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -433,7 +538,11 @@ public class TableManagementController {
|
||||
@DeleteMapping("/tables/{tableName}/delete")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteTableData(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Object body) {
|
||||
@RequestBody Object body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
List<Map<String, Object>> dataList;
|
||||
if (body instanceof List) {
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -457,7 +566,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log")
|
||||
public ResponseEntity<ApiResponse<Void>> createLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
@@ -487,7 +600,11 @@ public class TableManagementController {
|
||||
@PostMapping("/tables/{tableName}/log/toggle")
|
||||
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
|
||||
tableManagementService.toggleLogTable(tableName, isActive);
|
||||
return ResponseEntity.ok(ApiResponse.success(null,
|
||||
@@ -544,7 +661,11 @@ public class TableManagementController {
|
||||
@PostMapping("/multi-table-save")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
|
||||
@RequestBody Map<String, Object> payload,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
tableManagementService.multiTableSave(payload, companyCode),
|
||||
"다중 테이블 저장이 완료되었습니다."));
|
||||
@@ -575,4 +696,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
|
||||
public static boolean isMetaContext() {
|
||||
return DbContextHolder.isMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
|
||||
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
|
||||
*/
|
||||
public static void requireManagementHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Cross-tenant operations are only available on the management site");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ public class CrossTenantController {
|
||||
*/
|
||||
@GetMapping("/_active-companies")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -92,6 +98,12 @@ public class CrossTenantController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -173,6 +185,12 @@ public class CrossTenantController {
|
||||
Map<String, Object> queryParams,
|
||||
String mapperId,
|
||||
boolean wrapSearchWithPercent) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
|
||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.RoleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.createRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
(String) body.get("auth_code"),
|
||||
auditDetails(request, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.updateRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
|
||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("objid", id);
|
||||
roleService.deleteRoleGroup(p);
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
|
||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (roleId != null) d.put("role_id", roleId);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||
private String asYn(Object raw) {
|
||||
if (raw == null) return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_CREATE,
|
||||
(String) body.get("user_id"),
|
||||
auditDetails(request, (String) body.get("user_id")));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
|
||||
}
|
||||
adminService.changeUserStatus(userId, "inactive");
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_DELETE,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.resetUserPassword(userId));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_PW_RESET,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Void 응답용 가드 (제네릭만 다름). */
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (targetUserId != null) d.put("target_user_id", targetUserId);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,135 @@ public class StartupSchemaMigrator {
|
||||
AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE)
|
||||
AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE)
|
||||
)
|
||||
""",
|
||||
|
||||
// V021 / RUN_087: BATCH_MAPPINGS 에 MAPPING_CONFIG JSONB 컬럼 추가.
|
||||
// conditional 매핑(when/then/default) 규칙 저장용.
|
||||
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
|
||||
|
||||
// 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)",
|
||||
|
||||
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
|
||||
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
|
||||
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
|
||||
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
|
||||
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
|
||||
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
|
||||
"""
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
)
|
||||
""",
|
||||
|
||||
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
|
||||
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
|
||||
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
|
||||
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
|
||||
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
|
||||
// - 이미 CODE_INFO 인 신규 테넌트: no-op
|
||||
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
|
||||
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
|
||||
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
|
||||
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
|
||||
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
|
||||
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
|
||||
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
|
||||
"""
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
)
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
|
||||
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
|
||||
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
|
||||
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
|
||||
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
|
||||
// IF NOT EXISTS 누락 문제도 회피.
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
|
||||
|
||||
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
|
||||
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
|
||||
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
|
||||
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
|
||||
// 매핑:
|
||||
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
|
||||
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
|
||||
// datetime → date
|
||||
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
|
||||
// WHERE 절로 멱등 (재실행 시 0 row).
|
||||
"""
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -200,9 +329,18 @@ public class StartupSchemaMigrator {
|
||||
}
|
||||
|
||||
int ok = 0, fail = 0;
|
||||
List<String> failedDbs = new java.util.ArrayList<>();
|
||||
for (String db : tenantDbs) {
|
||||
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
|
||||
if (applyTo(db, "tenant")) ok++; else fail++;
|
||||
if (applyTo(db, "tenant")) {
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
failedDbs.add(db);
|
||||
}
|
||||
}
|
||||
if (!failedDbs.isEmpty()) {
|
||||
log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs);
|
||||
}
|
||||
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
|
||||
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
||||
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
||||
|
||||
// cross-tenant write 감사 액션
|
||||
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
|
||||
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
|
||||
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
|
||||
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@@ -100,13 +100,22 @@ public class DataCopier {
|
||||
try (Statement us = dst.createStatement()) {
|
||||
for (String[] r : rows) {
|
||||
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
|
||||
if (!isIntegerLike(coltype)) {
|
||||
String sql;
|
||||
if (isIntegerLike(coltype)) {
|
||||
sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl);
|
||||
} else if (isVarcharLike(coltype)) {
|
||||
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
|
||||
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
|
||||
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
|
||||
sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl, col);
|
||||
} else {
|
||||
skippedType++;
|
||||
continue;
|
||||
}
|
||||
String sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl);
|
||||
try {
|
||||
us.execute(sql);
|
||||
updated++;
|
||||
@@ -117,14 +126,8 @@ public class DataCopier {
|
||||
}
|
||||
}
|
||||
}
|
||||
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
|
||||
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
|
||||
if (updated == 0 && skippedErr == 0) {
|
||||
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
|
||||
} else {
|
||||
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
|
||||
updated, skippedType, skippedErr, rows.size());
|
||||
}
|
||||
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
|
||||
updated, skippedType, skippedErr, rows.size());
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -135,6 +138,12 @@ public class DataCopier {
|
||||
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
|
||||
}
|
||||
|
||||
private static boolean isVarcharLike(String coltype) {
|
||||
if (coltype == null) return false;
|
||||
String t = coltype.toLowerCase();
|
||||
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
|
||||
}
|
||||
|
||||
private List<String> listColumns(Connection conn, String table) throws SQLException {
|
||||
List<String> cols = new ArrayList<>();
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
|
||||
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
@@ -40,13 +37,7 @@ public class ProvisioningController {
|
||||
private final ProvisioningRegistry registry;
|
||||
private final SqlSession sqlSession;
|
||||
private final CompanyStatsService statsService;
|
||||
|
||||
/**
|
||||
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
||||
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
||||
*/
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
private final SuperAdminGuard guard;
|
||||
|
||||
@GetMapping("/table-groups")
|
||||
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||
@@ -208,23 +199,11 @@ public class ProvisioningController {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 권한 체크
|
||||
//
|
||||
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
||||
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
||||
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
||||
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
||||
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
|
||||
// CompanyMgmtController 와 동일한 가드를 공유.
|
||||
// ------------------------------------------------------------------
|
||||
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
||||
"tenant.provisioning.require-super-admin=true in production)");
|
||||
return;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
||||
guard.enforce(request);
|
||||
}
|
||||
|
||||
// --- Validation helpers ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.provisioning;
|
||||
|
||||
import com.erp.tenant.ReservedSubdomains;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
||||
*
|
||||
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
|
||||
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
|
||||
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
|
||||
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
||||
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
||||
*
|
||||
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Slf4j
|
||||
public class SuperAdminGuard {
|
||||
|
||||
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
|
||||
public void enforce(HttpServletRequest request) {
|
||||
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
|
||||
String host = request.getHeader("Host");
|
||||
if (isTenantHost(host)) {
|
||||
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
|
||||
host, request.getRequestURI(), request.getAttribute("user_type"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Provisioning is only available on the management site");
|
||||
}
|
||||
|
||||
// 2) role 체크
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
return userId == null ? "dev-anonymous" : userId;
|
||||
}
|
||||
|
||||
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
|
||||
public String requestHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
return host == null ? "" : host;
|
||||
}
|
||||
|
||||
/**
|
||||
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
|
||||
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
|
||||
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
|
||||
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
|
||||
*/
|
||||
public static boolean isTenantHost(String host) {
|
||||
if (host == null || host.isBlank()) return false;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return false;
|
||||
if (IPV4.matcher(host).matches()) return false;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length == 2) {
|
||||
// {sub}.localhost (dev)
|
||||
if (!"localhost".equals(parts[1])) return false;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return false;
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
if (parts.length < 3) return false;
|
||||
|
||||
String first = parts[0];
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
|
||||
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
|
||||
if (isSkippablePath(path)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
String companyCode = (String) request.getAttribute("company_code");
|
||||
|
||||
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private static boolean isSkippablePath(String path) {
|
||||
return path.startsWith("/api/auth/")
|
||||
|| path.equals("/api/admin/menus")
|
||||
|| path.equals("/api/admin/user-menus")
|
||||
|| path.equals("/api/admin/user-locale");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.batch.BatchExecutor;
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -24,8 +25,11 @@ public class BatchManagementService extends BaseService {
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private BatchExecutor batchExecutor;
|
||||
|
||||
private static final String NS = "batchManagement.";
|
||||
private static final String EXEC_LOG_NS = "batchExecutionLog.";
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -113,24 +117,102 @@ public class BatchManagementService extends BaseService {
|
||||
Map<String, Object> batchConfig = batchService.getBatchInfo(params);
|
||||
if (batchConfig == null) throw new RuntimeException("배치 설정을 찾을 수 없습니다.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
long startMs = System.currentTimeMillis();
|
||||
String batchName = str(batchConfig.get("batch_name"));
|
||||
String companyCode = str(batchConfig.get("company_code"));
|
||||
log.info("배치 수동 실행: id={}, name={}", id, batchName);
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
// 1. 실행 로그 INSERT — RUNNING 상태로 먼저 박아두면 도중 비정상 종료해도 추적 가능
|
||||
Map<String, Object> logRow = new LinkedHashMap<>();
|
||||
logRow.put("batch_config_id", id);
|
||||
logRow.put("company_code", companyCode);
|
||||
logRow.put("execution_status", "RUNNING");
|
||||
logRow.put("server_name", safeHostName());
|
||||
logRow.put("process_id", String.valueOf(ProcessHandle.current().pid()));
|
||||
try {
|
||||
sqlSession.insert(EXEC_LOG_NS + "insertBatchExecutionLog", logRow);
|
||||
} catch (Exception e) {
|
||||
log.warn("실행 로그 INSERT 실패 (실행은 계속 진행): {}", e.getMessage());
|
||||
}
|
||||
Object logId = logRow.get("id");
|
||||
|
||||
// 2. 실제 ETL 실행 — 예외는 로그에 기록 후 다시 throw (controller 의 에러 응답 위해)
|
||||
BatchExecutor.ExecutionResult execResult = null;
|
||||
String status = "SUCCESS";
|
||||
String errorMessage = null;
|
||||
try {
|
||||
execResult = batchExecutor.execute(batchConfig);
|
||||
if (execResult.failedRecords > 0) {
|
||||
status = execResult.successRecords > 0 ? "PARTIAL" : "FAILED";
|
||||
}
|
||||
if (!execResult.errorMessages.isEmpty()) {
|
||||
errorMessage = String.join("\n", execResult.errorMessages);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
status = "FAILED";
|
||||
errorMessage = e.getMessage();
|
||||
log.error("배치 실행 중 예외: id={} — {}", id, e.getMessage(), e);
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startMs;
|
||||
|
||||
// 3. 실행 로그 UPDATE — 최종 상태/카운트/duration 마무리
|
||||
// 주의: batch_execution_logs 의 duration_ms / *_records 컬럼은 운영 DB 에서 VARCHAR
|
||||
// (V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 로 자동 변환하지 못할 수 있어
|
||||
// 명시적으로 String 으로 보낸다. mapper 의 COALESCE default 도 '0' (문자열) 이라 일관됨.
|
||||
if (logId != null) {
|
||||
Map<String, Object> updateLog = new LinkedHashMap<>();
|
||||
updateLog.put("id", logId);
|
||||
updateLog.put("execution_status", status);
|
||||
updateLog.put("end_time", new java.sql.Timestamp(System.currentTimeMillis()));
|
||||
updateLog.put("duration_ms", String.valueOf(duration));
|
||||
updateLog.put("total_records", String.valueOf(execResult != null ? execResult.totalRecords : 0));
|
||||
updateLog.put("success_records", String.valueOf(execResult != null ? execResult.successRecords : 0));
|
||||
updateLog.put("failed_records", String.valueOf(execResult != null ? execResult.failedRecords : 0));
|
||||
if (errorMessage != null) updateLog.put("error_message", errorMessage);
|
||||
try {
|
||||
sqlSession.update(EXEC_LOG_NS + "updateBatchExecutionLog", updateLog);
|
||||
} catch (Exception e) {
|
||||
log.warn("실행 로그 UPDATE 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("batch_name", batchName);
|
||||
result.put("total_records", 0);
|
||||
result.put("success_records", 0);
|
||||
result.put("failed_records", 0);
|
||||
result.put("execution_status", status);
|
||||
result.put("total_records", execResult != null ? execResult.totalRecords : 0);
|
||||
result.put("success_records", execResult != null ? execResult.successRecords : 0);
|
||||
result.put("failed_records", execResult != null ? execResult.failedRecords : 0);
|
||||
result.put("execution_time", duration);
|
||||
if (errorMessage != null) result.put("error_message", errorMessage);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 실행 로그 server_name 컬럼용 — hostname resolve 실패 시 "unknown". */
|
||||
private static String safeHostName() {
|
||||
try {
|
||||
return java.net.InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// ── REST API Preview / Save ───────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> previewRestApiData(Map<String, Object> body) {
|
||||
// 프론트(batchManagement.ts)는 camelCase 로 키를 보내고 백엔드는 snake_case 로 읽음.
|
||||
// 기존 convertCamelToSnake() 는 batch_configs 전용 remap 이라 여기엔 효과 없음.
|
||||
// → previewRestApiData 전용으로 사용하는 키만 직접 remap.
|
||||
remap(body, "apiUrl", "api_url");
|
||||
remap(body, "apiKey", "api_key");
|
||||
remap(body, "requestBody", "request_body");
|
||||
remap(body, "dataArrayPath", "data_array_path");
|
||||
remap(body, "paramType", "param_type");
|
||||
remap(body, "paramName", "param_name");
|
||||
remap(body, "paramValue", "param_value");
|
||||
remap(body, "paramSource", "param_source");
|
||||
remap(body, "authServiceName", "auth_service_name");
|
||||
|
||||
String apiUrl = str(body.get("api_url"));
|
||||
String endpoint = str(body.get("endpoint"));
|
||||
String method = body.get("method") != null ? str(body.get("method")) : "GET";
|
||||
@@ -214,6 +296,11 @@ public class BatchManagementService extends BaseService {
|
||||
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,6 +16,9 @@ public class BatchService extends BaseService {
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private static final String NS = "batch.";
|
||||
private static final String EXT_NS = "externalDbConnection.";
|
||||
|
||||
@@ -29,7 +33,11 @@ public class BatchService extends BaseService {
|
||||
|
||||
public Map<String, Object> getBatchInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getBatchInfo", params);
|
||||
Map<String, Object> batch = sqlSession.selectOne(NS + "getBatchInfo", params);
|
||||
if (batch != null) {
|
||||
attachMappings(batch);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -37,9 +45,18 @@ public class BatchService extends BaseService {
|
||||
sqlSession.insert(NS + "insertBatch", params);
|
||||
Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null;
|
||||
if (id != null) {
|
||||
// batch_configs INSERT 직후 mappings 동기화 (params 에 mappings 키가 있을 때만)
|
||||
if (params.containsKey("mappings")) {
|
||||
syncMappings(id,
|
||||
toStr(params.get("company_code")),
|
||||
toMappingList(params.get("mappings")),
|
||||
toStr(params.get("created_by")));
|
||||
}
|
||||
Map<String, Object> infoParams = new HashMap<>();
|
||||
infoParams.put("id", id);
|
||||
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
|
||||
Map<String, Object> result = sqlSession.selectOne(NS + "getBatchInfo", infoParams);
|
||||
if (result != null) attachMappings(result);
|
||||
return result;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@@ -48,9 +65,89 @@ public class BatchService extends BaseService {
|
||||
public Map<String, Object> updateBatch(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateBatch", params);
|
||||
Long id = params.get("id") != null ? Long.parseLong(params.get("id").toString()) : null;
|
||||
// replace-all: body 에 mappings 키가 들어왔으면 (빈 배열 포함) 매핑 전체 교체
|
||||
if (id != null && params.containsKey("mappings")) {
|
||||
syncMappings(id,
|
||||
toStr(params.get("company_code")),
|
||||
toMappingList(params.get("mappings")),
|
||||
toStr(params.get("updated_by") != null ? params.get("updated_by") : params.get("created_by")));
|
||||
}
|
||||
Map<String, Object> infoParams = new HashMap<>();
|
||||
infoParams.put("id", params.get("id"));
|
||||
return sqlSession.selectOne(NS + "getBatchInfo", infoParams);
|
||||
Map<String, Object> result = sqlSession.selectOne(NS + "getBatchInfo", infoParams);
|
||||
if (result != null) attachMappings(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── batch_mappings replace-all 동기화 ─────────────────────────────────────
|
||||
|
||||
/** batch_config_id 의 매핑을 전부 지우고 mappings 리스트로 다시 채운다. */
|
||||
private void syncMappings(Long batchConfigId, String companyCode,
|
||||
List<Map<String, Object>> mappings, String userId) {
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("batch_config_id", batchConfigId);
|
||||
sqlSession.delete(NS + "deleteBatchMappingsByConfigId", delParams);
|
||||
|
||||
if (mappings == null || mappings.isEmpty()) return;
|
||||
|
||||
for (int i = 0; i < mappings.size(); i++) {
|
||||
Map<String, Object> row = new HashMap<>(mappings.get(i));
|
||||
row.put("batch_config_id", batchConfigId);
|
||||
if (row.get("company_code") == null) row.put("company_code", companyCode);
|
||||
if (row.get("created_by") == null) row.put("created_by", userId);
|
||||
if (row.get("mapping_order") == null) row.put("mapping_order", i + 1);
|
||||
stringifyJsonField(row, "mapping_config");
|
||||
sqlSession.insert(NS + "insertBatchMapping", row);
|
||||
}
|
||||
}
|
||||
|
||||
/** getBatchInfo 결과에 batch_mappings 리스트 attach. */
|
||||
private void attachMappings(Map<String, Object> batch) {
|
||||
Object idObj = batch.get("id");
|
||||
if (idObj == null) return;
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("batch_config_id", idObj);
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params);
|
||||
if (mappings != null) {
|
||||
for (Map<String, Object> row : mappings) parseJsonField(row, "mapping_config");
|
||||
}
|
||||
batch.put("batch_mappings", mappings != null ? mappings : new ArrayList<>());
|
||||
}
|
||||
|
||||
/** JSONB → 객체. SELECT 결과의 TEXT cast 값을 파싱해 Map/List 로 되돌린다. */
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String && !((String) val).isEmpty()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue((String) val, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 객체 → JSON 문자열. INSERT 전 ::jsonb 캐스팅을 위해 직렬화한다. null 은 그대로 둠. */
|
||||
private void stringifyJsonField(Map<String, Object> params, String key) {
|
||||
Object val = params.get(key);
|
||||
if (val == null || val instanceof String) return;
|
||||
try {
|
||||
params.put(key, objectMapper.writeValueAsString(val));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to stringify field '{}': {}", key, e.getMessage());
|
||||
params.put(key, null);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> toMappingList(Object raw) {
|
||||
if (raw == null) return new ArrayList<>();
|
||||
if (raw instanceof List) return (List<Map<String, Object>>) raw;
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private String toStr(Object v) {
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingAutoFillService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingAutoFill.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingAutoFillGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingAutoFillGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingAutoFillGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingAutoFillGroupDetail(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (group == null) return null;
|
||||
|
||||
Map<String, Object> mappingParams = new HashMap<>();
|
||||
mappingParams.put("group_code", params.get("group_code"));
|
||||
mappingParams.put("company_code", group.get("company_code"));
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
|
||||
|
||||
Map<String, Object> result = new HashMap<>(group);
|
||||
result.put("mappings", mappings);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
// Generate group code: AF_{timestamp_base36}_{count:03d}
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
Number cntNum = sqlSession.selectOne(NS + "getCascadingAutoFillGroupCount", countParams);
|
||||
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
|
||||
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
|
||||
String groupCode = "AF_" + suffix + "_" + String.format("%03d", count);
|
||||
params.put("group_code", groupCode);
|
||||
|
||||
if (params.get("is_active") == null) {
|
||||
params.put("is_active", "Y");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillGroup", params);
|
||||
|
||||
// Insert mappings
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List) {
|
||||
List<?> mappings = (List<?>) mappingsObj;
|
||||
for (int i = 0; i < mappings.size(); i++) {
|
||||
Object m = mappings.get(i);
|
||||
if (m instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mapping = (Map<String, Object>) m;
|
||||
Map<String, Object> mp = new HashMap<>(mapping);
|
||||
mp.put("group_code", groupCode);
|
||||
mp.put("company_code", companyCode);
|
||||
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
|
||||
if (mp.get("is_required") == null) mp.put("is_required", "N");
|
||||
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
String actualCompanyCode = (String) existing.get("company_code");
|
||||
params.put("company_code", actualCompanyCode);
|
||||
|
||||
sqlSession.update(NS + "updateCascadingAutoFillGroup", params);
|
||||
|
||||
// Replace mappings if provided
|
||||
if (params.containsKey("mappings")) {
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", actualCompanyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
|
||||
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List) {
|
||||
List<?> mappings = (List<?>) mappingsObj;
|
||||
for (int i = 0; i < mappings.size(); i++) {
|
||||
Object m = mappings.get(i);
|
||||
if (m instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mapping = (Map<String, Object>) m;
|
||||
Map<String, Object> mp = new HashMap<>(mapping);
|
||||
mp.put("group_code", groupCode);
|
||||
mp.put("company_code", actualCompanyCode);
|
||||
if (mp.get("is_editable") == null) mp.put("is_editable", "Y");
|
||||
if (mp.get("is_required") == null) mp.put("is_required", "N");
|
||||
if (mp.get("sort_order") == null) mp.put("sort_order", i + 1);
|
||||
sqlSession.insert(NS + "insertCascadingAutoFillMapping", mp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingAutoFillGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String companyCode = (String) existing.get("company_code");
|
||||
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillMappings", delParams);
|
||||
sqlSession.delete(NS + "deleteCascadingAutoFillGroup", delParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAutoFillMasterOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>(params);
|
||||
groupParams.put("is_active", "Y");
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
|
||||
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
|
||||
Object labelColObj = group.get("master_label_column");
|
||||
String labelColumn = (labelColObj != null && !labelColObj.toString().isEmpty())
|
||||
? sanitizeIdentifier(labelColObj.toString()) : masterValueColumn;
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(masterValueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(masterTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
}
|
||||
|
||||
public Map<String, Object> getAutoFillData(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String masterValue = (String) params.get("master_value");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>(params);
|
||||
groupParams.put("is_active", "Y");
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingAutoFillGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
String actualCompanyCode = (String) group.get("company_code");
|
||||
Map<String, Object> mappingParams = new HashMap<>();
|
||||
mappingParams.put("group_code", groupCode);
|
||||
mappingParams.put("company_code", actualCompanyCode);
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCascadingAutoFillMappingList", mappingParams);
|
||||
|
||||
if (mappings.isEmpty()) {
|
||||
Map<String, Object> empty = new HashMap<>();
|
||||
empty.put("data", new HashMap<>());
|
||||
empty.put("mappings", new ArrayList<>());
|
||||
return empty;
|
||||
}
|
||||
|
||||
String masterTable = sanitizeIdentifier((String) group.get("master_table"));
|
||||
String masterValueColumn = sanitizeIdentifier((String) group.get("master_value_column"));
|
||||
String sourceColumns = mappings.stream()
|
||||
.map(m -> sanitizeIdentifier((String) m.get("source_column")))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(sourceColumns)
|
||||
.append(" FROM ").append(masterTable)
|
||||
.append(" WHERE ").append(masterValueColumn).append(" = ?");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
sqlParams.add(masterValue);
|
||||
|
||||
if (!"*".equals(companyCode) && hasColumn(masterTable, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> dataRow = rows.isEmpty() ? null : rows.get(0);
|
||||
|
||||
Map<String, Object> autoFillData = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> mappingInfo = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> mapping : mappings) {
|
||||
String sourceColumn = (String) mapping.get("source_column");
|
||||
String targetField = (String) mapping.get("target_field");
|
||||
Object sourceValue = (dataRow != null) ? dataRow.get(sourceColumn) : null;
|
||||
Object defaultVal = mapping.get("default_value");
|
||||
Object finalValue = (sourceValue != null) ? sourceValue : defaultVal;
|
||||
|
||||
autoFillData.put(targetField, finalValue);
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("target_field", targetField);
|
||||
info.put("target_label", mapping.get("target_label"));
|
||||
info.put("value", finalValue);
|
||||
info.put("is_editable", "Y".equals(mapping.get("is_editable")));
|
||||
info.put("is_required", "Y".equals(mapping.get("is_required")));
|
||||
mappingInfo.add(info);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("data", autoFillData);
|
||||
result.put("mappings", mappingInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitizeIdentifier(String identifier) {
|
||||
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
|
||||
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingConditionService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingCondition.";
|
||||
private static final String NS_RELATION = "cascadingRelation.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingConditionList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingConditionListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingConditionList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingConditionInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingConditionInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.insert(NS + "insertCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingCondition(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.delete(NS + "deleteCascadingCondition", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
public Map<String, Object> getFilteredOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String conditionFieldValue = params.get("condition_field_value") != null
|
||||
? String.valueOf(params.get("condition_field_value")) : null;
|
||||
String parentValue = params.get("parent_value") != null
|
||||
? String.valueOf(params.get("parent_value")) : null;
|
||||
|
||||
// 1. 연쇄 관계 조회
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS_RELATION + "get_cascading_relation_by_code", params);
|
||||
if (relation == null) {
|
||||
Map<String, Object> empty = new LinkedHashMap<>();
|
||||
empty.put("data", Collections.emptyList());
|
||||
empty.put("applied_condition", null);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 2. 조건 규칙 조회 (우선순위 내림차순)
|
||||
List<Map<String, Object>> conditions = sqlSession.selectList(NS + "getCascadingConditionsByRelationCode", params);
|
||||
|
||||
// 3. 매칭 조건 탐색
|
||||
Map<String, Object> matchedCondition = null;
|
||||
if (conditionFieldValue != null) {
|
||||
for (Map<String, Object> cond : conditions) {
|
||||
String operator = (String) cond.get("condition_operator");
|
||||
String expectedValue = (String) cond.get("condition_value");
|
||||
if (evaluateCondition(conditionFieldValue, operator, expectedValue)) {
|
||||
matchedCondition = cond;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 동적 옵션 쿼리 생성
|
||||
String childTable = String.valueOf(relation.get("child_table"));
|
||||
String valueCol = String.valueOf(relation.get("child_value_column"));
|
||||
String labelCol = String.valueOf(relation.get("child_label_column"));
|
||||
Object filterColObj = relation.get("child_filter_column");
|
||||
Object orderColObj = relation.get("child_order_column");
|
||||
String filterCol = filterColObj != null ? String.valueOf(filterColObj) : null;
|
||||
String orderCol = orderColObj != null ? String.valueOf(orderColObj) : null;
|
||||
String orderDir = relation.get("child_order_direction") != null
|
||||
? String.valueOf(relation.get("child_order_direction")) : "ASC";
|
||||
|
||||
StringBuilder sql = new StringBuilder("SELECT ")
|
||||
.append(valueCol).append(" as value, ")
|
||||
.append(labelCol).append(" as label FROM ")
|
||||
.append(childTable).append(" WHERE 1=1");
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
if (parentValue != null && filterCol != null && !filterCol.isEmpty()) {
|
||||
sql.append(" AND ").append(filterCol).append(" = ?");
|
||||
sqlParams.add(parentValue);
|
||||
}
|
||||
|
||||
if (matchedCondition != null) {
|
||||
String condFilterColumn = (String) matchedCondition.get("filter_column");
|
||||
String condFilterValues = (String) matchedCondition.get("filter_values");
|
||||
if (condFilterColumn != null && condFilterValues != null) {
|
||||
String[] values = condFilterValues.split(",");
|
||||
String placeholders = Arrays.stream(values).map(v -> "?").collect(Collectors.joining(","));
|
||||
sql.append(" AND ").append(condFilterColumn).append(" IN (").append(placeholders).append(")");
|
||||
for (String v : values) sqlParams.add(v.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시 필터 (child_table에 company_code 컬럼이 있는 경우만)
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
try {
|
||||
Integer cnt = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = 'company_code'",
|
||||
Integer.class, childTable);
|
||||
if (cnt != null && cnt > 0) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("company_code 컬럼 확인 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY ");
|
||||
if (orderCol != null && !orderCol.isEmpty()) {
|
||||
sql.append(orderCol).append(" ").append(orderDir);
|
||||
} else {
|
||||
sql.append(labelCol);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
if (matchedCondition != null) {
|
||||
Map<String, Object> applied = new HashMap<>();
|
||||
applied.put("condition_id", matchedCondition.get("condition_id"));
|
||||
applied.put("condition_name", matchedCondition.get("condition_name"));
|
||||
result.put("applied_condition", applied);
|
||||
} else {
|
||||
result.put("applied_condition", null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean evaluateCondition(String actualValue, String operator, String expectedValue) {
|
||||
if (operator == null || actualValue == null || expectedValue == null) return false;
|
||||
String actual = actualValue.toLowerCase().trim();
|
||||
String expected = expectedValue.toLowerCase().trim();
|
||||
return switch (operator.toUpperCase()) {
|
||||
case "EQ", "=", "EQUALS" -> actual.equals(expected);
|
||||
case "NEQ", "!=", "<>", "NOT_EQUALS" -> !actual.equals(expected);
|
||||
case "CONTAINS", "LIKE" -> actual.contains(expected);
|
||||
case "NOT_CONTAINS", "NOT_LIKE" -> !actual.contains(expected);
|
||||
case "STARTS_WITH" -> actual.startsWith(expected);
|
||||
case "ENDS_WITH" -> actual.endsWith(expected);
|
||||
case "IN" -> Arrays.stream(expected.split(",")).map(String::trim).anyMatch(v -> v.equals(actual));
|
||||
case "NOT_IN" -> Arrays.stream(expected.split(",")).map(String::trim).noneMatch(v -> v.equals(actual));
|
||||
case "GT", ">" -> {
|
||||
try { yield Double.parseDouble(actual) > Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "GTE", ">=" -> {
|
||||
try { yield Double.parseDouble(actual) >= Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "LT", "<" -> {
|
||||
try { yield Double.parseDouble(actual) < Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "LTE", "<=" -> {
|
||||
try { yield Double.parseDouble(actual) <= Double.parseDouble(expected); }
|
||||
catch (NumberFormatException e) { yield false; }
|
||||
}
|
||||
case "IS_NULL", "NULL" -> actual.isEmpty() || "null".equals(actual) || "undefined".equals(actual);
|
||||
case "IS_NOT_NULL", "NOT_NULL" -> !actual.isEmpty() && !"null".equals(actual) && !"undefined".equals(actual);
|
||||
default -> {
|
||||
log.warn("알 수 없는 연산자: {}", operator);
|
||||
yield false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingHierarchyService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingHierarchy.";
|
||||
|
||||
private final CommonService commonService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingHierarchyGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingHierarchyGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingHierarchyGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingHierarchyGroupDetail(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (group == null) return null;
|
||||
|
||||
Map<String, Object> levelParams = new HashMap<>();
|
||||
levelParams.put("group_code", params.get("group_code"));
|
||||
levelParams.put("company_code", group.get("company_code"));
|
||||
List<Map<String, Object>> levels = sqlSession.selectList(NS + "getCascadingHierarchyLevelList", levelParams);
|
||||
|
||||
Map<String, Object> result = new HashMap<>(group);
|
||||
result.put("levels", levels);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String userId = (String) params.getOrDefault("user_id", "system");
|
||||
|
||||
// Generate group code: HG_{timestamp_base36}_{count:03d}
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
Number cntNum = sqlSession.selectOne(NS + "getCascadingHierarchyGroupCount", countParams);
|
||||
int count = (cntNum != null ? cntNum.intValue() : 0) + 1;
|
||||
String timestamp = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
String suffix = timestamp.substring(Math.max(0, timestamp.length() - 4));
|
||||
String groupCode = "HG_" + suffix + "_" + String.format("%03d", count);
|
||||
|
||||
params.put("group_code", groupCode);
|
||||
params.put("created_by", userId);
|
||||
|
||||
if (params.get("hierarchy_type") == null) params.put("hierarchy_type", "MULTI_TABLE");
|
||||
if (params.get("is_fixed_levels") == null) params.put("is_fixed_levels", "Y");
|
||||
if (params.get("empty_message") == null) params.put("empty_message", "선택해주세요");
|
||||
if (params.get("no_options_message") == null) params.put("no_options_message", "옵션이 없습니다");
|
||||
if (params.get("loading_message") == null) params.put("loading_message", "로딩 중...");
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyGroup", params);
|
||||
|
||||
// Insert levels for MULTI_TABLE type
|
||||
Object levelsObj = params.get("levels");
|
||||
if ("MULTI_TABLE".equals(params.get("hierarchy_type")) && levelsObj instanceof List) {
|
||||
List<?> levels = (List<?>) levelsObj;
|
||||
for (Object l : levels) {
|
||||
if (l instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> level = (Map<String, Object>) l;
|
||||
Map<String, Object> lp = new HashMap<>(level);
|
||||
lp.put("group_code", groupCode);
|
||||
lp.put("company_code", companyCode);
|
||||
if (lp.get("order_direction") == null) lp.put("order_direction", "ASC");
|
||||
if (lp.get("is_required") == null) lp.put("is_required", "Y");
|
||||
if (lp.get("is_searchable") == null) lp.put("is_searchable", "N");
|
||||
if (lp.get("placeholder") == null && lp.get("level_name") != null) {
|
||||
lp.put("placeholder", lp.get("level_name") + " 선택");
|
||||
}
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyLevel", lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
params.put("updated_by", params.getOrDefault("user_id", "system"));
|
||||
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
params.put("company_code", existing.get("company_code"));
|
||||
sqlSession.update(NS + "updateCascadingHierarchyGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingHierarchyGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
String groupCode = (String) params.get("group_code");
|
||||
String companyCode = (String) existing.get("company_code");
|
||||
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("group_code", groupCode);
|
||||
delParams.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyLevels", delParams);
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyGroup", delParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String groupCode = (String) params.get("group_code");
|
||||
|
||||
Map<String, Object> groupParams = new HashMap<>();
|
||||
groupParams.put("group_code", groupCode);
|
||||
groupParams.put("company_code", params.get("company_code"));
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCascadingHierarchyGroupByCode", groupParams);
|
||||
if (group == null) return null;
|
||||
|
||||
params.put("company_code", group.get("company_code"));
|
||||
if (params.get("order_direction") == null) params.put("order_direction", "ASC");
|
||||
if (params.get("is_required") == null) params.put("is_required", "Y");
|
||||
if (params.get("is_searchable") == null) params.put("is_searchable", "N");
|
||||
if (params.get("placeholder") == null && params.get("level_name") != null) {
|
||||
params.put("placeholder", params.get("level_name") + " 선택");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingHierarchyLevel", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
|
||||
if (existing == null) return null;
|
||||
|
||||
sqlSession.update(NS + "updateCascadingHierarchyLevel", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean deleteCascadingHierarchyLevel(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> existing = sqlSession.selectOne(NS + "getCascadingHierarchyLevelInfo", params);
|
||||
if (existing == null) return false;
|
||||
|
||||
sqlSession.delete(NS + "deleteCascadingHierarchyLevel", params);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Map<String, Object> getLevelOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> level = sqlSession.selectOne(NS + "getCascadingHierarchyLevelForOptions", params);
|
||||
if (level == null) return null;
|
||||
|
||||
String tableName = sanitizeIdentifier((String) level.get("table_name"));
|
||||
String valueColumn = sanitizeIdentifier((String) level.get("value_column"));
|
||||
String labelColumn = sanitizeIdentifier((String) level.get("label_column"));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ").append(valueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(tableName)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
// Parent value filter (level 2+)
|
||||
Object parentValue = params.get("parent_value");
|
||||
Object parentKeyColumn = level.get("parent_key_column");
|
||||
if (parentKeyColumn != null && !parentKeyColumn.toString().isEmpty() && parentValue != null) {
|
||||
sql.append(" AND ").append(sanitizeIdentifier(parentKeyColumn.toString())).append(" = ?");
|
||||
sqlParams.add(parentValue);
|
||||
}
|
||||
|
||||
// Fixed filter
|
||||
Object filterColumn = level.get("filter_column");
|
||||
Object filterValue = level.get("filter_value");
|
||||
if (filterColumn != null && !filterColumn.toString().isEmpty()
|
||||
&& filterValue != null && !filterValue.toString().isEmpty()) {
|
||||
sql.append(" AND ").append(sanitizeIdentifier(filterColumn.toString())).append(" = ?");
|
||||
sqlParams.add(filterValue.toString());
|
||||
}
|
||||
|
||||
// Multi-tenancy
|
||||
if (!"*".equals(companyCode) && hasColumn(tableName, "company_code")) {
|
||||
sql.append(" AND company_code = ?");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
// Order
|
||||
Object orderColumn = level.get("order_column");
|
||||
if (orderColumn != null && !orderColumn.toString().isEmpty()) {
|
||||
Object orderDir = level.get("order_direction");
|
||||
String dir = (orderDir != null && "DESC".equalsIgnoreCase(orderDir.toString())) ? "DESC" : "ASC";
|
||||
sql.append(" ORDER BY ").append(sanitizeIdentifier(orderColumn.toString())).append(" ").append(dir);
|
||||
} else {
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
|
||||
Map<String, Object> levelInfo = new LinkedHashMap<>();
|
||||
levelInfo.put("level_id", level.get("level_id"));
|
||||
levelInfo.put("level_name", level.get("level_name"));
|
||||
levelInfo.put("placeholder", level.get("placeholder"));
|
||||
levelInfo.put("is_required", level.get("is_required"));
|
||||
levelInfo.put("is_searchable", level.get("is_searchable"));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("data", options);
|
||||
result.put("level_info", levelInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitizeIdentifier(String identifier) {
|
||||
if (identifier == null || !identifier.matches("[a-zA-Z0-9_.]+")) {
|
||||
throw new IllegalArgumentException("Invalid SQL identifier: " + identifier);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CascadingMutualExclusionService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingMutualExclusion.";
|
||||
|
||||
private final CommonService commonService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingMutualExclusionList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingMutualExclusionListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingMutualExclusionList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingMutualExclusionInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingMutualExclusionInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
if (params.get("exclusion_type") == null) params.put("exclusion_type", "SAME_VALUE");
|
||||
if (params.get("error_message") == null) params.put("error_message", "동일한 값을 선택할 수 없습니다");
|
||||
|
||||
// 배제 코드 자동 생성: EX_XXXX_NNN
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Map<String, Object> countParams = new LinkedHashMap<>();
|
||||
countParams.put("company_code", companyCode);
|
||||
int count = sqlSession.selectOne(NS + "getCascadingMutualExclusionCount", countParams);
|
||||
String ts = Long.toString(System.currentTimeMillis(), 36).toUpperCase();
|
||||
ts = ts.substring(Math.max(0, ts.length() - 4));
|
||||
params.put("exclusion_code", String.format("EX_%s_%03d", ts, count + 1));
|
||||
|
||||
sqlSession.insert(NS + "insertCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.delete(NS + "deleteCascadingMutualExclusion", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상호 배제 검증: 선택한 값들 간 충돌 여부 확인 (SAME_VALUE 타입)
|
||||
*/
|
||||
public Map<String, Object> validateCascadingMutualExclusion(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
|
||||
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> fieldValues = (Map<String, Object>) params.getOrDefault("field_values", Collections.emptyMap());
|
||||
|
||||
String fieldNamesStr = (String) exclusion.get("field_names");
|
||||
String[] fields = fieldNamesStr != null ? fieldNamesStr.split(",") : new String[0];
|
||||
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String field : fields) {
|
||||
Object v = fieldValues.get(field.trim());
|
||||
if (v != null) values.add(v.toString());
|
||||
}
|
||||
|
||||
boolean isValid = true;
|
||||
String errorMessage = null;
|
||||
List<String> conflictingFields = new ArrayList<>();
|
||||
|
||||
String exclusionType = (String) exclusion.getOrDefault("exclusion_type", "SAME_VALUE");
|
||||
if ("SAME_VALUE".equals(exclusionType)) {
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
boolean hasDuplicate = false;
|
||||
for (String v : values) {
|
||||
if (!seen.add(v)) { hasDuplicate = true; break; }
|
||||
}
|
||||
if (hasDuplicate) {
|
||||
isValid = false;
|
||||
errorMessage = (String) exclusion.get("error_message");
|
||||
Map<String, List<String>> valueCounts = new LinkedHashMap<>();
|
||||
for (String field : fields) {
|
||||
Object v = fieldValues.get(field.trim());
|
||||
if (v != null) {
|
||||
valueCounts.computeIfAbsent(v.toString(), k -> new ArrayList<>()).add(field.trim());
|
||||
}
|
||||
}
|
||||
for (List<String> fl : valueCounts.values()) {
|
||||
if (fl.size() > 1) { conflictingFields = fl; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_valid", isValid);
|
||||
result.put("error_message", isValid ? null : errorMessage);
|
||||
result.put("conflicting_fields", conflictingFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배제 옵션 조회: source_table에서 이미 선택된 값을 제외한 목록 반환
|
||||
*/
|
||||
public List<Map<String, Object>> getExcludedOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> exclusion = sqlSession.selectOne(NS + "getCascadingMutualExclusionByCode", params);
|
||||
if (exclusion == null) throw new NoSuchElementException("상호 배제 규칙을 찾을 수 없습니다.");
|
||||
|
||||
String sourceTable = (String) exclusion.get("source_table");
|
||||
String valueColumn = (String) exclusion.get("value_column");
|
||||
String labelColumn = (String) exclusion.get("label_column");
|
||||
if (labelColumn == null || labelColumn.isEmpty()) labelColumn = valueColumn;
|
||||
|
||||
boolean hasCompanyCode = hasColumn(sourceTable, "company_code");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ")
|
||||
.append(valueColumn).append(" AS value, ")
|
||||
.append(labelColumn).append(" AS label")
|
||||
.append(" FROM ").append(sourceTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
|
||||
Object selectedValuesParam = params.get("selected_values");
|
||||
if (selectedValuesParam != null) {
|
||||
List<String> excludeValues = new ArrayList<>();
|
||||
for (String v : selectedValuesParam.toString().split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) excludeValues.add(trimmed);
|
||||
}
|
||||
if (!excludeValues.isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(excludeValues.size(), "?"));
|
||||
sql.append(" AND ").append(valueColumn).append(" NOT IN (").append(placeholders).append(")");
|
||||
queryParams.addAll(excludeValues);
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY ").append(labelColumn);
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CascadingRelationService extends BaseService {
|
||||
|
||||
private static final String NS = "cascadingRelation.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCascadingRelationList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCascadingRelationListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCascadingRelationList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingRelationInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingRelationInfo", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCascadingRelationByCode(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
if (params.get("empty_parent_message") == null)
|
||||
params.put("empty_parent_message", "상위 항목을 먼저 선택하세요");
|
||||
if (params.get("no_options_message") == null)
|
||||
params.put("no_options_message", "선택 가능한 항목이 없습니다");
|
||||
if (params.get("loading_message") == null)
|
||||
params.put("loading_message", "로딩 중...");
|
||||
if (params.get("child_order_direction") == null)
|
||||
params.put("child_order_direction", "ASC");
|
||||
Object clearOnParentChange = params.get("clear_on_parent_change");
|
||||
if (clearOnParentChange == null) {
|
||||
params.put("clear_on_parent_change", "Y");
|
||||
} else if (clearOnParentChange instanceof Boolean) {
|
||||
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
|
||||
}
|
||||
params.put("is_active", "Y");
|
||||
sqlSession.insert(NS + "insertCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Object isActive = params.get("is_active");
|
||||
if (isActive instanceof Boolean) {
|
||||
params.put("is_active", Boolean.TRUE.equals(isActive) ? "Y" : "N");
|
||||
}
|
||||
Object clearOnParentChange = params.get("clear_on_parent_change");
|
||||
if (clearOnParentChange instanceof Boolean) {
|
||||
params.put("clear_on_parent_change", Boolean.TRUE.equals(clearOnParentChange) ? "Y" : "N");
|
||||
}
|
||||
sqlSession.update(NS + "updateCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCascadingRelation(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "deleteCascadingRelation", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 옵션 조회: relation_code로 관계 조회 후 parent_table에서 동적 쿼리
|
||||
*/
|
||||
public List<Map<String, Object>> getParentOptions(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
if (relation == null) {
|
||||
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String parentTable = (String) relation.get("parent_table");
|
||||
String parentValueColumn = (String) relation.get("parent_value_column");
|
||||
String parentLabelColumn = (String) relation.get("parent_label_column");
|
||||
if (parentLabelColumn == null || parentLabelColumn.isEmpty()) {
|
||||
parentLabelColumn = parentValueColumn;
|
||||
}
|
||||
|
||||
boolean hasCompanyCode = hasColumn(parentTable, "company_code");
|
||||
boolean hasStatus = hasColumn(parentTable, "status");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT ")
|
||||
.append(parentValueColumn).append(" AS value, ")
|
||||
.append(parentLabelColumn).append(" AS label")
|
||||
.append(" FROM ").append(parentTable)
|
||||
.append(" WHERE 1=1");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
if (hasStatus) {
|
||||
sql.append(" AND (status IS NULL OR status != 'N')");
|
||||
}
|
||||
sql.append(" ORDER BY ").append(parentLabelColumn).append(" ASC");
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 연쇄 옵션 조회: relation_code로 관계 조회 후 child_table에서 동적 쿼리
|
||||
* parentValue(단일) 또는 parentValues(콤마 구분 다중) 지원
|
||||
*/
|
||||
public List<Map<String, Object>> getCascadingOptions(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Object parentValueParam = params.get("parent_value");
|
||||
Object parentValuesParam = params.get("parent_values");
|
||||
|
||||
List<String> parentValueArray = new ArrayList<>();
|
||||
if (parentValuesParam != null) {
|
||||
for (String v : parentValuesParam.toString().split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
|
||||
}
|
||||
} else if (parentValueParam != null) {
|
||||
parentValueArray.add(parentValueParam.toString());
|
||||
}
|
||||
|
||||
if (parentValueArray.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, Object> relation = sqlSession.selectOne(NS + "getCascadingRelationByCode", params);
|
||||
if (relation == null) {
|
||||
throw new NoSuchElementException("연쇄 관계를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String childTable = (String) relation.get("child_table");
|
||||
String childFilterColumn = (String) relation.get("child_filter_column");
|
||||
String childValueColumn = (String) relation.get("child_value_column");
|
||||
String childLabelColumn = (String) relation.get("child_label_column");
|
||||
String childOrderColumn = (String) relation.get("child_order_column");
|
||||
String childOrderDir = (String) relation.get("child_order_direction");
|
||||
if (childOrderDir == null || childOrderDir.isEmpty()) childOrderDir = "ASC";
|
||||
|
||||
boolean hasCompanyCode = hasColumn(childTable, "company_code");
|
||||
|
||||
List<Object> queryParams = new ArrayList<>(parentValueArray);
|
||||
String placeholders = String.join(", ", Collections.nCopies(parentValueArray.size(), "?"));
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT DISTINCT ")
|
||||
.append(childValueColumn).append(" AS value, ")
|
||||
.append(childLabelColumn).append(" AS label, ")
|
||||
.append(childFilterColumn).append(" AS parent_value")
|
||||
.append(" FROM ").append(childTable)
|
||||
.append(" WHERE ").append(childFilterColumn).append(" IN (").append(placeholders).append(")");
|
||||
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
sql.append(" AND company_code = ?");
|
||||
queryParams.add(companyCode);
|
||||
}
|
||||
|
||||
if (childOrderColumn != null && !childOrderColumn.isEmpty()) {
|
||||
sql.append(" ORDER BY ").append(childOrderColumn).append(" ").append(childOrderDir);
|
||||
} else {
|
||||
sql.append(" ORDER BY ").append(childLabelColumn).append(" ASC");
|
||||
}
|
||||
|
||||
return jdbcTemplate.queryForList(sql.toString(), queryParams.toArray());
|
||||
}
|
||||
|
||||
private boolean hasColumn(String tableName, String columnName) {
|
||||
String sql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?";
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName, columnName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
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.*;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CategoryTreeService extends BaseService {
|
||||
|
||||
private static final String NS = "categoryTree.";
|
||||
|
||||
private Long toLong(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof Number n) return n.longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 조회 (플랫 리스트 → 트리 변환)
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeList(String companyCode, String tableName, String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryTreeList", params);
|
||||
return buildTree(flatList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 플랫 리스트 조회
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeFlatList(String companyCode, String tableName, String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeList", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 단건 조회
|
||||
*/
|
||||
public Map<String, Object> getCategoryTreeInfo(String companyCode, int valueId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> insertCategoryTree(Map<String, Object> body, String companyCode, String createdBy) {
|
||||
String tableName = (String) body.get("table_name");
|
||||
String columnName = (String) body.get("column_name");
|
||||
String valueCode = (String) body.get("value_code");
|
||||
String valueLabel = (String) body.get("value_label");
|
||||
|
||||
Object valueOrderRaw = body.get("value_order");
|
||||
int valueOrder = valueOrderRaw != null ? ((Number) valueOrderRaw).intValue() : 0;
|
||||
|
||||
Object parentValueIdRaw = body.get("parent_value_id");
|
||||
|
||||
// depth / path 계산
|
||||
int depth = 1;
|
||||
String path = valueLabel;
|
||||
|
||||
if (parentValueIdRaw != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("company_code", companyCode);
|
||||
parentParams.put("value_id", ((Number) parentValueIdRaw).intValue());
|
||||
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
|
||||
if (parent != null) {
|
||||
depth = ((Number) parent.get("depth")).intValue() + 1;
|
||||
if (depth > 3) {
|
||||
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
String parentPath = (String) parent.get("path");
|
||||
path = parentPath != null ? parentPath + "/" + valueLabel : valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("value_code", valueCode);
|
||||
params.put("value_label", valueLabel);
|
||||
params.put("value_order", valueOrder);
|
||||
params.put("parent_value_id", parentValueIdRaw);
|
||||
params.put("depth", depth);
|
||||
params.put("path", path);
|
||||
params.put("description", body.get("description"));
|
||||
params.put("color", body.get("color"));
|
||||
params.put("icon", body.get("icon"));
|
||||
|
||||
Object isActiveRaw = body.get("is_active");
|
||||
Object isDefaultRaw = body.get("is_default");
|
||||
params.put("is_active", isActiveRaw != null ? isActiveRaw : true);
|
||||
params.put("is_default", isDefaultRaw != null ? isDefaultRaw : false);
|
||||
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", createdBy);
|
||||
|
||||
sqlSession.insert(NS + "insertCategoryTree", params);
|
||||
|
||||
// useGeneratedKeys → params.get("value_id") 에 생성된 ID 저장
|
||||
Map<String, Object> fetchParams = new HashMap<>();
|
||||
fetchParams.put("company_code", companyCode);
|
||||
fetchParams.put("value_id", params.get("value_id"));
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryTree(String companyCode, int valueId,
|
||||
Map<String, Object> body, String updatedBy) {
|
||||
Map<String, Object> currentParams = new HashMap<>();
|
||||
currentParams.put("company_code", companyCode);
|
||||
currentParams.put("value_id", valueId);
|
||||
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryTreeInfo", currentParams);
|
||||
if (current == null) return null;
|
||||
|
||||
String currentLabel = (String) current.get("value_label");
|
||||
int currentDepth = ((Number) current.get("depth")).intValue();
|
||||
String currentPath = (String) current.get("path");
|
||||
Object currentParentId = current.get("parent_value_id");
|
||||
|
||||
String newLabel = body.containsKey("value_label") ? (String) body.get("value_label") : currentLabel;
|
||||
int newDepth = currentDepth;
|
||||
String newPath = currentPath;
|
||||
Object newParentId = body.containsKey("parent_value_id") ? body.get("parent_value_id") : currentParentId;
|
||||
|
||||
boolean labelChanged = body.containsKey("value_label")
|
||||
&& !Objects.equals(newLabel, currentLabel);
|
||||
boolean parentChanged = body.containsKey("parent_value_id")
|
||||
&& !Objects.equals(toLong(body.get("parent_value_id")), toLong(currentParentId));
|
||||
|
||||
if (parentChanged) {
|
||||
if (body.get("parent_value_id") != null) {
|
||||
Map<String, Object> newParentParams = new HashMap<>();
|
||||
newParentParams.put("company_code", companyCode);
|
||||
newParentParams.put("value_id", ((Number) body.get("parent_value_id")).intValue());
|
||||
Map<String, Object> newParent = sqlSession.selectOne(NS + "getCategoryTreeInfo", newParentParams);
|
||||
if (newParent != null) {
|
||||
newDepth = ((Number) newParent.get("depth")).intValue() + 1;
|
||||
if (newDepth > 3) {
|
||||
throw new IllegalArgumentException("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
String parentPath = (String) newParent.get("path");
|
||||
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
|
||||
}
|
||||
} else {
|
||||
newDepth = 1;
|
||||
newPath = newLabel;
|
||||
}
|
||||
} else if (labelChanged) {
|
||||
if (currentParentId != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("company_code", companyCode);
|
||||
parentParams.put("value_id", ((Number) currentParentId).intValue());
|
||||
Map<String, Object> parent = sqlSession.selectOne(NS + "getCategoryTreeInfo", parentParams);
|
||||
String parentPath = parent != null ? (String) parent.get("path") : null;
|
||||
newPath = parentPath != null ? parentPath + "/" + newLabel : newLabel;
|
||||
} else {
|
||||
newPath = newLabel;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> updateParams = new HashMap<>();
|
||||
updateParams.put("company_code", companyCode);
|
||||
updateParams.put("value_id", valueId);
|
||||
updateParams.put("value_code", body.get("value_code"));
|
||||
updateParams.put("value_label", body.containsKey("value_label") ? newLabel : null);
|
||||
updateParams.put("value_order", body.get("value_order"));
|
||||
updateParams.put("parent_value_id", newParentId);
|
||||
updateParams.put("depth", newDepth);
|
||||
updateParams.put("path", newPath);
|
||||
updateParams.put("description", body.get("description"));
|
||||
updateParams.put("color", body.get("color"));
|
||||
updateParams.put("icon", body.get("icon"));
|
||||
updateParams.put("is_active", body.get("is_active"));
|
||||
updateParams.put("is_default", body.get("is_default"));
|
||||
updateParams.put("updated_by", updatedBy);
|
||||
|
||||
int affected = sqlSession.update(NS + "updateCategoryTree", updateParams);
|
||||
if (affected == 0) return null;
|
||||
|
||||
if (labelChanged || parentChanged) {
|
||||
updateChildrenPaths(companyCode, valueId, newPath != null ? newPath : "");
|
||||
}
|
||||
|
||||
Map<String, Object> fetchParams = new HashMap<>();
|
||||
fetchParams.put("company_code", companyCode);
|
||||
fetchParams.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryTreeInfo", fetchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
public Map<String, Object> checkCanDelete(String companyCode, int valueId) {
|
||||
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) {
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "카테고리 값을 찾을 수 없습니다");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> childParams = new HashMap<>();
|
||||
childParams.put("value_id", valueId);
|
||||
childParams.put("company_code", companyCode);
|
||||
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
|
||||
int childCount = childCountObj != null ? childCountObj : 0;
|
||||
if (childCount > 0) {
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
|
||||
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
|
||||
if (inUse) {
|
||||
int count = ((Number) usage.get("count")).intValue();
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", false);
|
||||
res.put("reason", "이 카테고리 값(" + value.get("value_label") + ")은 " + count + "건의 데이터에서 사용 중이므로 삭제할 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("can_delete", true);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (자식·사용 여부 검증 후 삭제)
|
||||
*/
|
||||
@Transactional
|
||||
public boolean deleteCategoryTree(String companyCode, int valueId) {
|
||||
Map<String, Object> value = getCategoryTreeInfo(companyCode, valueId);
|
||||
if (value == null) return false;
|
||||
|
||||
// 1. 자식 존재 여부
|
||||
Map<String, Object> childParams = new HashMap<>();
|
||||
childParams.put("value_id", valueId);
|
||||
childParams.put("company_code", companyCode);
|
||||
Integer childCountObj = sqlSession.selectOne(NS + "getCategoryTreeChildrenCnt", childParams);
|
||||
int childCount = childCountObj != null ? childCountObj : 0;
|
||||
if (childCount > 0) {
|
||||
throw new IllegalStateException(
|
||||
"VALIDATION:하위 카테고리가 " + childCount + "개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.");
|
||||
}
|
||||
|
||||
// 2. 실제 데이터 사용 여부
|
||||
Map<String, Object> usage = checkCategoryValueInUse(companyCode, value);
|
||||
boolean inUse = Boolean.TRUE.equals(usage.get("in_use"));
|
||||
if (inUse) {
|
||||
int count = ((Number) usage.get("count")).intValue();
|
||||
throw new IllegalStateException(
|
||||
"VALIDATION:이 카테고리 값(" + value.get("value_label") + ")은 "
|
||||
+ value.get("table_name") + " 테이블에서 " + count + "건의 데이터가 사용 중이므로 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 3. 삭제
|
||||
Map<String, Object> deleteParams = new HashMap<>();
|
||||
deleteParams.put("company_code", companyCode);
|
||||
deleteParams.put("value_id", valueId);
|
||||
return sqlSession.delete(NS + "deleteCategoryTree", deleteParams) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeColumnList(String companyCode, String tableName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeColumnList", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
*/
|
||||
public List<Map<String, Object>> getCategoryTreeKeyList(String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectList(NS + "getCategoryTreeKeyList", params);
|
||||
}
|
||||
|
||||
// ─── private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 플랫 리스트 → 트리 구조 변환
|
||||
*/
|
||||
private List<Map<String, Object>> buildTree(List<Map<String, Object>> flatList) {
|
||||
Map<Object, Map<String, Object>> map = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> item : flatList) {
|
||||
Map<String, Object> node = new LinkedHashMap<>(item);
|
||||
node.put("children", new ArrayList<>());
|
||||
map.put(item.get("value_id"), node);
|
||||
}
|
||||
|
||||
for (Map<String, Object> item : flatList) {
|
||||
Object parentId = item.get("parent_value_id");
|
||||
Map<String, Object> node = map.get(item.get("value_id"));
|
||||
if (parentId != null && map.containsKey(parentId)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children =
|
||||
(List<Map<String, Object>>) map.get(parentId).get("children");
|
||||
children.add(node);
|
||||
} else {
|
||||
roots.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 항목들의 path 재귀 업데이트
|
||||
*/
|
||||
private void updateChildrenPaths(String companyCode, int parentValueId, String parentPath) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("parent_value_id", parentValueId);
|
||||
|
||||
List<Map<String, Object>> children = sqlSession.selectList(NS + "getCategoryTreeChildrenList", params);
|
||||
for (Map<String, Object> child : children) {
|
||||
String valueLabel = (String) child.get("value_label");
|
||||
String newPath = parentPath + "/" + valueLabel;
|
||||
|
||||
Map<String, Object> updateParams = new HashMap<>();
|
||||
updateParams.put("value_id", child.get("value_id"));
|
||||
updateParams.put("path", newPath);
|
||||
sqlSession.update(NS + "updateCategoryTreeChildPath", updateParams);
|
||||
|
||||
int childId = ((Number) child.get("value_id")).intValue();
|
||||
updateChildrenPaths(companyCode, childId, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||
* 오류 발생 시 무시하고 삭제 허용 (Node.js 동일 동작)
|
||||
*/
|
||||
private Map<String, Object> checkCategoryValueInUse(String companyCode, Map<String, Object> value) {
|
||||
String tableName = (String) value.get("table_name");
|
||||
String columnName = (String) value.get("column_name");
|
||||
String valueCode = (String) value.get("value_code");
|
||||
|
||||
Map<String, Object> notInUse = Map.of("in_use", false, "count", 0);
|
||||
|
||||
try {
|
||||
// 1. 테이블 존재 확인
|
||||
Map<String, Object> tableParams = new HashMap<>();
|
||||
tableParams.put("table_name", tableName);
|
||||
Integer teObj = sqlSession.selectOne(NS + "checkTableExists", tableParams);
|
||||
if (teObj == null || teObj == 0) return notInUse;
|
||||
|
||||
// 2. 컬럼 존재 확인
|
||||
Map<String, Object> colParams = new HashMap<>();
|
||||
colParams.put("table_name", tableName);
|
||||
colParams.put("column_name", columnName);
|
||||
Integer ceObj = sqlSession.selectOne(NS + "checkColumnExists", colParams);
|
||||
if (ceObj == null || ceObj == 0) return notInUse;
|
||||
|
||||
// 3. company_code 컬럼 존재 확인
|
||||
Map<String, Object> companyColParams = new HashMap<>();
|
||||
companyColParams.put("table_name", tableName);
|
||||
companyColParams.put("column_name", "company_code");
|
||||
Integer ccObj = sqlSession.selectOne(NS + "checkColumnExists", companyColParams);
|
||||
boolean hasCompanyCode = ccObj != null && ccObj > 0;
|
||||
|
||||
// 4. 사용 건수 조회
|
||||
int count;
|
||||
if (hasCompanyCode && !"*".equals(companyCode)) {
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("table_name", tableName);
|
||||
countParams.put("column_name", columnName);
|
||||
countParams.put("company_code", companyCode);
|
||||
countParams.put("value_code", valueCode);
|
||||
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsageWithCompany", countParams);
|
||||
count = cntObj != null ? cntObj : 0;
|
||||
} else {
|
||||
Map<String, Object> countParams = new HashMap<>();
|
||||
countParams.put("table_name", tableName);
|
||||
countParams.put("column_name", columnName);
|
||||
countParams.put("value_code", valueCode);
|
||||
Integer cntObj = sqlSession.selectOne(NS + "countCategoryUsage", countParams);
|
||||
count = cntObj != null ? cntObj : 0;
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("in_use", count > 0);
|
||||
result.put("count", count);
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용): {}", e.getMessage());
|
||||
return notInUse;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CategoryValueCascadingService extends BaseService {
|
||||
|
||||
private static final String NS = "categoryValueCascading.";
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupList(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "getCategoryValueCascadingGroupList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupInfo(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupInfo", params);
|
||||
if (group == null) return null;
|
||||
|
||||
List<Map<String, Object>> mappings = sqlSession.selectList(NS + "getCategoryValueCascadingMappingsByGroupId", params);
|
||||
|
||||
Map<String, List<Map<String, Object>>> mappingsByParent = new LinkedHashMap<>();
|
||||
for (Map<String, Object> m : mappings) {
|
||||
String parentKey = String.valueOf(m.get("parent_value_code"));
|
||||
mappingsByParent.computeIfAbsent(parentKey, k -> new ArrayList<>()).add(Map.of(
|
||||
"child_value_code", m.getOrDefault("child_value_code", ""),
|
||||
"child_value_label", m.getOrDefault("child_value_label", ""),
|
||||
"display_order", m.getOrDefault("display_order", 0)
|
||||
));
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>(group);
|
||||
result.put("mappings", mappings);
|
||||
result.put("mappings_by_parent", mappingsByParent);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingGroupByCode(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.insert(NS + "insertCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "updateCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> deleteCategoryValueCascadingGroup(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
sqlSession.update(NS + "deleteCategoryValueCascadingGroup", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> saveCategoryValueCascadingMappings(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
Object groupId = params.get("group_id");
|
||||
|
||||
sqlSession.delete(NS + "deleteCategoryValueCascadingMappingsByGroupId", params);
|
||||
|
||||
int savedCount = 0;
|
||||
Object mappingsObj = params.get("mappings");
|
||||
if (mappingsObj instanceof List<?>) {
|
||||
List<Map<String, Object>> mappings = (List<Map<String, Object>>) mappingsObj;
|
||||
for (Map<String, Object> mapping : mappings) {
|
||||
Map<String, Object> mappingParams = new HashMap<>(mapping);
|
||||
mappingParams.put("group_id", groupId);
|
||||
mappingParams.put("company_code", companyCode);
|
||||
sqlSession.insert(NS + "insertCategoryValueCascadingMapping", mappingParams);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("saved_count", savedCount);
|
||||
result.put("group_id", groupId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingParentOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(group.get("parent_table_name"));
|
||||
String columnName = String.valueOf(group.get("parent_column_name"));
|
||||
Object menuObjid = group.get("parent_menu_objid");
|
||||
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT value_code as value, value_label as label, value_order as display_order" +
|
||||
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
|
||||
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
|
||||
|
||||
if (menuObjid != null) {
|
||||
sql.append(" AND menu_objid = ?");
|
||||
sqlParams.add(menuObjid);
|
||||
}
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
sql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY value_order, value_label");
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingChildOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(group.get("child_table_name"));
|
||||
String columnName = String.valueOf(group.get("child_column_name"));
|
||||
Object menuObjid = group.get("child_menu_objid");
|
||||
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT value_code as value, value_label as label, value_order as display_order" +
|
||||
" FROM category_values WHERE table_name = ? AND column_name = ? AND is_active = true");
|
||||
List<Object> sqlParams = new ArrayList<>(Arrays.asList(tableName, columnName));
|
||||
|
||||
if (menuObjid != null) {
|
||||
sql.append(" AND menu_objid = ?");
|
||||
sqlParams.add(menuObjid);
|
||||
}
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
sql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
sqlParams.add(companyCode);
|
||||
}
|
||||
sql.append(" ORDER BY value_order, value_label");
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql.toString(), sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingOptions(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
|
||||
String parentValuesStr = params.get("parent_values") != null ? String.valueOf(params.get("parent_values")) : null;
|
||||
String parentValue = params.get("parent_value") != null ? String.valueOf(params.get("parent_value")) : null;
|
||||
|
||||
List<String> parentValueArray = new ArrayList<>();
|
||||
if (parentValuesStr != null && !parentValuesStr.isEmpty()) {
|
||||
for (String v : parentValuesStr.split(",")) {
|
||||
String trimmed = v.trim();
|
||||
if (!trimmed.isEmpty()) parentValueArray.add(trimmed);
|
||||
}
|
||||
} else if (parentValue != null && !parentValue.isEmpty()) {
|
||||
parentValueArray.add(parentValue);
|
||||
}
|
||||
|
||||
if (parentValueArray.isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> group = sqlSession.selectOne(NS + "getCategoryValueCascadingGroupByCode", params);
|
||||
if (group == null) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
Object groupId = group.get("group_id");
|
||||
String showGroupLabel = group.get("show_group_label") != null ? String.valueOf(group.get("show_group_label")) : "N";
|
||||
|
||||
String placeholders = parentValueArray.stream().map(v -> "?").collect(Collectors.joining(", "));
|
||||
String sql = "SELECT DISTINCT child_value_code as value, child_value_label as label," +
|
||||
" parent_value_code as parent_value, parent_value_label as parent_label, display_order" +
|
||||
" FROM category_value_cascading_mapping" +
|
||||
" WHERE group_id = ? AND parent_value_code IN (" + placeholders + ") AND is_active = 'Y'" +
|
||||
" ORDER BY parent_value_code, display_order, child_value_label";
|
||||
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
sqlParams.add(groupId);
|
||||
sqlParams.addAll(parentValueArray);
|
||||
|
||||
List<Map<String, Object>> options = jdbcTemplate.queryForList(sql, sqlParams.toArray());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", options);
|
||||
result.put("show_group_label", "Y".equals(showGroupLabel));
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCategoryValueCascadingMappingsByTable(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String tableName = (String) params.get("table_name");
|
||||
|
||||
StringBuilder groupSql = new StringBuilder(
|
||||
"SELECT group_id, relation_code, child_column_name" +
|
||||
" FROM category_value_cascading_group" +
|
||||
" WHERE child_table_name = ? AND is_active = 'Y'");
|
||||
List<Object> groupSqlParams = new ArrayList<>();
|
||||
groupSqlParams.add(tableName);
|
||||
|
||||
if (companyCode != null && !"*".equals(companyCode)) {
|
||||
groupSql.append(" AND (company_code = ? OR company_code = '*')");
|
||||
groupSqlParams.add(companyCode);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> groups = jdbcTemplate.queryForList(groupSql.toString(), groupSqlParams.toArray());
|
||||
|
||||
Map<String, Object> mappings = new LinkedHashMap<>();
|
||||
for (Map<String, Object> group : groups) {
|
||||
Object groupId = group.get("group_id");
|
||||
String childColumnName = String.valueOf(group.get("child_column_name"));
|
||||
|
||||
List<Map<String, Object>> groupMappings = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT child_value_code as code, child_value_label as label" +
|
||||
" FROM category_value_cascading_mapping" +
|
||||
" WHERE group_id = ? AND is_active = 'Y'" +
|
||||
" ORDER BY child_value_label",
|
||||
groupId);
|
||||
|
||||
if (!groupMappings.isEmpty()) {
|
||||
mappings.put(childColumnName, groupMappings);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", mappings);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CodeMergeService extends BaseService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final String NS = "codeMerge.";
|
||||
|
||||
// ── Tables With Column ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /tables-with-column/:columnName
|
||||
* 해당 컬럼과 company_code 컬럼을 함께 가진 public 테이블 목록 반환
|
||||
*/
|
||||
public Map<String, Object> getTablesWithColumn(String columnName) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getTablesWithColumn", params);
|
||||
|
||||
List<String> tables = rows.stream()
|
||||
.map(r -> {
|
||||
Object val = r.get("table_name");
|
||||
return val != null ? val.toString() : null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("tables", tables);
|
||||
result.put("count", tables.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Preview (column-based) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /preview
|
||||
* columnName + oldValue 기준으로 영향받을 테이블/행 수 미리보기 (DB 변경 없음)
|
||||
*/
|
||||
public Map<String, Object> previewCodeMerge(Map<String, Object> body) {
|
||||
String columnName = str(body.get("column_name"));
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(columnName) || isBlank(oldValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue)");
|
||||
}
|
||||
|
||||
log.info("코드 병합 미리보기: column={}, oldValue={}, company={}", columnName, oldValue, companyCode);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("column_name", columnName);
|
||||
List<Map<String, Object>> tableRows = sqlSession.selectList(NS + "getTablesWithColumn", params);
|
||||
|
||||
List<Map<String, Object>> preview = new ArrayList<>();
|
||||
int totalRows = 0;
|
||||
|
||||
for (Map<String, Object> tableRow : tableRows) {
|
||||
Object nameVal = tableRow.get("table_name");
|
||||
if (nameVal == null) continue;
|
||||
String tableName = nameVal.toString();
|
||||
|
||||
// 테이블명·컬럼명은 information_schema에서 검증된 값 — SQL 인젝션 위험 없음
|
||||
String countSql = String.format(
|
||||
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?",
|
||||
tableName, columnName);
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(countSql, Integer.class, oldValue, companyCode);
|
||||
if (count != null && count > 0) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", tableName);
|
||||
item.put("affected_rows", count);
|
||||
preview.add(item);
|
||||
totalRows += count;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("테이블 {} 조회 실패 (건너뜀): {}", tableName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("old_value", oldValue);
|
||||
result.put("preview", preview);
|
||||
result.put("total_affected_rows", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Merge All Tables (column-based) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /merge-all-tables
|
||||
* PostgreSQL 함수 merge_code_all_tables(columnName, oldValue, newValue, companyCode) 호출
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> mergeAllTables(Map<String, Object> body) {
|
||||
String columnName = str(body.get("column_name"));
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String newValue = str(body.get("new_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(columnName) || isBlank(oldValue) || isBlank(newValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (columnName, oldValue, newValue)");
|
||||
}
|
||||
if (oldValue.equals(newValue)) {
|
||||
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
|
||||
}
|
||||
|
||||
log.info("코드 병합 시작: column={}, {} → {}, company={}", columnName, oldValue, newValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM merge_code_all_tables(?, ?, ?, ?)",
|
||||
columnName, oldValue, newValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> affectedTables = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("table_name"));
|
||||
item.put("rows_updated", r.get("rows_updated") != null ? ((Number) r.get("rows_updated")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
log.info("코드 병합 완료: 영향 테이블 {}개, 총 {}행", affectedTables.size(), totalRows);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("column_name", columnName);
|
||||
result.put("old_value", oldValue);
|
||||
result.put("new_value", newValue);
|
||||
result.put("affected_tables", affectedTables);
|
||||
result.put("total_rows_updated", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Merge By Value ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /merge-by-value
|
||||
* PostgreSQL 함수 merge_code_by_value(oldValue, newValue, companyCode) 호출
|
||||
* 컬럼명에 관계없이 해당 값을 가진 모든 위치를 변경
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> mergeByValue(Map<String, Object> body) {
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String newValue = str(body.get("new_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(oldValue) || isBlank(newValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue, newValue)");
|
||||
}
|
||||
if (oldValue.equals(newValue)) {
|
||||
throw new IllegalArgumentException("기존 값과 새 값이 동일합니다.");
|
||||
}
|
||||
|
||||
log.info("값 기반 코드 병합 시작: {} → {}, company={}", oldValue, newValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM merge_code_by_value(?, ?, ?)",
|
||||
oldValue, newValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> affectedData = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("out_table_name"));
|
||||
item.put("column_name", r.get("out_column_name"));
|
||||
item.put("rows_updated", r.get("out_rows_updated") != null ? ((Number) r.get("out_rows_updated")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
log.info("값 기반 코드 병합 완료: {} → {}, 총 {}행", oldValue, newValue, totalRows);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("old_value", oldValue);
|
||||
result.put("new_value", newValue);
|
||||
result.put("affected_data", affectedData);
|
||||
result.put("total_rows_updated", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Preview By Value ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /preview-by-value
|
||||
* PostgreSQL 함수 preview_merge_code_by_value(oldValue, companyCode) 호출
|
||||
*/
|
||||
public Map<String, Object> previewByValue(Map<String, Object> body) {
|
||||
String oldValue = str(body.get("old_value"));
|
||||
String companyCode = str(body.get("company_code"));
|
||||
|
||||
if (isBlank(oldValue)) {
|
||||
throw new IllegalArgumentException("필수 필드가 누락되었습니다. (oldValue)");
|
||||
}
|
||||
|
||||
log.info("값 기반 코드 병합 미리보기: oldValue={}, company={}", oldValue, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM preview_merge_code_by_value(?, ?)",
|
||||
oldValue, companyCode);
|
||||
|
||||
int totalRows = rows.stream()
|
||||
.mapToInt(r -> r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0)
|
||||
.sum();
|
||||
|
||||
List<Map<String, Object>> preview = rows.stream().map(r -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("table_name", r.get("out_table_name"));
|
||||
item.put("column_name", r.get("out_column_name"));
|
||||
item.put("affected_rows", r.get("out_affected_rows") != null ? ((Number) r.get("out_affected_rows")).intValue() : 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("old_value", oldValue);
|
||||
result.put("preview", preview);
|
||||
result.put("total_affected_rows", totalRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private String str(Object val) {
|
||||
return val != null ? val.toString() : null;
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Common Code Service
|
||||
* Common Code Service — 마스터-디테일 패턴.
|
||||
*
|
||||
* commonCodeRoutes.ts 포팅.
|
||||
* 테이블: code_category, code_info
|
||||
* • code_info : 1레벨 그룹 마스터 (PK = code_info + company_code)
|
||||
* • code_detail : 2레벨 ~ 무한대 트리 (PK = code_detail_id, parent_detail_id 로 self-ref)
|
||||
*
|
||||
* 옛 캐스케이딩/카테고리 구조 폐기. 단일 그룹 안에서 재귀 트리.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -19,13 +21,11 @@ public class CommonCodeService extends BaseService {
|
||||
|
||||
private static final String NS = "commonCode.";
|
||||
|
||||
private static final long DEFAULT_MENU_OBJID = 1757401858940L;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 목록
|
||||
// CODE_INFO — 그룹 마스터 CRUD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeCategoryList(Map<String, Object> params) {
|
||||
public Map<String, Object> getCodeInfoList(Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("limit", size);
|
||||
@@ -34,425 +34,280 @@ public class CommonCodeService extends BaseService {
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "getCommonCodeCategoryList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeCategoryListCnt", params);
|
||||
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeInfoList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCodeInfoListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", categories);
|
||||
result.put("data", data);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 중복 확인
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
public Map<String, Object> getCodeInfoInfo(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCodeInfoInfo", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> checkCategoryDuplicate(String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
@Transactional
|
||||
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
|
||||
Object rawCodeInfo = body.get("code_info");
|
||||
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
|
||||
if (codeInfo != null && !codeInfo.isEmpty()
|
||||
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
|
||||
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("field", field != null ? field : "category_code");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeCategoryDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 생성
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCommonCodeCategory(Map<String, Object> body, String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", body.get("category_code"));
|
||||
params.put("category_name", body.get("category_name"));
|
||||
params.put("category_name_eng", body.getOrDefault("category_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCommonCodeCategory", params);
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", params.get("category_code"));
|
||||
q.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 수정
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCommonCodeCategory(String categoryCode, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_info", body.get("code_info"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", null));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("category_name")) params.put("category_name", body.get("category_name"));
|
||||
if (body.containsKey("category_name_eng")) params.put("category_name_eng", body.get("category_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
sqlSession.insert(NS + "insertCodeInfo", params);
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCodeCategory", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCommonCodeCategoryInfo", q);
|
||||
return getCodeInfoInfo(String.valueOf(params.get("code_info")), companyCode);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 카테고리 삭제
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCodeCategory(String categoryCode, String companyCode) {
|
||||
public Map<String, Object> updateCodeInfo(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCodeCategory", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("카테고리를 찾을 수 없습니다.");
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
|
||||
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
if (body.containsKey("menu_objid")) params.put("menu_objid", body.get("menu_objid"));
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCodeInfo", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
return getCodeInfoInfo(codeInfo, companyCode);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 목록 (snake_case + camelCase 이중 필드)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@Transactional
|
||||
public void deleteCodeInfo(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
public Map<String, Object> getCommonCodeList(String categoryCode, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCommonCodeListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
List<Map<String, Object>> codes = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
codes.add(transformCode(raw));
|
||||
}
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeInfo", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드 그룹을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
public Map<String, Object> checkCodeInfoDuplicate(String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("data", codes);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 중복 확인
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> checkCodeDuplicate(String categoryCode, String field, String value,
|
||||
String excludeCode, String companyCode) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("field", field != null ? field : "code_value");
|
||||
params.put("field", field != null ? field : "code_info");
|
||||
params.put("value", value.trim());
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeDuplicateByField", params);
|
||||
params.put("exclude_code", excludeCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeInfoDuplicateByField", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 생성
|
||||
// CODE_DETAIL — 디테일 트리 CRUD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCommonCode(String categoryCode, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parentCodeValue 기반 depth 자동 계산
|
||||
Object parentCodeValueRaw = body.getOrDefault("parent_code_value", null);
|
||||
int depth = 1;
|
||||
if (parentCodeValueRaw != null && !parentCodeValueRaw.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", parentCodeValueRaw.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 0) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("menu_objid", body.getOrDefault("menu_objid", DEFAULT_MENU_OBJID));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("parent_code_value", parentCodeValueRaw);
|
||||
params.put("depth", depth);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCommonCode", params);
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", params.get("code_value"));
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 정렬 순서 변경
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void updateCommonCodeOrder(String categoryCode, List<Map<String, Object>> codes, String companyCode) {
|
||||
for (Map<String, Object> code : codes) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", code.get("code_value"));
|
||||
params.put("sort_order", code.get("sort_order"));
|
||||
params.put("company_code", companyCode);
|
||||
sqlSession.update(NS + "updateCommonCodeSortOrder", params);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 계층형 코드 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeHierarchicalList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
public Map<String, Object> getCodeDetailList(String codeInfo, Map<String, Object> params) {
|
||||
int page = toInt(params.get("page"), 1);
|
||||
int size = toInt(params.get("size"), 20);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("limit", size);
|
||||
params.put("offset", (page - 1) * size);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
if (isActiveRaw != null) params.put("is_active", toActiveStr(isActiveRaw));
|
||||
else params.remove("is_active");
|
||||
|
||||
// parentCodeValue, depth 필터는 params에 그대로 전달 (XML에서 처리)
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeHierarchicalList", params);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
result.add(transformCode(raw));
|
||||
Object parentRaw = params.get("parent_detail_id");
|
||||
if (parentRaw != null && !parentRaw.toString().isEmpty()) {
|
||||
params.put("parent_detail_id", toLong(parentRaw));
|
||||
} else {
|
||||
params.remove("parent_detail_id");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 트리 구조 — { flat: [...], tree: [...] }
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCommonCodeTree(String categoryCode, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCommonCodeTreeList", params);
|
||||
|
||||
List<Map<String, Object>> flatTransformed = new ArrayList<>();
|
||||
for (Map<String, Object> raw : flatList) {
|
||||
flatTransformed.add(transformCode(raw));
|
||||
}
|
||||
List<Map<String, Object>> data = sqlSession.selectList(NS + "getCodeDetailList", params);
|
||||
Integer totalObj = sqlSession.selectOne(NS + "getCodeDetailListCnt", params);
|
||||
int total = totalObj != null ? totalObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("flat", flatTransformed);
|
||||
result.put("tree", buildTree(flatList));
|
||||
result.put("data", data);
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 자식 존재 여부
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> hasChildren(String categoryCode, String codeValue, String companyCode) {
|
||||
/**
|
||||
* 그룹 전체 트리 — 평탄화된 리스트로 반환 (depth + sort_order 순).
|
||||
* 프론트가 parent_detail_id 로 nest 처리하기 좋게.
|
||||
*/
|
||||
public List<Map<String, Object>> getCodeDetailTree(String codeInfo, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("company_code", companyCode);
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCommonCodeChildrenCnt", params);
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectList(NS + "getCodeDetailTree", params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeDetailInfo(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
return sqlSession.selectOne(NS + "getCodeDetailInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCodeDetail(String codeInfo, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
// parent_detail_id 기반 depth 자동 계산. NULL = 그룹 직속 (depth=2).
|
||||
Long parentDetailId = toLong(body.getOrDefault("parent_detail_id", null));
|
||||
int depth = 2;
|
||||
if (parentDetailId != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", parentDetailId);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
depth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("parent_detail_id", parentDetailId);
|
||||
params.put("code_value", body.get("code_value"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
params.put("code_name_eng", body.getOrDefault("code_name_eng", null));
|
||||
params.put("description", body.getOrDefault("description", null));
|
||||
params.put("depth", depth);
|
||||
params.put("sort_order", body.getOrDefault("sort_order", 0));
|
||||
params.put("is_active", toActiveStr(body.getOrDefault("is_active", true)));
|
||||
params.put("company_code", companyCode);
|
||||
params.put("created_by", userId);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
sqlSession.insert(NS + "insertCodeDetail", params);
|
||||
|
||||
Object newIdRaw = params.get("code_detail_id");
|
||||
Long newId = toLong(newIdRaw);
|
||||
return newId != null ? getCodeDetailInfo(newId, companyCode) : null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCodeDetail(Long codeDetailId, Map<String, Object> body,
|
||||
String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("code_value")) params.put("code_value", body.get("code_value"));
|
||||
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
|
||||
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
// parent_detail_id 변경 시 depth 재계산.
|
||||
if (body.containsKey("parent_detail_id")) {
|
||||
Long newParent = toLong(body.get("parent_detail_id"));
|
||||
int newDepth = 2;
|
||||
if (newParent != null) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("code_detail_id", newParent);
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCodeDetailParentDepth", parentParams);
|
||||
newDepth = (parentDepth != null ? parentDepth : 1) + 1;
|
||||
}
|
||||
params.put("reparent", true);
|
||||
params.put("parent_detail_id", newParent);
|
||||
params.put("depth", newDepth);
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCodeDetail", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
return getCodeDetailInfo(codeDetailId, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCodeDetail(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
int deleted = sqlSession.delete(NS + "deleteCodeDetail", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
public Map<String, Object> checkCodeDetailDuplicate(String codeInfo, String codeValue,
|
||||
Long excludeId, String companyCode) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (codeValue == null || codeValue.trim().isEmpty()) {
|
||||
result.put("is_duplicate", false);
|
||||
result.put("message", "값을 입력해주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", codeInfo);
|
||||
params.put("code_value", codeValue.trim());
|
||||
params.put("exclude_id", excludeId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailDuplicateCnt", params);
|
||||
boolean isDuplicate = countObj != null && countObj > 0;
|
||||
|
||||
result.put("is_duplicate", isDuplicate);
|
||||
result.put("message", isDuplicate ? "이미 사용 중인 값입니다." : "사용 가능한 값입니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Object> hasCodeDetailChildren(Long codeDetailId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_detail_id", codeDetailId);
|
||||
params.put("company_code", companyCode);
|
||||
|
||||
Integer countObj = sqlSession.selectOne(NS + "getCodeDetailChildrenCnt", params);
|
||||
int count = countObj != null ? countObj : 0;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("has_children", count > 0);
|
||||
result.put("count", count);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 수정
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCommonCode(String categoryCode, String codeValue,
|
||||
Map<String, Object> body, String companyCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("updated_by", userId);
|
||||
|
||||
if (body.containsKey("code_name")) params.put("code_name", body.get("code_name"));
|
||||
if (body.containsKey("code_name_eng")) params.put("code_name_eng", body.get("code_name_eng"));
|
||||
if (body.containsKey("description")) params.put("description", body.get("description"));
|
||||
if (body.containsKey("sort_order")) params.put("sort_order", body.get("sort_order"));
|
||||
if (body.containsKey("is_active")) params.put("is_active", toActiveStr(body.get("is_active")));
|
||||
|
||||
if (body.containsKey("parent_code_value")) {
|
||||
Object newParent = body.get("parent_code_value");
|
||||
params.put("parent_code_value", newParent);
|
||||
// parentCodeValue 변경 시 depth 재계산
|
||||
if (newParent != null && !newParent.toString().isEmpty()) {
|
||||
Map<String, Object> parentParams = new HashMap<>();
|
||||
parentParams.put("category_code", categoryCode);
|
||||
parentParams.put("code_value", newParent.toString());
|
||||
parentParams.put("company_code", companyCode);
|
||||
Integer parentDepth = sqlSession.selectOne(NS + "getCommonCodeParentDepth", parentParams);
|
||||
params.put("depth", (parentDepth != null ? parentDepth : 0) + 1);
|
||||
} else {
|
||||
params.put("depth", 1);
|
||||
}
|
||||
} else if (body.containsKey("depth")) {
|
||||
params.put("depth", body.get("depth"));
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateCommonCode", params);
|
||||
if (updated == 0) return null;
|
||||
|
||||
Map<String, Object> q = new HashMap<>();
|
||||
q.put("category_code", categoryCode);
|
||||
q.put("code_value", codeValue);
|
||||
q.put("company_code", companyCode);
|
||||
Map<String, Object> raw = sqlSession.selectOne(NS + "getCommonCodeInfo", q);
|
||||
return raw != null ? transformCode(raw) : null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 삭제
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCommonCode(String categoryCode, String codeValue, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("category_code", categoryCode);
|
||||
params.put("code_value", codeValue);
|
||||
params.put("company_code", companyCode);
|
||||
int deleted = sqlSession.delete(NS + "deleteCommonCode", params);
|
||||
if (deleted == 0) throw new IllegalArgumentException("코드를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 코드 옵션 목록
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCommonCodeOptionList(String categoryCode, Map<String, Object> params) {
|
||||
params.put("category_code", categoryCode);
|
||||
|
||||
Object isActiveRaw = params.get("is_active");
|
||||
// 미지정 시 활성 코드만 반환 (드롭다운 기본 동작)
|
||||
params.put("is_active", isActiveRaw != null ? toActiveStr(isActiveRaw) : "Y");
|
||||
|
||||
List<Map<String, Object>> rawList = sqlSession.selectList(NS + "getCommonCodeOptionList", params);
|
||||
List<Map<String, Object>> options = new ArrayList<>();
|
||||
for (Map<String, Object> raw : rawList) {
|
||||
Map<String, Object> opt = new LinkedHashMap<>();
|
||||
opt.put("value", raw.get("code_value"));
|
||||
opt.put("label", raw.get("code_name"));
|
||||
opt.put("label_eng", raw.get("code_name_eng"));
|
||||
options.add(opt);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Private helpers
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/** snake_case 원본 + camelCase 별칭을 모두 포함하는 Map 반환 */
|
||||
private Map<String, Object> transformCode(Map<String, Object> raw) {
|
||||
Map<String, Object> item = new LinkedHashMap<>(raw);
|
||||
item.put("code_value", raw.get("code_value"));
|
||||
item.put("code_name", raw.get("code_name"));
|
||||
item.put("code_name_eng", raw.get("code_name_eng"));
|
||||
item.put("code_category", raw.get("code_category"));
|
||||
item.put("sort_order", raw.get("sort_order"));
|
||||
item.put("is_active", raw.get("is_active"));
|
||||
item.put("menu_objid", raw.get("menu_objid"));
|
||||
item.put("company_code", raw.get("company_code"));
|
||||
item.put("parent_code_value", raw.get("parent_code_value"));
|
||||
item.put("created_by", raw.get("created_by"));
|
||||
item.put("updated_by", raw.get("updated_by"));
|
||||
item.put("created_date", raw.get("created_date"));
|
||||
item.put("updated_date", raw.get("updated_date"));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 평탄 목록을 부모-자식 트리로 변환 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> buildTree(List<Map<String, Object>> codes) {
|
||||
Map<String, Map<String, Object>> byValue = new LinkedHashMap<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Map<String, Object> node = new LinkedHashMap<>(transformCode(code));
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
byValue.put(val, node);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (Map<String, Object> code : codes) {
|
||||
String val = objToStr(code.get("code_value"));
|
||||
Object parentRaw = code.get("parent_code_value");
|
||||
String parentVal = (parentRaw != null) ? parentRaw.toString() : null;
|
||||
|
||||
Map<String, Object> node = byValue.get(val);
|
||||
if (parentVal == null || parentVal.isEmpty() || !byValue.containsKey(parentVal)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
((List<Map<String, Object>>) byValue.get(parentVal).get("children")).add(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** boolean/String → VARCHAR 'Y'/'N' 변환 */
|
||||
/** boolean / String / Number → VARCHAR(1) 'Y'/'N'. */
|
||||
private String toActiveStr(Object val) {
|
||||
if (val == null) return "Y";
|
||||
if (val instanceof Boolean b) return b ? "Y" : "N";
|
||||
if (val instanceof Number n) return n.intValue() != 0 ? "Y" : "N";
|
||||
String s = val.toString().toLowerCase();
|
||||
return ("true".equals(s) || "y".equals(s) || "1".equals(s)) ? "Y" : "N";
|
||||
}
|
||||
@@ -463,7 +318,12 @@ public class CommonCodeService extends BaseService {
|
||||
catch (NumberFormatException e) { return defaultVal; }
|
||||
}
|
||||
|
||||
private String objToStr(Object val) {
|
||||
return val != null ? val.toString() : "";
|
||||
private Long toLong(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof Number n) return n.longValue();
|
||||
String s = val.toString().trim();
|
||||
if (s.isEmpty() || "null".equalsIgnoreCase(s)) return null;
|
||||
try { return Long.parseLong(s); }
|
||||
catch (NumberFormatException e) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -39,12 +40,6 @@ public class DdlService extends BaseService {
|
||||
"id", "created_date", "updated_date", "company_code"
|
||||
);
|
||||
|
||||
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
|
||||
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
);
|
||||
|
||||
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
@@ -146,9 +141,9 @@ public class DdlService extends BaseService {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
String inputType = convertToInputType(column);
|
||||
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
@@ -231,6 +226,79 @@ public class DdlService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> dropColumn(String tableName, String columnName,
|
||||
String companyCode, String userId) {
|
||||
// 1. 시스템 테이블 보호
|
||||
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
||||
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
|
||||
}
|
||||
|
||||
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
|
||||
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
|
||||
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
|
||||
}
|
||||
|
||||
// 3. 테이블/컬럼 존재 여부
|
||||
if (!tableExists(tableName)) {
|
||||
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
|
||||
}
|
||||
if (!columnExists(tableName, columnName)) {
|
||||
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
|
||||
}
|
||||
|
||||
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
|
||||
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
|
||||
|
||||
try {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
// 컬럼 메타 청소
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
return null;
|
||||
});
|
||||
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
|
||||
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
|
||||
|
||||
return Map.of(
|
||||
"success", true,
|
||||
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
|
||||
"table_name", tableName,
|
||||
"column_name", columnName,
|
||||
"executed_query", ddlQuery
|
||||
);
|
||||
} catch (Exception e) {
|
||||
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
|
||||
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
|
||||
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
|
||||
: "";
|
||||
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"FAILED: " + rawMsg, false, errorMsg);
|
||||
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATE
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -421,9 +489,9 @@ public class DdlService extends BaseService {
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
Map<String, Object> col = columns.get(i);
|
||||
String inputType = convertToInputType(col);
|
||||
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
@@ -532,6 +600,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";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -20,17 +21,22 @@ public class DepartmentService extends BaseService {
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getDepartments(String companyCode) {
|
||||
return getDepartments(companyCode, false);
|
||||
return getDepartments(companyCode, false, null);
|
||||
}
|
||||
|
||||
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
|
||||
return getDepartments(companyCode, includeDeleted, null);
|
||||
}
|
||||
|
||||
/** 기준일 필터 — baseDate 가 있으면 해당 시점에 active 한 부서만 반환 (start_date ≤ baseDate ≤ end_date OR end_date IS NULL) */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted, String baseDate) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("include_deleted", includeDeleted);
|
||||
params.put("base_date", baseDate); // null/빈문자면 XML if 가 skip
|
||||
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
|
||||
|
||||
// member_count를 int로 변환
|
||||
for (Map<String, Object> dept : departments) {
|
||||
Object cnt = dept.get("member_count");
|
||||
if (cnt != null) {
|
||||
@@ -38,6 +44,10 @@ public class DepartmentService extends BaseService {
|
||||
} else {
|
||||
dept.put("member_count", 0);
|
||||
}
|
||||
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return departments;
|
||||
}
|
||||
@@ -46,14 +56,26 @@ public class DepartmentService extends BaseService {
|
||||
public Map<String, Object> getDepartment(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
|
||||
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -129,11 +151,15 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
sqlSession.insert("department.insertDepartment", insertParams);
|
||||
|
||||
syncManagers(deptCode, companyCode, body, "approval");
|
||||
syncManagers(deptCode, companyCode, body, "dept");
|
||||
syncManagers(deptCode, companyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
||||
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -196,10 +222,12 @@ public class DepartmentService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncManagers(deptCode, deptCompanyCode, body, "approval");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "dept");
|
||||
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
|
||||
|
||||
log.info("부서 수정 성공: deptCode={}", deptCode);
|
||||
Map<String, Object> findParams = new HashMap<>();
|
||||
findParams.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
|
||||
return getDepartment(deptCode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,6 +366,330 @@ public class DepartmentService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 (Bulk)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final int BULK_MAX_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
|
||||
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
|
||||
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
Set<String> existingNames = collectActiveDeptNames(companyCode);
|
||||
Set<String> batchNames = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
String dn = trimString(input.get("dept_name"));
|
||||
if (dn != null) batchNames.add(dn.toLowerCase());
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — preview (read-only). mode = department | manager.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkUpdateRow(input, companyCode, mode);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
|
||||
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
|
||||
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
|
||||
}
|
||||
int inserted = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String label = trimString(row.get("dept_name"));
|
||||
try {
|
||||
createDepartment(companyCode, row);
|
||||
inserted++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
|
||||
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
|
||||
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
int updated = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수.");
|
||||
}
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
|
||||
}
|
||||
try {
|
||||
if ("department".equals(mode)) {
|
||||
Map<String, Object> merged = buildMergedDeptBody(existing, row);
|
||||
Map<String, Object> result = updateDepartment(deptCode, merged);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("수정 실패: " + deptCode);
|
||||
}
|
||||
} else {
|
||||
// manager mode — row 에 명시된 role 만 sync
|
||||
if (row.containsKey("approval_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "approval");
|
||||
}
|
||||
if (row.containsKey("dept_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "dept");
|
||||
}
|
||||
if (row.containsKey("org_leaders")) {
|
||||
syncManagers(deptCode, companyCode, row, "org_leader");
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
|
||||
private Set<String> collectActiveDeptNames(String companyCode) {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
|
||||
Object name = d.get("dept_name");
|
||||
if (name != null) names.add(name.toString().trim().toLowerCase());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
|
||||
*/
|
||||
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
|
||||
Set<String> existingNames, Set<String> batchNames) {
|
||||
String deptName = trimString(row.get("dept_name"));
|
||||
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
|
||||
String lower = deptName.toLowerCase();
|
||||
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
|
||||
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
|
||||
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
|
||||
String mgrErr = validateManagerIds(row, companyCode);
|
||||
if (mgrErr != null) return mgrErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
|
||||
*/
|
||||
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) return "부서코드(dept_code) 필수.";
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
return "다른 회사의 부서: " + deptCode;
|
||||
}
|
||||
if ("department".equals(mode)) {
|
||||
String newName = trimString(row.get("dept_name"));
|
||||
if (newName != null && !newName.isEmpty()) {
|
||||
String existingName = existing.get("dept_name") != null
|
||||
? existing.get("dept_name").toString().trim() : "";
|
||||
if (!newName.equalsIgnoreCase(existingName)) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCode);
|
||||
dupParams.put("dept_name", newName);
|
||||
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
|
||||
return "이미 존재하는 부서명: " + newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
}
|
||||
return validateManagerIds(row, companyCode);
|
||||
}
|
||||
|
||||
private String validateParentForBulk(String parent, String companyCode) {
|
||||
if (parent == null) return null;
|
||||
Map<String, Object> p = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
|
||||
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
|
||||
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
|
||||
Object pc = p.get("company_code");
|
||||
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
|
||||
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateDateRange(Map<String, Object> row) {
|
||||
String sd = trimString(row.get("start_date"));
|
||||
String ed = trimString(row.get("end_date"));
|
||||
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
|
||||
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
|
||||
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateManagerIds(Map<String, Object> row, String companyCode) {
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list && list.size() > 10) {
|
||||
return key + " 는 최대 10명까지 등록 가능합니다.";
|
||||
}
|
||||
}
|
||||
List<String> ids = collectManagerUserIds(row);
|
||||
if (ids.isEmpty()) return null;
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", ids);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (valid == null || valid.size() != ids.size()) {
|
||||
Set<String> invalid = new HashSet<>(ids);
|
||||
if (valid != null) invalid.removeAll(valid);
|
||||
return "유효하지 않은 사용자 ID: " + invalid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> collectManagerUserIds(Map<String, Object> row) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof 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() && !ids.contains(uid)) ids.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 department mode — 기존값 + row override 머지.
|
||||
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
|
||||
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
|
||||
*/
|
||||
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
String[] textKeys = {
|
||||
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
|
||||
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
|
||||
"sort_order", "status", "location"
|
||||
};
|
||||
for (String k : textKeys) merged.put(k, existing.get(k));
|
||||
merged.put("start_date", stringifyDate(existing.get("start_date")));
|
||||
merged.put("end_date", stringifyDate(existing.get("end_date")));
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
String k = e.getKey();
|
||||
if ("dept_code".equals(k)) continue;
|
||||
if (e.getValue() == null) continue;
|
||||
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
|
||||
merged.put(k, e.getValue());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private String stringifyDate(Object date) {
|
||||
if (date == null) return null;
|
||||
String s = date.toString();
|
||||
return s.length() >= 10 ? s.substring(0, 10) : null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 부서원 관리
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -472,6 +824,108 @@ public class DepartmentService extends BaseService {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── 관리자 매핑 sync ────────────────────────────────
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024;
|
||||
|
||||
private void parseManagersJson(Map<String, Object> dept, String key) {
|
||||
Object raw = dept.get(key);
|
||||
if (raw == null) {
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
String s = raw.toString();
|
||||
if (s.length() > MAX_MANAGERS_JSON_BYTES) {
|
||||
log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}",
|
||||
dept.get("dept_code"), key, s.length());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
dept.put(key, parsed);
|
||||
} catch (Exception e) {
|
||||
log.warn("parseManagersJson 실패 dept_code={} key={} err={}",
|
||||
dept.get("dept_code"), key, e.getMessage());
|
||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴.
|
||||
* body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders".
|
||||
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
||||
* 최대 10명 검증 + 빈 user_id 무시.
|
||||
*/
|
||||
private void syncManagers(String deptCode, String companyCode, Map<String, Object> body, String role) {
|
||||
String bodyKey = switch (role) {
|
||||
case "approval" -> "approval_managers";
|
||||
case "dept" -> "dept_managers";
|
||||
case "org_leader" -> "org_leaders";
|
||||
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
||||
};
|
||||
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
|
||||
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
|
||||
if (!body.containsKey(bodyKey)) {
|
||||
return;
|
||||
}
|
||||
Object raw = body.get(bodyKey);
|
||||
java.util.List<String> userIds = new java.util.ArrayList<>();
|
||||
if (raw instanceof java.util.List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
|
||||
userIds.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userIds.size() > 10) {
|
||||
String roleLabel = switch (role) {
|
||||
case "approval" -> "결재 관리자";
|
||||
case "dept" -> "부서 관리자";
|
||||
case "org_leader" -> "조직장";
|
||||
default -> role;
|
||||
};
|
||||
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
|
||||
}
|
||||
// user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", userIds);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (validUserIds == null || validUserIds.size() != userIds.size()) {
|
||||
Set<String> invalid = new HashSet<>(userIds);
|
||||
if (validUserIds != null) invalid.removeAll(validUserIds);
|
||||
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
|
||||
}
|
||||
}
|
||||
// delete-all
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("dept_code", deptCode);
|
||||
delParams.put("role", role);
|
||||
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
|
||||
// insert-all
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> insParams = new HashMap<>();
|
||||
insParams.put("dept_code", deptCode);
|
||||
insParams.put("role", role);
|
||||
insParams.put("user_ids", userIds);
|
||||
sqlSession.insert("department.insertDeptManagers", insParams);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 중복 예외 클래스 ────────────────────────────────
|
||||
|
||||
public static class DuplicateDeptNameException extends RuntimeException {
|
||||
|
||||
@@ -101,20 +101,20 @@ public class EntityReferenceService extends BaseService {
|
||||
}
|
||||
|
||||
public Map<String, Object> getCodeData(Map<String, Object> params) {
|
||||
String codeCategory = (String) params.get("code_category");
|
||||
String codeInfo = (String) params.get("code_info");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
int limit = toInt(params.getOrDefault("limit", 100));
|
||||
Object search = params.get("search");
|
||||
|
||||
Map<String, Object> queryParams = new HashMap<>();
|
||||
queryParams.put("code_category", codeCategory);
|
||||
queryParams.put("code_info", codeInfo);
|
||||
queryParams.put("company_code", companyCode);
|
||||
queryParams.put("limit", limit);
|
||||
if (search != null && !search.toString().isBlank()) {
|
||||
queryParams.put("search_like", "%" + search + "%");
|
||||
}
|
||||
|
||||
log.info("공통 코드 데이터 조회: category={}, company={}", codeCategory, companyCode);
|
||||
log.info("공통 코드 데이터 조회: category={}, company={}", codeInfo, companyCode);
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectCodeData", queryParams);
|
||||
|
||||
@@ -128,7 +128,7 @@ public class EntityReferenceService extends BaseService {
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("options", options);
|
||||
result.put("code_category", codeCategory);
|
||||
result.put("code_info", codeInfo);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,12 +394,12 @@ public class EntitySearchService extends BaseService {
|
||||
Map<String, Object> ttcp = new HashMap<>();
|
||||
ttcp.put("table_name", tableName);
|
||||
ttcp.put("column_name", columnName);
|
||||
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeCategoryInfo", ttcp);
|
||||
String codeCategory = ttcRow != null ? (String) ttcRow.get("code_category") : null;
|
||||
Map<String, Object> ttcRow = sqlSession.selectOne(NS + "getCodeInfoInfo", ttcp);
|
||||
String codeInfo = ttcRow != null ? (String) ttcRow.get("code_info") : null;
|
||||
|
||||
if (codeCategory != null) {
|
||||
if (codeInfo != null) {
|
||||
Map<String, Object> cip = new HashMap<>();
|
||||
cip.put("code_category", codeCategory);
|
||||
cip.put("code_info", codeInfo);
|
||||
cip.put("raw_values", rawValues);
|
||||
cip.put("company_code", companyCode);
|
||||
List<Map<String, Object>> ciRows = sqlSession.selectList(NS + "getCodeInfoList", cip);
|
||||
|
||||
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
private Map<String, Object> executeConnectionTest(
|
||||
String dbType, Map<String, Object> conn, String password) {
|
||||
|
||||
String type = dbType == null ? "" : dbType.toLowerCase();
|
||||
String host = str(conn, "host");
|
||||
int port = toInt(conn, "port", 5432);
|
||||
int port = toInt(conn, "port", defaultPort(type));
|
||||
String database = str(conn, "database_name");
|
||||
String username = str(conn, "username");
|
||||
String sslEnabled = str(conn, "ssl_enabled");
|
||||
int connTimeout = toInt(conn, "connection_timeout", 30);
|
||||
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
|
||||
|
||||
if (!"postgresql".equalsIgnoreCase(dbType)) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
|
||||
String url;
|
||||
Properties props = new Properties();
|
||||
props.setProperty("user", username);
|
||||
props.setProperty("password", password);
|
||||
props.setProperty("connect_timeout", String.valueOf(connTimeout));
|
||||
props.setProperty("socket_timeout", "30");
|
||||
if ("Y".equalsIgnoreCase(sslEnabled)) {
|
||||
props.setProperty("ssl", "true");
|
||||
props.setProperty("sslmode", "require");
|
||||
if (username != null) props.setProperty("user", username);
|
||||
if (password != null) props.setProperty("password", password);
|
||||
|
||||
switch (type) {
|
||||
case "postgresql" -> {
|
||||
url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connect_timeout", String.valueOf(connTimeout));
|
||||
props.setProperty("socket_timeout", "30");
|
||||
if (ssl) {
|
||||
props.setProperty("ssl", "true");
|
||||
props.setProperty("sslmode", "require");
|
||||
}
|
||||
}
|
||||
case "mysql" -> {
|
||||
url = String.format("jdbc:mysql://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
|
||||
props.setProperty("socketTimeout", "30000");
|
||||
props.setProperty("useSSL", String.valueOf(ssl));
|
||||
props.setProperty("allowPublicKeyRetrieval", "true");
|
||||
}
|
||||
case "mariadb" -> {
|
||||
url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
|
||||
props.setProperty("socketTimeout", "30000");
|
||||
if (ssl) props.setProperty("useSsl", "true");
|
||||
}
|
||||
case "mssql", "sqlserver" -> {
|
||||
StringBuilder sb = new StringBuilder()
|
||||
.append("jdbc:sqlserver://").append(host).append(':').append(port)
|
||||
.append(";databaseName=").append(database)
|
||||
.append(";loginTimeout=").append(connTimeout)
|
||||
.append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false");
|
||||
url = sb.toString();
|
||||
}
|
||||
case "sqlite" -> {
|
||||
// SQLite: host/port 무의미. database_name 을 파일 경로로 사용 (비면 in-memory)
|
||||
url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:");
|
||||
}
|
||||
default -> {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", "지원하지 않는 DB 타입입니다: " + dbType);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
try (Connection c = DriverManager.getConnection(url, props);
|
||||
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "연결 성공");
|
||||
result.put("details", Map.of("host", host, "database", database, "port", port));
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("host", host == null ? "" : host);
|
||||
details.put("database", database == null ? "" : database);
|
||||
details.put("port", port);
|
||||
result.put("details", details);
|
||||
return result;
|
||||
} catch (SQLException e) {
|
||||
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage());
|
||||
@@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private int defaultPort(String dbType) {
|
||||
if (dbType == null) return 5432;
|
||||
return switch (dbType.toLowerCase()) {
|
||||
case "mysql", "mariadb" -> 3306;
|
||||
case "mssql", "sqlserver" -> 1433;
|
||||
case "sqlite" -> 0;
|
||||
default -> 5432;
|
||||
};
|
||||
}
|
||||
|
||||
// ── SQL 쿼리 실행 (SELECT only) ────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> executeQuery(long id, String sql) {
|
||||
|
||||
@@ -189,7 +189,14 @@ public class NumberingRuleService extends BaseService {
|
||||
return allocateCode(ruleId, companyCode, null, null);
|
||||
}
|
||||
|
||||
/** POST /:ruleId/reset → 순번 초기화 */
|
||||
/**
|
||||
* POST /:ruleId/reset → 순번 초기화 (admin)
|
||||
*
|
||||
* 두 테이블 다 처리:
|
||||
* 1. numbering_rule_sequences (prefix 별 발번 카운터, 실제 ground truth) 전체 DELETE → 다음 발번 1 부터
|
||||
* 2. numbering_rules.current_sequence (표시용) 직접 0 으로 set
|
||||
* - admin 전용 SQL `setCurrentSequenceInRule` 사용 (GREATEST 없음)
|
||||
*/
|
||||
@Transactional
|
||||
public void resetSequence(String ruleId, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
@@ -197,10 +204,32 @@ public class NumberingRuleService extends BaseService {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("current_sequence", 0);
|
||||
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
|
||||
sqlSession.update(NS + "updateCurrentSequenceInRule", params);
|
||||
sqlSession.update(NS + "setCurrentSequenceInRule", params);
|
||||
log.info("시퀀스 초기화 완료: ruleId={}, companyCode={}", ruleId, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /:ruleId/sequence → 현재 시퀀스 임의 값으로 수정 (admin)
|
||||
*
|
||||
* admin 이 "지금 카운터를 N 으로 set" 의도. 다음 발번은 N+1 부터.
|
||||
* 두 테이블 다 처리:
|
||||
* 1. numbering_rule_sequences (prefix 별 실제 카운터) 전체 DELETE
|
||||
* → 다음 allocate 시 새 row 가 INSERT (current_sequence=1) 되거나
|
||||
* 또는 admin set 값을 기반으로 시작하도록 별도 처리 필요할 수 있음
|
||||
* - 운영 전 단계라 historical sequence 폐기 안전
|
||||
* 2. numbering_rules.current_sequence 를 newSequence 로 set
|
||||
*/
|
||||
@Transactional
|
||||
public void updateRuleSequence(String ruleId, Integer newSequence, String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("rule_id", ruleId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("current_sequence", newSequence);
|
||||
sqlSession.delete(NS + "deleteSequencesByRuleId", params);
|
||||
sqlSession.update(NS + "setCurrentSequenceInRule", params);
|
||||
log.info("시퀀스 수정 완료: ruleId={}, newSequence={}, companyCode={}", ruleId, newSequence, companyCode);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ■ Available Rules
|
||||
// ================================================================
|
||||
@@ -426,12 +455,31 @@ public class NumberingRuleService extends BaseService {
|
||||
return seq == null ? 0L : ((Number) seq).longValue();
|
||||
}
|
||||
|
||||
/** 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING */
|
||||
/**
|
||||
* 순번 증가 UPSERT – ON CONFLICT DO UPDATE RETURNING.
|
||||
*
|
||||
* INSERT 분기의 base 값:
|
||||
* - 동일 prefix 의 row 가 없을 때 (첫 발번 / admin reset 후 / 새 카테고리 등)
|
||||
* `numbering_rules.current_sequence + 1` 부터 시작.
|
||||
* - 의미: admin 이 sequence 를 N 으로 set 하고 historical sequences 를 비웠을 때,
|
||||
* 다음 발번이 N+1 부터 정확히 시작되도록.
|
||||
* - numbering_rules row 가 없는 비정상 케이스는 0+1=1.
|
||||
*/
|
||||
private long incrementSequenceForPrefix(String ruleId, String companyCode, String prefixKey) {
|
||||
String sql = """
|
||||
INSERT INTO numbering_rule_sequences
|
||||
(rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES (?, ?, ?, 1, NOW())
|
||||
VALUES (
|
||||
?, ?, ?,
|
||||
COALESCE((
|
||||
SELECT current_sequence
|
||||
FROM numbering_rules
|
||||
WHERE rule_id = ?
|
||||
AND (company_code = ? OR company_code = '*')
|
||||
LIMIT 1
|
||||
), 0) + 1,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET
|
||||
current_sequence = numbering_rule_sequences.current_sequence + 1,
|
||||
@@ -439,7 +487,7 @@ public class NumberingRuleService extends BaseService {
|
||||
RETURNING current_sequence
|
||||
""";
|
||||
Long newSeq = jdbcTemplate.queryForObject(sql, Long.class,
|
||||
ruleId, companyCode, prefixKey);
|
||||
ruleId, companyCode, prefixKey, ruleId, companyCode);
|
||||
return newSeq != null ? newSeq : 1L;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
|
||||
|
||||
private static final String NS = "screenGroup.";
|
||||
|
||||
/**
|
||||
* canonical table / legacy table-list / hidden v2-table-list 위젯 카운트 합산.
|
||||
* screen type inference 시 셋 모두 grid 화면으로 인식해야 한다 (frontend
|
||||
* isTableLikeComponentType 와 동일 정책 — 2026-05-19 canonical cleanup follow-up).
|
||||
*/
|
||||
private static int countTableLikeWidgets(Map<String, Integer> widgetCounts) {
|
||||
if (widgetCounts == null) return 0;
|
||||
return widgetCounts.getOrDefault("table", 0)
|
||||
+ widgetCounts.getOrDefault("table-list", 0)
|
||||
+ widgetCounts.getOrDefault("v2-table-list", 0);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Screen Groups
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService {
|
||||
}
|
||||
|
||||
// 화면 타입 추론
|
||||
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
|
||||
// 어느 것이든 있으면 grid 로 본다.
|
||||
String screenType = "form";
|
||||
if (widgetCounts.getOrDefault("table", 0) > 0) {
|
||||
if (countTableLikeWidgets(widgetCounts) > 0) {
|
||||
screenType = "grid";
|
||||
} else if (widgetCounts.getOrDefault("custom", 0) > 2) {
|
||||
screenType = "dashboard";
|
||||
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
|
||||
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
|
||||
}
|
||||
|
||||
// 화면 타입 추론
|
||||
// 화면 타입 추론 — canonical / legacy / hidden v2 모두 grid 로 인식
|
||||
summaryMap.values().forEach(summary -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts");
|
||||
if (wc.getOrDefault("table-list", 0) > 0) {
|
||||
if (countTableLikeWidgets(wc) > 0) {
|
||||
summary.put("screen_type", "grid");
|
||||
} else if (wc.getOrDefault("table-search-widget", 0) > 1) {
|
||||
summary.put("screen_type", "dashboard");
|
||||
|
||||
@@ -994,7 +994,7 @@ public class ScreenManagementService extends BaseService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int copyCodeCategoryAndCodes(Map<String, Object> body) {
|
||||
public int copyCodeInfoAndCodes(Map<String, Object> body) {
|
||||
String sourceCompanyCode = (String) body.get("source_company_code");
|
||||
String targetCompanyCode = (String) body.get("target_company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
@@ -1002,16 +1002,16 @@ public class ScreenManagementService extends BaseService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("source_company_code", sourceCompanyCode);
|
||||
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeCategoryForCopy", params);
|
||||
List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeInfoForCopy", params);
|
||||
int count = 0;
|
||||
for (Map<String, Object> cat : categories) {
|
||||
Map<String, Object> cp = new HashMap<>(cat);
|
||||
cp.put("target_company_code", targetCompanyCode);
|
||||
sqlSession.insert(NS + "upsertCodeCategory", cp);
|
||||
sqlSession.insert(NS + "upsertCodeInfo", cp);
|
||||
|
||||
Map<String, Object> codeParams = new HashMap<>();
|
||||
codeParams.put("source_company_code", sourceCompanyCode);
|
||||
codeParams.put("code_category", cat.get("category_code"));
|
||||
codeParams.put("code_info", cat.get("category_code"));
|
||||
List<Map<String, Object>> codes = sqlSession.selectList(NS + "selectCodeInfoForCopy", codeParams);
|
||||
for (Map<String, Object> code : codes) {
|
||||
Map<String, Object> cop = new HashMap<>(code);
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
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.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TableCategoryValueService extends BaseService {
|
||||
|
||||
private static final String NS = "tableCategoryValue.";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Columns
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCategoryColumns(Map<String, Object> params) {
|
||||
log.info("카테고리 컬럼 목록 조회: tableName={}, companyCode={}",
|
||||
params.get("table_name"), params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getCategoryColumnList", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAllCategoryColumns(Map<String, Object> params) {
|
||||
log.info("전체 카테고리 컬럼 목록 조회: companyCode={}", params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getAllCategoryColumnList", params);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Read
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getCategoryValues(Map<String, Object> params) {
|
||||
log.info("카테고리 값 목록 조회: tableName={}, columnName={}, companyCode={}",
|
||||
params.get("table_name"), params.get("column_name"), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> flatList = sqlSession.selectList(NS + "getCategoryValueList", params);
|
||||
List<Map<String, Object>> hierarchy = buildHierarchy(flatList, null);
|
||||
|
||||
log.info("카테고리 값 {}개 조회 완료 (평면)", flatList.size());
|
||||
return hierarchy;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Write
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addCategoryValue(Map<String, Object> params) {
|
||||
String tableName = (String) params.get("table_name");
|
||||
String columnName = (String) params.get("column_name");
|
||||
String valueCode = (String) params.get("value_code");
|
||||
String valueLabel = (String) params.get("value_label");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 추가: tableName={}, columnName={}, valueCode={}, companyCode={}",
|
||||
tableName, columnName, valueCode, companyCode);
|
||||
|
||||
Integer codeDup = sqlSession.selectOne(NS + "countDuplicateCode", params);
|
||||
if (codeDup != null && codeDup > 0) {
|
||||
throw new IllegalArgumentException("이미 존재하는 코드입니다");
|
||||
}
|
||||
|
||||
Integer labelDup = sqlSession.selectOne(NS + "countDuplicateLabel", params);
|
||||
if (labelDup != null && labelDup > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"이미 동일한 이름의 카테고리 값이 존재합니다: \"" + valueLabel + "\"");
|
||||
}
|
||||
|
||||
if (params.get("value_order") == null) params.put("value_order", 0);
|
||||
if (params.get("depth") == null) params.put("depth", 1);
|
||||
if (params.get("is_active") == null) params.put("is_active", true);
|
||||
if (params.get("is_default") == null) params.put("is_default", false);
|
||||
|
||||
sqlSession.insert(NS + "insertCategoryValue", params);
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
|
||||
log.info("카테고리 값 추가 완료: valueId={}", valueId);
|
||||
|
||||
Map<String, Object> fetchP = new HashMap<>();
|
||||
fetchP.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateCategoryValue(Map<String, Object> params) {
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 수정: valueId={}, companyCode={}", valueId, companyCode);
|
||||
|
||||
if (params.get("value_label") != null) {
|
||||
Map<String, Object> current = sqlSession.selectOne(NS + "getCategoryValueLabelInfo",
|
||||
Map.of("value_id", valueId));
|
||||
if (current != null) {
|
||||
Map<String, Object> labelP = new HashMap<>();
|
||||
labelP.put("table_name", current.get("table_name"));
|
||||
labelP.put("column_name", current.get("column_name"));
|
||||
labelP.put("company_code", current.get("company_code"));
|
||||
labelP.put("value_label", params.get("value_label"));
|
||||
labelP.put("value_id", valueId);
|
||||
Integer dup = sqlSession.selectOne(NS + "countDuplicateLabelExcludeSelf", labelP);
|
||||
if (dup != null && dup > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"이미 동일한 이름의 카테고리 값이 존재합니다: \""
|
||||
+ params.get("value_label") + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params.put("value_id", valueId);
|
||||
Integer rows = sqlSession.selectOne(NS + "updateCategoryValue", params);
|
||||
if (rows == null || rows == 0) {
|
||||
// update returns affected rows via selectOne workaround; use update method instead
|
||||
sqlSession.update(NS + "updateCategoryValue", params);
|
||||
}
|
||||
|
||||
Map<String, Object> fetchP = new HashMap<>();
|
||||
fetchP.put("value_id", valueId);
|
||||
return sqlSession.selectOne(NS + "getCategoryValueInfo", fetchP);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Category Values — Delete
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@Transactional
|
||||
public void deleteCategoryValue(Map<String, Object> params) {
|
||||
long valueId = toLong(params.get("value_id"));
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 삭제: valueId={}, companyCode={}", valueId, companyCode);
|
||||
|
||||
List<Map<String, Object>> childRows = sqlSession.selectList(NS + "getChildValueIdList", params);
|
||||
List<Long> allIds = new ArrayList<>();
|
||||
allIds.add(valueId);
|
||||
childRows.forEach(r -> allIds.add(toLong(r.get("value_id"))));
|
||||
|
||||
log.info("삭제 대상 카테고리 값 수집 완료: 자신={}, 하위={}", valueId, childRows.size());
|
||||
|
||||
for (Long id : allIds) {
|
||||
checkNotInUse(id, companyCode);
|
||||
}
|
||||
|
||||
List<Long> reversed = new ArrayList<>(allIds);
|
||||
Collections.reverse(reversed);
|
||||
for (Long id : reversed) {
|
||||
Map<String, Object> delP = new HashMap<>();
|
||||
delP.put("value_id", id);
|
||||
delP.put("company_code", companyCode);
|
||||
sqlSession.delete(NS + "deleteValueById", delP);
|
||||
}
|
||||
|
||||
log.info("카테고리 값 삭제 완료: totalDeleted={}", allIds.size());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteCategoryValues(Map<String, Object> params) {
|
||||
log.info("카테고리 값 일괄 삭제: count={}, companyCode={}",
|
||||
((List<?>) params.get("value_ids")).size(), params.get("company_code"));
|
||||
sqlSession.update(NS + "bulkSoftDeleteValues", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reorderCategoryValues(Map<String, Object> params) {
|
||||
List<?> rawIds = (List<?>) params.get("ordered_value_ids");
|
||||
String companyCode = (String) params.get("company_code");
|
||||
|
||||
log.info("카테고리 값 순서 변경: count={}, companyCode={}", rawIds.size(), companyCode);
|
||||
|
||||
for (int i = 0; i < rawIds.size(); i++) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("value_id", toLong(rawIds.get(i)));
|
||||
p.put("value_order", i + 1);
|
||||
p.put("company_code", companyCode);
|
||||
sqlSession.update(NS + "updateValueOrder", p);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Column Mapping
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getColumnMapping(Map<String, Object> params) {
|
||||
log.info("컬럼 매핑 조회: tableName={}, menuObjid={}, companyCode={}",
|
||||
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getColumnMappingList", params);
|
||||
|
||||
Map<String, Object> mapping = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
mapping.put(String.valueOf(row.get("logical_column_name")),
|
||||
String.valueOf(row.get("physical_column_name")));
|
||||
}
|
||||
log.info("컬럼 매핑 {}개 조회 완료", mapping.size());
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> createColumnMapping(Map<String, Object> params) {
|
||||
String tableName = (String) params.get("table_name");
|
||||
String logicalColumnName = (String) params.get("logical_column_name");
|
||||
String physicalColumnName = (String) params.get("physical_column_name");
|
||||
|
||||
log.info("컬럼 매핑 생성: tableName={}, logical={}, physical={}, companyCode={}",
|
||||
tableName, logicalColumnName, physicalColumnName, params.get("company_code"));
|
||||
|
||||
Integer colExists = sqlSession.selectOne(NS + "checkPhysicalColumnExists", params);
|
||||
if (colExists == null || colExists == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"테이블 " + tableName + "에 컬럼 " + physicalColumnName + "이(가) 존재하지 않습니다");
|
||||
}
|
||||
|
||||
sqlSession.insert(NS + "upsertColumnMapping", params);
|
||||
Map<String, Object> result = sqlSession.selectOne(NS + "getColumnMappingInfo", params);
|
||||
|
||||
log.info("컬럼 매핑 생성 완료: mappingId={}", result != null ? result.get("mapping_id") : "?");
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getLogicalColumns(Map<String, Object> params) {
|
||||
log.info("논리적 컬럼 목록 조회: tableName={}, menuObjid={}, companyCode={}",
|
||||
params.get("table_name"), params.get("menu_objid"), params.get("company_code"));
|
||||
return sqlSession.selectList(NS + "getLogicalColumnList", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteColumnMapping(Map<String, Object> params) {
|
||||
int deleted = sqlSession.delete(NS + "deleteColumnMappingById", params);
|
||||
if (deleted == 0) {
|
||||
throw new IllegalArgumentException("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
log.info("컬럼 매핑 삭제 완료: mappingId={}", params.get("mapping_id"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteColumnMappingsByColumn(Map<String, Object> params) {
|
||||
int deleted = sqlSession.delete(NS + "deleteColumnMappingsByColumn", params);
|
||||
log.info("테이블+컬럼 기준 매핑 삭제 완료: tableName={}, columnName={}, deletedCount={}",
|
||||
params.get("table_name"), params.get("column_name"), deleted);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Labels by Codes
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getCategoryLabelsByCodes(Map<String, Object> params) {
|
||||
Object rawCodes = params.get("value_codes");
|
||||
if (!(rawCodes instanceof List) || ((List<?>) rawCodes).isEmpty()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
log.info("카테고리 코드로 라벨 조회: count={}, companyCode={}",
|
||||
((List<?>) rawCodes).size(), params.get("company_code"));
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "getLabelListByCodes", params);
|
||||
|
||||
Map<String, Object> labels = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
String code = String.valueOf(row.get("value_code"));
|
||||
if (!labels.containsKey(code)) {
|
||||
labels.put(code, row.get("value_label"));
|
||||
}
|
||||
}
|
||||
log.info("카테고리 라벨 {}개 조회 완료", labels.size());
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Second-Level Menus
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getSecondLevelMenus(Map<String, Object> params) {
|
||||
log.info("2레벨 메뉴 목록 조회: companyCode={}", params.get("company_code"));
|
||||
|
||||
Integer hasCC = sqlSession.selectOne(NS + "checkMenuInfoHasCompanyCode", null);
|
||||
params.put("has_company_code", hasCC != null && hasCC > 0);
|
||||
log.info("menu_info.company_code 컬럼 존재 여부: {}", hasCC != null && hasCC > 0);
|
||||
|
||||
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getSecondLevelMenuList", params);
|
||||
log.info("2레벨 메뉴 {}개 조회 완료", menus.size());
|
||||
return menus;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// private helpers
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
private void checkNotInUse(long valueId, String companyCode) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("value_id", valueId);
|
||||
p.put("company_code", companyCode);
|
||||
|
||||
Map<String, Object> valueInfo = sqlSession.selectOne(NS + "getCategoryValueUsageInfo", p);
|
||||
if (valueInfo == null) {
|
||||
throw new IllegalArgumentException("카테고리 값을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
String tableName = String.valueOf(valueInfo.get("table_name"));
|
||||
String columnName = String.valueOf(valueInfo.get("column_name"));
|
||||
String valueCode = String.valueOf(valueInfo.get("value_code"));
|
||||
String valueLabel = String.valueOf(valueInfo.get("value_label"));
|
||||
|
||||
String safeTable = sanitize(tableName);
|
||||
String safeColumn = sanitize(columnName);
|
||||
|
||||
if (safeTable.isEmpty() || safeColumn.isEmpty()) return;
|
||||
|
||||
Integer tableExists = sqlSession.selectOne(NS + "checkTableExistsForUsage",
|
||||
Map.of("table_name", safeTable));
|
||||
if (tableExists == null || tableExists == 0) return;
|
||||
|
||||
Map<String, Object> countP = new HashMap<>();
|
||||
countP.put("safe_table_name", safeTable);
|
||||
countP.put("safe_column_name", safeColumn);
|
||||
countP.put("value_code", valueCode);
|
||||
countP.put("company_code", companyCode);
|
||||
Integer count = sqlSession.selectOne(NS + "countValueUsageInTable", countP);
|
||||
|
||||
if (count != null && count > 0) {
|
||||
List<Map<String, Object>> menus = sqlSession.selectList(NS + "getMenuListUsingTable",
|
||||
Map.of("table_name", tableName, "company_code", companyCode));
|
||||
|
||||
StringBuilder msg = new StringBuilder();
|
||||
msg.append("카테고리 \"").append(valueLabel).append("\"을(를) 삭제할 수 없습니다.\n");
|
||||
msg.append("\n현재 ").append(count).append("개의 데이터에서 사용 중입니다.");
|
||||
|
||||
if (!menus.isEmpty()) {
|
||||
String menuNames = menus.stream()
|
||||
.map(m -> String.valueOf(m.get("menu_name")))
|
||||
.collect(Collectors.joining(", "));
|
||||
msg.append("\n\n다음 메뉴에서 사용 중입니다:\n").append(menuNames);
|
||||
}
|
||||
msg.append("\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.");
|
||||
|
||||
throw new IllegalArgumentException(msg.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildHierarchy(
|
||||
List<Map<String, Object>> values, Object parentId) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> v : values) {
|
||||
Object pid = v.get("parent_value_id");
|
||||
if (Objects.equals(pid, parentId)) {
|
||||
List<Map<String, Object>> children = buildHierarchy(values, v.get("value_id"));
|
||||
v.put("children", children);
|
||||
result.add(v);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String sanitize(String name) {
|
||||
if (name == null) return "";
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
}
|
||||
|
||||
private long toLong(Object val) {
|
||||
if (val == null) return 0L;
|
||||
if (val instanceof Number) return ((Number) val).longValue();
|
||||
try { return Long.parseLong(val.toString()); } catch (NumberFormatException e) { return 0L; }
|
||||
}
|
||||
}
|
||||
@@ -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,10 +28,14 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
private static final String NS = "tableManagement.";
|
||||
|
||||
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
||||
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
|
||||
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
|
||||
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
|
||||
"varchar", "text", "char", "character", "character varying",
|
||||
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
|
||||
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
|
||||
"time", "time without time zone", "time with time zone",
|
||||
"uuid", "json", "jsonb", "bytea"
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -151,22 +157,40 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> settings, String companyCode) {
|
||||
ensureTableInLabels(tableName);
|
||||
|
||||
boolean inputTypeChanged = settings.containsKey("input_type");
|
||||
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
|
||||
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
|
||||
Object rawInputType = settings.get("input_type");
|
||||
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
|
||||
InputTypeContext ctx = inputTypeChanged
|
||||
? InputTypeContext.USER_UPDATE_TYPE
|
||||
: InputTypeContext.USER_UPDATE_OTHER;
|
||||
String inputType = normalizeInputType((String) rawInputType, ctx);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("column_label", settings.get("column_label"));
|
||||
params.put("input_type", inputType);
|
||||
params.put("detail_settings", toJsonString(settings.get("detail_settings")));
|
||||
params.put("code_category", "code".equals(inputType) ? settings.get("code_category") : null);
|
||||
params.put("code_info", "code".equals(inputType) ? settings.get("code_info") : null);
|
||||
params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
|
||||
params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
|
||||
params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
|
||||
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
||||
params.put("display_order", settings.getOrDefault("display_order", 0));
|
||||
params.put("is_visible", settings.getOrDefault("is_visible", true));
|
||||
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
|
||||
Object rawIsNullable = settings.get("is_nullable");
|
||||
if (rawIsNullable != null) {
|
||||
String s = rawIsNullable.toString();
|
||||
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
|
||||
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
|
||||
params.put("is_nullable", "N");
|
||||
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
|
||||
params.put("is_nullable", "Y");
|
||||
} else {
|
||||
params.put("is_nullable", null);
|
||||
}
|
||||
} else {
|
||||
params.put("is_nullable", null);
|
||||
}
|
||||
params.put("company_code", companyCode);
|
||||
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
||||
sqlSession.update(NS + "upsertColumnSettings", params);
|
||||
@@ -191,26 +215,28 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void updateColumnWebType(String tableName, String columnName,
|
||||
String webType, Map<String, Object> detailSettings) {
|
||||
String webType, Map<String, Object> detailSettings,
|
||||
String companyCode) {
|
||||
String finalType = normalizeInputType(webType);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("input_type", finalType);
|
||||
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
||||
params.put("company_code", "*");
|
||||
// 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정
|
||||
params.put("company_code", companyCode != null ? companyCode : "*");
|
||||
params.put("clear_entity", false);
|
||||
params.put("clear_code", false);
|
||||
params.put("clear_category", false);
|
||||
sqlSession.update(NS + "upsertColumnInputType", params);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateColumnInputType(String tableName, String columnName,
|
||||
String inputType, String companyCode,
|
||||
Map<String, Object> detailSettings) {
|
||||
String finalType = normalizeInputType(inputType, "user-update-type");
|
||||
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
@@ -374,12 +400,14 @@ public class TableManagementService extends BaseService {
|
||||
String safeTable = sanitize(tableName);
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
|
||||
for (Map<String, Object> col : uniqueCols) {
|
||||
String colName = (String) col.get("column_name");
|
||||
Object val = data.get(colName);
|
||||
if (val == null) continue;
|
||||
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
String sql;
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
@@ -463,6 +491,369 @@ public class TableManagementService extends BaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final Set<String> AGG_TYPES = Set.of(
|
||||
"count", "sum", "avg", "min", "max", "distinctCount"
|
||||
);
|
||||
|
||||
private static final Set<String> FILTER_OPS = Set.of(
|
||||
"=", "!=", ">", "<", ">=", "<=",
|
||||
"like", "in", "notIn", "isNull", "isNotNull"
|
||||
);
|
||||
|
||||
/**
|
||||
* 단일 집계 값 계산.
|
||||
*
|
||||
* count — column 없이도 동작 (COUNT(*))
|
||||
* sum/avg/min/max — column 필수
|
||||
* distinctCount — column 필수 (COUNT(DISTINCT col))
|
||||
*/
|
||||
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String columnName = options.get("columnName") instanceof String s ? s : null;
|
||||
String safeColumn = columnName != null ? sanitize(columnName) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeColumn.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeColumn)) {
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
|
||||
// count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
|
||||
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeColumn.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeColumn)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
|
||||
} else {
|
||||
// sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
|
||||
selectExpr, safeTable, where);
|
||||
|
||||
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
|
||||
double value = raw != null ? raw.doubleValue() : 0d;
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("value", value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
|
||||
if (filters == null || filters.isEmpty()) return "";
|
||||
List<String> clauses = new ArrayList<>();
|
||||
for (Map<String, Object> f : filters) {
|
||||
if (f == null) continue;
|
||||
String col = f.get("column") instanceof String s ? s : null;
|
||||
String op = f.get("operator") instanceof String s ? s : "=";
|
||||
if (col == null || col.isBlank()) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
if (!FILTER_OPS.contains(op)) continue;
|
||||
|
||||
Object val = f.get("value");
|
||||
|
||||
switch (op) {
|
||||
case "isNull":
|
||||
clauses.add(String.format("\"%s\" IS NULL", safeCol));
|
||||
break;
|
||||
case "isNotNull":
|
||||
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
|
||||
break;
|
||||
case "in":
|
||||
case "notIn": {
|
||||
List<Object> list = toList(val);
|
||||
if (list.isEmpty()) continue;
|
||||
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
|
||||
String kw = "in".equals(op) ? "IN" : "NOT IN";
|
||||
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
|
||||
values.addAll(list);
|
||||
break;
|
||||
}
|
||||
case "like":
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
|
||||
values.add("%" + val + "%");
|
||||
break;
|
||||
default:
|
||||
if (isEmptyAggregateFilterValue(val)) continue;
|
||||
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
|
||||
values.add(val);
|
||||
}
|
||||
}
|
||||
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
|
||||
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (Object item : rawList) {
|
||||
if (item instanceof Map<?, ?> rawMap) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
if (entry.getKey() instanceof String key) {
|
||||
normalized.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
if (!normalized.isEmpty()) out.add(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean isEmptyAggregateFilterValue(Object val) {
|
||||
if (val == null) return true;
|
||||
if (val instanceof String s) return s.isBlank();
|
||||
if (val instanceof Collection<?> c) return c.isEmpty();
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Object> toList(Object val) {
|
||||
if (val == null) return List.of();
|
||||
if (val instanceof List<?> l) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object o : l) {
|
||||
if (o == null) continue;
|
||||
if (o instanceof String s && s.isBlank()) continue;
|
||||
out.add(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (val instanceof String s) {
|
||||
if (s.isBlank()) return List.of();
|
||||
return Arrays.stream(s.split(","))
|
||||
.map(String::trim)
|
||||
.filter(p -> !p.isEmpty())
|
||||
.map(p -> (Object) p)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return List.of(val);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 그룹별 집계 (Phase G.3 — canonical chart 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
|
||||
* horizontalBar 모두에서 같은 endpoint 를 사용.
|
||||
*
|
||||
* body 예:
|
||||
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
|
||||
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
|
||||
*/
|
||||
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
String groupBy = options.get("groupBy") instanceof String s ? s : null;
|
||||
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
|
||||
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
|
||||
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
|
||||
}
|
||||
|
||||
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
||||
if (!AGG_TYPES.contains(aggregation)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
||||
}
|
||||
|
||||
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
|
||||
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
|
||||
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
|
||||
|
||||
boolean columnRequired = !"count".equals(aggregation);
|
||||
if (columnRequired) {
|
||||
if (safeValueCol.isBlank()) {
|
||||
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
|
||||
}
|
||||
if (!hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
|
||||
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
|
||||
String orderDir = options.get("orderDir") instanceof String s
|
||||
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
|
||||
? s.toUpperCase()
|
||||
: "DESC";
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
String selectExpr;
|
||||
if ("count".equals(aggregation)) {
|
||||
selectExpr = !safeValueCol.isBlank()
|
||||
? String.format("COUNT(\"%s\")", safeValueCol)
|
||||
: "COUNT(*)";
|
||||
} else if ("distinctCount".equals(aggregation)) {
|
||||
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
|
||||
} else {
|
||||
String upper = aggregation.toUpperCase();
|
||||
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
||||
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
|
||||
} else {
|
||||
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
|
||||
}
|
||||
}
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT \"%s\" AS group_value, %s AS agg_value " +
|
||||
"FROM \"%s\" main %s " +
|
||||
"GROUP BY \"%s\" " +
|
||||
"ORDER BY agg_value %s NULLS LAST " +
|
||||
"LIMIT %d",
|
||||
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
|
||||
|
||||
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map<String, Object> r : rawRows) {
|
||||
Object groupVal = r.get("group_value");
|
||||
Object aggVal = r.get("agg_value");
|
||||
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("group", groupVal);
|
||||
out.put("value", value);
|
||||
rows.add(out);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
|
||||
* `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
|
||||
* 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
|
||||
*
|
||||
* body 예:
|
||||
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
|
||||
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
|
||||
*
|
||||
* response:
|
||||
* { "rows": [{...}, {...}] }
|
||||
*/
|
||||
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
|
||||
String safeTable = sanitize(tableName);
|
||||
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
||||
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
|
||||
? (List<Object>) raw : Collections.emptyList();
|
||||
|
||||
List<String> safeColumns = new ArrayList<>();
|
||||
for (Object c : rawColumns) {
|
||||
if (!(c instanceof String s)) continue;
|
||||
String safe = sanitize(s);
|
||||
if (safe.isBlank()) continue;
|
||||
if (!hasColumn(safeTable, safe)) continue;
|
||||
safeColumns.add(safe);
|
||||
}
|
||||
|
||||
String selectExpr;
|
||||
if (safeColumns.isEmpty()) {
|
||||
selectExpr = "main.*";
|
||||
} else {
|
||||
selectExpr = safeColumns.stream()
|
||||
.map(c -> "\"" + c + "\"")
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
||||
|
||||
List<Object> values = new ArrayList<>();
|
||||
String where = buildAggregateWhere(safeTable, filters, values);
|
||||
|
||||
// orderBy: [{ column, direction }]
|
||||
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
|
||||
|
||||
List<String> orderClauses = new ArrayList<>();
|
||||
for (Map<String, Object> ob : orderBy) {
|
||||
if (ob == null) continue;
|
||||
String col = ob.get("column") instanceof String s ? s : null;
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
||||
String dir = ob.get("direction") instanceof String s
|
||||
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
|
||||
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
|
||||
}
|
||||
String order = "";
|
||||
if (!orderClauses.isEmpty()) {
|
||||
order = "ORDER BY " + String.join(", ", orderClauses);
|
||||
} else if (hasColumn(safeTable, "created_date")) {
|
||||
order = "ORDER BY main.created_date DESC";
|
||||
}
|
||||
|
||||
int limit = toInt(options.get("limit"), 50);
|
||||
if (limit < 1) limit = 50;
|
||||
if (limit > 500) limit = 500;
|
||||
int offset = toInt(options.get("offset"), 0);
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
String sql = String.format(
|
||||
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
|
||||
selectExpr, safeTable, where, order, limit, offset);
|
||||
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("rows", rows);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
|
||||
String safeTable = sanitize(tableName);
|
||||
@@ -611,9 +1002,14 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
|
||||
String logTableName = tableName + "_log";
|
||||
String safeLog = sanitize(logTableName);
|
||||
String safeOrig = sanitize(tableName);
|
||||
if (safeOrig.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
|
||||
}
|
||||
String safeLog = sanitize(safeOrig + "_log");
|
||||
if (safeLog.isBlank()) {
|
||||
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
|
||||
}
|
||||
|
||||
// 원본 테이블 컬럼 정보 조회
|
||||
Map<String, String> colTypes = getColumnTypes(safeOrig);
|
||||
@@ -625,13 +1021,32 @@ public class TableManagementService extends BaseService {
|
||||
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
|
||||
colDefs.add("log_user VARCHAR(100)");
|
||||
|
||||
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
|
||||
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
|
||||
? logColumns
|
||||
: new ArrayList<>(colTypes.keySet());
|
||||
|
||||
for (String col : targetCols) {
|
||||
String type = colTypes.getOrDefault(col, "TEXT");
|
||||
colDefs.add(String.format("\"%s\" %s", col, type));
|
||||
// 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
|
||||
List<String> persistedCols = new ArrayList<>();
|
||||
for (String col : requestedCols) {
|
||||
if (col == null) continue;
|
||||
String safeCol = sanitize(col);
|
||||
if (safeCol.isBlank()) continue; // sanitize 결과 빈 식별자 차단
|
||||
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
|
||||
|
||||
String rawType = colTypes.get(col);
|
||||
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
|
||||
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
|
||||
// 알 수 없는 type 은 text 로 fallback (안전 default)
|
||||
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
|
||||
safeOrig, safeCol, rawType);
|
||||
normalized = "text";
|
||||
}
|
||||
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
|
||||
persistedCols.add(safeCol);
|
||||
}
|
||||
|
||||
if (persistedCols.isEmpty()) {
|
||||
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
|
||||
}
|
||||
|
||||
String createSql = String.format(
|
||||
@@ -642,7 +1057,7 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("is_active", isActive);
|
||||
params.put("log_columns", String.join(",", targetCols));
|
||||
params.put("log_columns", String.join(",", persistedCols));
|
||||
sqlSession.update(NS + "upsertLogConfig", params);
|
||||
|
||||
log.info("로그 테이블 생성: {}", safeLog);
|
||||
@@ -856,9 +1271,40 @@ public class TableManagementService extends BaseService {
|
||||
}
|
||||
|
||||
/** SQL injection 방지용 식별자 정리 */
|
||||
/**
|
||||
* SQL 식별자(테이블/컬럼명) 살균.
|
||||
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
|
||||
* - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException
|
||||
*
|
||||
* 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해
|
||||
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김.
|
||||
*/
|
||||
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
|
||||
"user", "order", "group", "table", "column", "index", "select", "insert",
|
||||
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
|
||||
"null", "true", "false", "create", "alter", "drop", "primary", "key",
|
||||
"foreign", "references", "constraint", "default", "unique", "check",
|
||||
"view", "procedure", "function"
|
||||
);
|
||||
|
||||
private String sanitize(String name) {
|
||||
if (name == null) return "";
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
if (name == null) {
|
||||
throw new IllegalArgumentException("식별자가 null 입니다.");
|
||||
}
|
||||
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
if (cleaned.isEmpty()) {
|
||||
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
|
||||
}
|
||||
if (cleaned.length() > 63) {
|
||||
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
|
||||
}
|
||||
if (Character.isDigit(cleaned.charAt(0))) {
|
||||
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
|
||||
}
|
||||
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
|
||||
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||
@@ -872,19 +1318,18 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
/**
|
||||
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
||||
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
|
||||
*/
|
||||
private String normalizeInputType(String value, String context) {
|
||||
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
|
||||
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||
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개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + value + ")"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// user-update-other / system-normalize: 기존 동작 그대로
|
||||
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
|
||||
return normalizeInputType(value);
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가
|
||||
-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다.
|
||||
-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도
|
||||
-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다.
|
||||
|
||||
ALTER TABLE BATCH_MAPPINGS
|
||||
ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =================================================================
|
||||
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
|
||||
-- =================================================================
|
||||
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
|
||||
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
|
||||
-- 멱등: IF NOT EXISTS 로 재실행 안전.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
|
||||
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
|
||||
|
||||
-- 솔루션 전용 메뉴 마킹
|
||||
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
@@ -58,6 +58,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -105,6 +108,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
@@ -124,26 +130,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -152,6 +140,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
@@ -187,6 +185,9 @@
|
||||
AND MENU.COMPANY_CODE = #{company_code}
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -212,6 +213,9 @@
|
||||
ON S.PARENT_OBJ_ID = V.OBJID
|
||||
WHERE S.OBJID != ALL(V.PATH)
|
||||
AND S.STATUS = 'active'
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
@@ -231,26 +235,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -259,6 +245,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
@@ -365,26 +361,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -393,6 +371,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
|
||||
@@ -102,6 +102,117 @@
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<!-- batch_mappings: 특정 batch_config_id 의 매핑 행들 조회 -->
|
||||
<select id="getBatchMappingsByConfigId" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
ID
|
||||
, BATCH_CONFIG_ID
|
||||
, COMPANY_CODE
|
||||
, FROM_CONNECTION_TYPE
|
||||
, FROM_CONNECTION_ID
|
||||
, FROM_TABLE_NAME
|
||||
, FROM_COLUMN_NAME
|
||||
, FROM_COLUMN_TYPE
|
||||
, FROM_API_URL
|
||||
, FROM_API_KEY
|
||||
, FROM_API_METHOD
|
||||
, FROM_API_PARAM_TYPE
|
||||
, FROM_API_PARAM_NAME
|
||||
, FROM_API_PARAM_VALUE
|
||||
, FROM_API_PARAM_SOURCE
|
||||
, FROM_API_BODY
|
||||
, TO_CONNECTION_TYPE
|
||||
, TO_CONNECTION_ID
|
||||
, TO_TABLE_NAME
|
||||
, TO_COLUMN_NAME
|
||||
, TO_COLUMN_TYPE
|
||||
, TO_API_URL
|
||||
, TO_API_KEY
|
||||
, TO_API_METHOD
|
||||
, TO_API_BODY
|
||||
, MAPPING_ORDER
|
||||
, MAPPING_TYPE
|
||||
, MAPPING_CONFIG::TEXT AS MAPPING_CONFIG
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
FROM BATCH_MAPPINGS
|
||||
WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
|
||||
ORDER BY MAPPING_ORDER, ID
|
||||
</select>
|
||||
|
||||
<!-- batch_mappings: 단건 INSERT (replace-all 패턴이라 INSERT/DELETE 만 사용) -->
|
||||
<insert id="insertBatchMapping" parameterType="map" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
|
||||
INSERT INTO BATCH_MAPPINGS (
|
||||
BATCH_CONFIG_ID
|
||||
, COMPANY_CODE
|
||||
, FROM_CONNECTION_TYPE
|
||||
, FROM_CONNECTION_ID
|
||||
, FROM_TABLE_NAME
|
||||
, FROM_COLUMN_NAME
|
||||
, FROM_COLUMN_TYPE
|
||||
, FROM_API_URL
|
||||
, FROM_API_KEY
|
||||
, FROM_API_METHOD
|
||||
, FROM_API_PARAM_TYPE
|
||||
, FROM_API_PARAM_NAME
|
||||
, FROM_API_PARAM_VALUE
|
||||
, FROM_API_PARAM_SOURCE
|
||||
, FROM_API_BODY
|
||||
, TO_CONNECTION_TYPE
|
||||
, TO_CONNECTION_ID
|
||||
, TO_TABLE_NAME
|
||||
, TO_COLUMN_NAME
|
||||
, TO_COLUMN_TYPE
|
||||
, TO_API_URL
|
||||
, TO_API_KEY
|
||||
, TO_API_METHOD
|
||||
, TO_API_BODY
|
||||
, MAPPING_ORDER
|
||||
, MAPPING_TYPE
|
||||
, MAPPING_CONFIG
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{batch_config_id}::varchar
|
||||
, #{company_code}
|
||||
, #{from_connection_type}
|
||||
, #{from_connection_id}
|
||||
, #{from_table_name}
|
||||
, #{from_column_name}
|
||||
, #{from_column_type}
|
||||
, #{from_api_url}
|
||||
, #{from_api_key}
|
||||
, #{from_api_method}
|
||||
, #{from_api_param_type}
|
||||
, #{from_api_param_name}
|
||||
, #{from_api_param_value}
|
||||
, #{from_api_param_source}
|
||||
, #{from_api_body}
|
||||
, #{to_connection_type}
|
||||
, #{to_connection_id}
|
||||
, #{to_table_name}
|
||||
, #{to_column_name}
|
||||
, #{to_column_type}
|
||||
, #{to_api_url}
|
||||
, #{to_api_key}
|
||||
, #{to_api_method}
|
||||
, #{to_api_body}
|
||||
, #{mapping_order}
|
||||
, <choose>
|
||||
<when test="mapping_type != null and mapping_type != ''">#{mapping_type}</when>
|
||||
<otherwise>'direct'</otherwise>
|
||||
</choose>
|
||||
, #{mapping_config,jdbcType=OTHER}::jsonb
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- batch_mappings: 특정 batch_config_id 의 매핑 전부 삭제 (replace-all 의 앞단계) -->
|
||||
<delete id="deleteBatchMappingsByConfigId" parameterType="map">
|
||||
DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
|
||||
</delete>
|
||||
|
||||
<!-- 내부 DB 테이블 목록 조회 -->
|
||||
<select id="getInternalTables" resultType="map">
|
||||
SELECT
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<sql id="batchExecutionLogSearchCondition">
|
||||
<if test="batch_config_id != null">
|
||||
AND bel.batch_config_id = #{batch_config_id}
|
||||
AND bel.batch_config_id = #{batch_config_id}::varchar
|
||||
</if>
|
||||
<if test="execution_status != null and execution_status != ''">
|
||||
AND bel.execution_status = #{execution_status}
|
||||
@@ -84,7 +84,7 @@
|
||||
<select id="getBatchExecutionLogLatest" parameterType="map" resultType="map">
|
||||
SELECT * FROM batch_execution_logs
|
||||
|
||||
WHERE batch_config_id = #{batch_config_id}
|
||||
WHERE batch_config_id = #{batch_config_id}::varchar
|
||||
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 1
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
WHERE 1=1
|
||||
<if test="batch_config_id != null">
|
||||
AND batch_config_id = #{batch_config_id}
|
||||
AND batch_config_id = #{batch_config_id}::varchar
|
||||
</if>
|
||||
<if test="start_date != null and start_date != ''">
|
||||
AND start_time >= #{start_date}::timestamp
|
||||
@@ -123,7 +123,7 @@
|
||||
total_records, success_records, failed_records,
|
||||
error_message, error_details, server_name, process_id
|
||||
) VALUES (
|
||||
#{batch_config_id}, #{company_code}, #{execution_status},
|
||||
#{batch_config_id}::varchar, #{company_code}, #{execution_status},
|
||||
COALESCE(#{start_time}::timestamp, NOW()),
|
||||
#{end_time}::timestamp,
|
||||
#{duration_ms},
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
execution_today AS (
|
||||
SELECT COUNT(*) AS today_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
WHERE DATE(start_time) = CURRENT_DATE
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
),
|
||||
execution_yesterday AS (
|
||||
SELECT COUNT(*) AS yesterday_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
)
|
||||
@@ -77,9 +77,9 @@
|
||||
SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count
|
||||
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
|
||||
WHERE batch_config_id = #{batch_config_id}
|
||||
WHERE batch_config_id = #{batch_config_id}::varchar
|
||||
AND start_time >= NOW() - INTERVAL '24 hours'
|
||||
|
||||
GROUP BY DATE_TRUNC('hour', start_time)
|
||||
@@ -87,6 +87,32 @@
|
||||
ORDER BY hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
|
||||
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
|
||||
WITH hours AS (
|
||||
SELECT generate_series(
|
||||
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
|
||||
DATE_TRUNC('hour', NOW()),
|
||||
INTERVAL '1 hour'
|
||||
) AS hour_slot
|
||||
),
|
||||
filtered_logs AS (
|
||||
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
|
||||
execution_status
|
||||
FROM batch_execution_logs
|
||||
WHERE start_time >= NOW() - INTERVAL '24 hours'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
)
|
||||
SELECT h.hour_slot,
|
||||
COUNT(l.execution_status) AS total_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
|
||||
FROM hours h
|
||||
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
|
||||
GROUP BY h.hour_slot
|
||||
ORDER BY h.hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 최근 실행 로그 목록 (최대 20건) -->
|
||||
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
||||
SELECT id,
|
||||
@@ -100,9 +126,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
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingAutoFill">
|
||||
|
||||
<sql id="cascadingAutoFillGroupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND G.IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingAutoFillGroupList" parameterType="map" resultType="map">
|
||||
SELECT G.*, COUNT(M.MAPPING_ID) AS MAPPING_COUNT
|
||||
FROM CASCADING_AUTO_FILL_GROUP G
|
||||
LEFT JOIN CASCADING_AUTO_FILL_MAPPING M
|
||||
ON G.GROUP_CODE = M.GROUP_CODE AND G.COMPANY_CODE = M.COMPANY_CODE
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
GROUP BY G.GROUP_ID
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_AUTO_FILL_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingAutoFillGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillMappingList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
ORDER BY SORT_ORDER, MAPPING_ID
|
||||
</select>
|
||||
|
||||
<select id="getCascadingAutoFillGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingAutoFillGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION,
|
||||
MASTER_TABLE, MASTER_VALUE_COLUMN, MASTER_LABEL_COLUMN,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{master_table},
|
||||
#{master_value_column},
|
||||
#{master_label_column, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
#{is_active, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingAutoFillMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CASCADING_AUTO_FILL_MAPPING (
|
||||
GROUP_CODE, COMPANY_CODE, SOURCE_COLUMN, TARGET_FIELD, TARGET_LABEL,
|
||||
IS_EDITABLE, IS_REQUIRED, DEFAULT_VALUE, SORT_ORDER
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{source_column},
|
||||
#{target_field},
|
||||
#{target_label, jdbcType=VARCHAR},
|
||||
#{is_editable, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{default_value, jdbcType=VARCHAR},
|
||||
#{sort_order}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingAutoFillGroup" parameterType="map">
|
||||
UPDATE CASCADING_AUTO_FILL_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MASTER_TABLE = COALESCE(#{master_table, jdbcType=VARCHAR}, MASTER_TABLE),
|
||||
MASTER_VALUE_COLUMN = COALESCE(#{master_value_column, jdbcType=VARCHAR}, MASTER_VALUE_COLUMN),
|
||||
MASTER_LABEL_COLUMN = COALESCE(#{master_label_column, jdbcType=VARCHAR}, MASTER_LABEL_COLUMN),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingAutoFillMappings" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_MAPPING
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingAutoFillGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_AUTO_FILL_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingCondition">
|
||||
|
||||
<sql id="cascadingConditionSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND CONDITION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="relation_code != null and relation_code != ''">
|
||||
AND RELATION_CODE = #{relation_code}
|
||||
</if>
|
||||
<if test="relation_type != null and relation_type != ''">
|
||||
AND RELATION_TYPE = #{relation_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingConditionList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_CODE, PRIORITY, CONDITION_NAME
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingConditionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingConditionInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingCondition" parameterType="map" useGeneratedKeys="true" keyProperty="conditionId">
|
||||
INSERT INTO CASCADING_CONDITION (
|
||||
RELATION_TYPE, RELATION_CODE, CONDITION_NAME,
|
||||
CONDITION_FIELD, CONDITION_OPERATOR, CONDITION_VALUE,
|
||||
FILTER_COLUMN, FILTER_VALUES, PRIORITY,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
COALESCE(#{relation_type}, 'RELATION'), #{relation_code}, #{condition_name},
|
||||
#{condition_field}, COALESCE(#{condition_operator}, 'EQ'), #{condition_value},
|
||||
#{filter_column}, #{filter_values}, COALESCE(#{priority}, 0),
|
||||
#{company_code}, COALESCE(#{is_active}, 'Y'), CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingCondition" parameterType="map">
|
||||
UPDATE CASCADING_CONDITION SET
|
||||
CONDITION_NAME = COALESCE(#{condition_name}, CONDITION_NAME),
|
||||
CONDITION_FIELD = COALESCE(#{condition_field}, CONDITION_FIELD),
|
||||
CONDITION_OPERATOR = COALESCE(#{condition_operator}, CONDITION_OPERATOR),
|
||||
CONDITION_VALUE = COALESCE(#{condition_value}, CONDITION_VALUE),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column}, FILTER_COLUMN),
|
||||
FILTER_VALUES = COALESCE(#{filter_values}, FILTER_VALUES),
|
||||
PRIORITY = COALESCE(#{priority}, PRIORITY),
|
||||
IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingCondition" parameterType="map">
|
||||
DELETE FROM CASCADING_CONDITION
|
||||
WHERE CONDITION_ID = #{condition_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCascadingConditionsByRelationCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_CONDITION
|
||||
WHERE RELATION_CODE = #{relation_code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
ORDER BY PRIORITY DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,219 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingHierarchy">
|
||||
|
||||
<sql id="cascadingHierarchyGroupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (G.GROUP_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND G.IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="hierarchy_type != null and hierarchy_type != ''">
|
||||
AND G.HIERARCHY_TYPE = #{hierarchy_type}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingHierarchyGroupList" parameterType="map" resultType="map">
|
||||
SELECT G.*,
|
||||
(SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
WHERE L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE) AS LEVEL_COUNT
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
ORDER BY G.GROUP_NAME
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(DISTINCT G.GROUP_ID)
|
||||
FROM CASCADING_HIERARCHY_GROUP G
|
||||
WHERE 1=1
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (G.COMPANY_CODE = #{company_code} OR G.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<include refid="cascadingHierarchyGroupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyGroupCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelList" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
ORDER BY LEVEL_ORDER
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingHierarchyLevelForOptions" parameterType="map" resultType="map">
|
||||
SELECT L.*, G.HIERARCHY_TYPE
|
||||
FROM CASCADING_HIERARCHY_LEVEL L
|
||||
JOIN CASCADING_HIERARCHY_GROUP G
|
||||
ON L.GROUP_CODE = G.GROUP_CODE AND L.COMPANY_CODE = G.COMPANY_CODE
|
||||
WHERE L.GROUP_CODE = #{group_code}
|
||||
AND L.LEVEL_ORDER = #{level_order}
|
||||
AND L.IS_ACTIVE = 'Y'
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingHierarchyGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CASCADING_HIERARCHY_GROUP (
|
||||
GROUP_CODE, GROUP_NAME, DESCRIPTION, HIERARCHY_TYPE,
|
||||
MAX_LEVELS, IS_FIXED_LEVELS,
|
||||
SELF_REF_TABLE, SELF_REF_ID_COLUMN, SELF_REF_PARENT_COLUMN,
|
||||
SELF_REF_VALUE_COLUMN, SELF_REF_LABEL_COLUMN, SELF_REF_LEVEL_COLUMN, SELF_REF_ORDER_COLUMN,
|
||||
BOM_TABLE, BOM_PARENT_COLUMN, BOM_CHILD_COLUMN,
|
||||
BOM_ITEM_TABLE, BOM_ITEM_ID_COLUMN, BOM_ITEM_LABEL_COLUMN, BOM_QTY_COLUMN, BOM_LEVEL_COLUMN,
|
||||
EMPTY_MESSAGE, NO_OPTIONS_MESSAGE, LOADING_MESSAGE,
|
||||
COMPANY_CODE, IS_ACTIVE, CREATED_BY, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{group_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{hierarchy_type},
|
||||
#{max_levels, jdbcType=INTEGER},
|
||||
#{is_fixed_levels, jdbcType=VARCHAR},
|
||||
#{self_ref_table, jdbcType=VARCHAR},
|
||||
#{self_ref_id_column, jdbcType=VARCHAR},
|
||||
#{self_ref_parent_column, jdbcType=VARCHAR},
|
||||
#{self_ref_value_column, jdbcType=VARCHAR},
|
||||
#{self_ref_label_column, jdbcType=VARCHAR},
|
||||
#{self_ref_level_column, jdbcType=VARCHAR},
|
||||
#{self_ref_order_column, jdbcType=VARCHAR},
|
||||
#{bom_table, jdbcType=VARCHAR},
|
||||
#{bom_parent_column, jdbcType=VARCHAR},
|
||||
#{bom_child_column, jdbcType=VARCHAR},
|
||||
#{bom_item_table, jdbcType=VARCHAR},
|
||||
#{bom_item_id_column, jdbcType=VARCHAR},
|
||||
#{bom_item_label_column, jdbcType=VARCHAR},
|
||||
#{bom_qty_column, jdbcType=VARCHAR},
|
||||
#{bom_level_column, jdbcType=VARCHAR},
|
||||
#{empty_message, jdbcType=VARCHAR},
|
||||
#{no_options_message, jdbcType=VARCHAR},
|
||||
#{loading_message, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
'Y',
|
||||
#{created_by, jdbcType=VARCHAR},
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertCascadingHierarchyLevel" parameterType="map" useGeneratedKeys="true" keyProperty="levelId">
|
||||
INSERT INTO CASCADING_HIERARCHY_LEVEL (
|
||||
GROUP_CODE, COMPANY_CODE, LEVEL_ORDER, LEVEL_NAME, LEVEL_CODE,
|
||||
TABLE_NAME, VALUE_COLUMN, LABEL_COLUMN, PARENT_KEY_COLUMN,
|
||||
FILTER_COLUMN, FILTER_VALUE, ORDER_COLUMN, ORDER_DIRECTION,
|
||||
PLACEHOLDER, IS_REQUIRED, IS_SEARCHABLE, IS_ACTIVE, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_code},
|
||||
#{company_code},
|
||||
#{level_order},
|
||||
#{level_name},
|
||||
#{level_code, jdbcType=VARCHAR},
|
||||
#{table_name},
|
||||
#{value_column},
|
||||
#{label_column},
|
||||
#{parent_key_column, jdbcType=VARCHAR},
|
||||
#{filter_column, jdbcType=VARCHAR},
|
||||
#{filter_value, jdbcType=VARCHAR},
|
||||
#{order_column, jdbcType=VARCHAR},
|
||||
#{order_direction, jdbcType=VARCHAR},
|
||||
#{placeholder, jdbcType=VARCHAR},
|
||||
#{is_required, jdbcType=VARCHAR},
|
||||
#{is_searchable, jdbcType=VARCHAR},
|
||||
'Y',
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingHierarchyGroup" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_GROUP SET
|
||||
GROUP_NAME = COALESCE(#{group_name, jdbcType=VARCHAR}, GROUP_NAME),
|
||||
DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION),
|
||||
MAX_LEVELS = COALESCE(#{max_levels, jdbcType=INTEGER}, MAX_LEVELS),
|
||||
IS_FIXED_LEVELS = COALESCE(#{is_fixed_levels, jdbcType=VARCHAR}, IS_FIXED_LEVELS),
|
||||
EMPTY_MESSAGE = COALESCE(#{empty_message, jdbcType=VARCHAR}, EMPTY_MESSAGE),
|
||||
NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE),
|
||||
LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_BY = #{updated_by, jdbcType=VARCHAR},
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</update>
|
||||
|
||||
<update id="updateCascadingHierarchyLevel" parameterType="map">
|
||||
UPDATE CASCADING_HIERARCHY_LEVEL SET
|
||||
LEVEL_NAME = COALESCE(#{level_name, jdbcType=VARCHAR}, LEVEL_NAME),
|
||||
TABLE_NAME = COALESCE(#{table_name, jdbcType=VARCHAR}, TABLE_NAME),
|
||||
VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN),
|
||||
LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN),
|
||||
PARENT_KEY_COLUMN = COALESCE(#{parent_key_column, jdbcType=VARCHAR}, PARENT_KEY_COLUMN),
|
||||
FILTER_COLUMN = COALESCE(#{filter_column, jdbcType=VARCHAR}, FILTER_COLUMN),
|
||||
FILTER_VALUE = COALESCE(#{filter_value, jdbcType=VARCHAR}, FILTER_VALUE),
|
||||
ORDER_COLUMN = COALESCE(#{order_column, jdbcType=VARCHAR}, ORDER_COLUMN),
|
||||
ORDER_DIRECTION = COALESCE(#{order_direction, jdbcType=VARCHAR}, ORDER_DIRECTION),
|
||||
PLACEHOLDER = COALESCE(#{placeholder, jdbcType=VARCHAR}, PLACEHOLDER),
|
||||
IS_REQUIRED = COALESCE(#{is_required, jdbcType=VARCHAR}, IS_REQUIRED),
|
||||
IS_SEARCHABLE = COALESCE(#{is_searchable, jdbcType=VARCHAR}, IS_SEARCHABLE),
|
||||
IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE),
|
||||
UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
</update>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevels" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyLevel" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_LEVEL
|
||||
WHERE LEVEL_ID = #{level_id}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteCascadingHierarchyGroup" parameterType="map">
|
||||
DELETE FROM CASCADING_HIERARCHY_GROUP
|
||||
WHERE GROUP_CODE = #{group_code}
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,145 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingMutualExclusion">
|
||||
|
||||
<sql id="cascadingMutualExclusionSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND EXCLUSION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingMutualExclusionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingMutualExclusionSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingMutualExclusionInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y') -->
|
||||
<select id="getCascadingMutualExclusionByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
EXCLUSION_ID
|
||||
, EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 코드 자동 생성용 카운트 -->
|
||||
<select id="getCascadingMutualExclusionCount" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingMutualExclusion" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="exclusionId" keyColumn="exclusion_id">
|
||||
INSERT INTO CASCADING_MUTUAL_EXCLUSION (
|
||||
EXCLUSION_CODE
|
||||
, EXCLUSION_NAME
|
||||
, FIELD_NAMES
|
||||
, SOURCE_TABLE
|
||||
, VALUE_COLUMN
|
||||
, LABEL_COLUMN
|
||||
, EXCLUSION_TYPE
|
||||
, ERROR_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{exclusion_code, jdbcType=VARCHAR}
|
||||
, #{exclusion_name, jdbcType=VARCHAR}
|
||||
, #{field_names, jdbcType=VARCHAR}
|
||||
, #{source_table, jdbcType=VARCHAR}
|
||||
, #{value_column, jdbcType=VARCHAR}
|
||||
, #{label_column, jdbcType=VARCHAR}
|
||||
, #{exclusion_type, jdbcType=VARCHAR}
|
||||
, #{error_message, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingMutualExclusion" parameterType="map">
|
||||
UPDATE CASCADING_MUTUAL_EXCLUSION
|
||||
SET
|
||||
EXCLUSION_NAME = COALESCE(#{exclusion_name, jdbcType=VARCHAR}, EXCLUSION_NAME)
|
||||
, FIELD_NAMES = COALESCE(#{field_names, jdbcType=VARCHAR}, FIELD_NAMES)
|
||||
, SOURCE_TABLE = COALESCE(#{source_table, jdbcType=VARCHAR}, SOURCE_TABLE)
|
||||
, VALUE_COLUMN = COALESCE(#{value_column, jdbcType=VARCHAR}, VALUE_COLUMN)
|
||||
, LABEL_COLUMN = COALESCE(#{label_column, jdbcType=VARCHAR}, LABEL_COLUMN)
|
||||
, EXCLUSION_TYPE = COALESCE(#{exclusion_type, jdbcType=VARCHAR}, EXCLUSION_TYPE)
|
||||
, ERROR_MESSAGE = COALESCE(#{error_message, jdbcType=VARCHAR}, ERROR_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 하드 삭제 (Node.js와 동일) -->
|
||||
<delete id="deleteCascadingMutualExclusion" parameterType="map">
|
||||
DELETE FROM CASCADING_MUTUAL_EXCLUSION
|
||||
WHERE EXCLUSION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -1,160 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="cascadingRelation">
|
||||
|
||||
<sql id="cascadingRelationColumns">
|
||||
RELATION_ID
|
||||
, RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_BY
|
||||
, UPDATED_DATE
|
||||
</sql>
|
||||
|
||||
<sql id="cascadingRelationSearchCondition">
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%'
|
||||
OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCascadingRelationList" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM CASCADING_RELATION
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="cascadingRelationSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCascadingRelationInfo" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- 코드로 단건 조회 (is_active = 'Y' 조건 포함) -->
|
||||
<select id="getCascadingRelationByCode" parameterType="map" resultType="map">
|
||||
SELECT <include refid="cascadingRelationColumns"/>
|
||||
FROM CASCADING_RELATION
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCascadingRelation" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="relationId" keyColumn="relation_id">
|
||||
INSERT INTO CASCADING_RELATION (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE
|
||||
, PARENT_VALUE_COLUMN
|
||||
, PARENT_LABEL_COLUMN
|
||||
, CHILD_TABLE
|
||||
, CHILD_FILTER_COLUMN
|
||||
, CHILD_VALUE_COLUMN
|
||||
, CHILD_LABEL_COLUMN
|
||||
, CHILD_ORDER_COLUMN
|
||||
, CHILD_ORDER_DIRECTION
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, LOADING_MESSAGE
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code, jdbcType=VARCHAR}
|
||||
, #{relation_name, jdbcType=VARCHAR}
|
||||
, #{description, jdbcType=VARCHAR}
|
||||
, #{parent_table, jdbcType=VARCHAR}
|
||||
, #{parent_value_column, jdbcType=VARCHAR}
|
||||
, #{parent_label_column, jdbcType=VARCHAR}
|
||||
, #{child_table, jdbcType=VARCHAR}
|
||||
, #{child_filter_column, jdbcType=VARCHAR}
|
||||
, #{child_value_column, jdbcType=VARCHAR}
|
||||
, #{child_label_column, jdbcType=VARCHAR}
|
||||
, #{child_order_column, jdbcType=VARCHAR}
|
||||
, #{child_order_direction, jdbcType=VARCHAR}
|
||||
, #{empty_parent_message, jdbcType=VARCHAR}
|
||||
, #{no_options_message, jdbcType=VARCHAR}
|
||||
, #{loading_message, jdbcType=VARCHAR}
|
||||
, #{clear_on_parent_change, jdbcType=VARCHAR}
|
||||
, #{company_code}
|
||||
, #{is_active, jdbcType=VARCHAR}
|
||||
, #{user_id, jdbcType=VARCHAR}
|
||||
, CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name, jdbcType=VARCHAR}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description, jdbcType=VARCHAR}, DESCRIPTION)
|
||||
, PARENT_TABLE = COALESCE(#{parent_table, jdbcType=VARCHAR}, PARENT_TABLE)
|
||||
, PARENT_VALUE_COLUMN = COALESCE(#{parent_value_column, jdbcType=VARCHAR}, PARENT_VALUE_COLUMN)
|
||||
, PARENT_LABEL_COLUMN = COALESCE(#{parent_label_column, jdbcType=VARCHAR}, PARENT_LABEL_COLUMN)
|
||||
, CHILD_TABLE = COALESCE(#{child_table, jdbcType=VARCHAR}, CHILD_TABLE)
|
||||
, CHILD_FILTER_COLUMN = COALESCE(#{child_filter_column, jdbcType=VARCHAR}, CHILD_FILTER_COLUMN)
|
||||
, CHILD_VALUE_COLUMN = COALESCE(#{child_value_column, jdbcType=VARCHAR}, CHILD_VALUE_COLUMN)
|
||||
, CHILD_LABEL_COLUMN = COALESCE(#{child_label_column, jdbcType=VARCHAR}, CHILD_LABEL_COLUMN)
|
||||
, CHILD_ORDER_COLUMN = COALESCE(#{child_order_column, jdbcType=VARCHAR}, CHILD_ORDER_COLUMN)
|
||||
, CHILD_ORDER_DIRECTION = COALESCE(#{child_order_direction, jdbcType=VARCHAR}, CHILD_ORDER_DIRECTION)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message, jdbcType=VARCHAR}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message, jdbcType=VARCHAR}, NO_OPTIONS_MESSAGE)
|
||||
, LOADING_MESSAGE = COALESCE(#{loading_message, jdbcType=VARCHAR}, LOADING_MESSAGE)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change, jdbcType=VARCHAR}, CLEAR_ON_PARENT_CHANGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active, jdbcType=VARCHAR}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 소프트 삭제: is_active = 'N' -->
|
||||
<update id="deleteCascadingRelation" parameterType="map">
|
||||
UPDATE CASCADING_RELATION
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{user_id, jdbcType=VARCHAR}
|
||||
, UPDATED_DATE = CURRENT_TIMESTAMP
|
||||
WHERE RELATION_ID = #{id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -1,182 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryTree">
|
||||
|
||||
<!-- 공통 컬럼 -->
|
||||
<sql id="categoryValueColumns">
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code,
|
||||
CREATED_DATE, UPDATED_DATE, created_by, updated_by
|
||||
</sql>
|
||||
|
||||
<!-- 카테고리 플랫 리스트 조회 (트리/플랫 모두 사용) -->
|
||||
<select id="getCategoryTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 단건 조회 -->
|
||||
<select id="getCategoryTreeInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
<include refid="categoryValueColumns"/>
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 생성 -->
|
||||
<insert id="insertCategoryTree" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
|
||||
#{parent_value_id}, #{depth}, #{path}, #{description}, #{color}, #{icon},
|
||||
#{is_active}, #{is_default}, #{company_code}, #{created_by}, #{created_by}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 카테고리 값 수정 (COALESCE로 부분 업데이트) -->
|
||||
<update id="updateCategoryTree" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET
|
||||
value_code = COALESCE(#{value_code}, value_code),
|
||||
value_label = COALESCE(#{value_label}, value_label),
|
||||
value_order = COALESCE(#{value_order}, value_order),
|
||||
parent_value_id = #{parent_value_id},
|
||||
depth = #{depth},
|
||||
path = #{path},
|
||||
description = COALESCE(#{description}, description),
|
||||
color = COALESCE(#{color}, color),
|
||||
icon = COALESCE(#{icon}, icon),
|
||||
is_active = COALESCE(#{is_active}, is_active),
|
||||
is_default = COALESCE(#{is_default}, is_default),
|
||||
UPDATED_DATE = NOW(),
|
||||
updated_by = #{updated_by}
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 카테고리 값 삭제 -->
|
||||
<delete id="deleteCategoryTree" parameterType="map">
|
||||
DELETE FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND value_id = #{value_id}
|
||||
</delete>
|
||||
|
||||
<!-- 자식 카테고리 수 조회 -->
|
||||
<select id="getCategoryTreeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM category_values
|
||||
|
||||
WHERE parent_value_id = #{value_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
</select>
|
||||
|
||||
<!-- 테이블 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkTableExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
</select>
|
||||
|
||||
<!-- 컬럼 존재 여부 확인 (0 또는 1 반환) -->
|
||||
<select id="checkColumnExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 포함) -->
|
||||
<select id="countCategoryUsageWithCompany" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND (#{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code})
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 카테고리 값 사용 건수 조회 (company_code 필터 없음) -->
|
||||
<select id="countCategoryUsage" parameterType="map" resultType="int">
|
||||
<![CDATA[
|
||||
SELECT COUNT(*) FROM "${tableName}"
|
||||
|
||||
WHERE #{value_code} = ANY(string_to_array("${columnName}"::text, ','))
|
||||
OR "${columnName}"::text = #{value_code}
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<!-- 직계 자식 목록 조회 (path 업데이트용) -->
|
||||
<select id="getCategoryTreeChildrenList" parameterType="map" resultType="map">
|
||||
SELECT value_id, value_label
|
||||
|
||||
FROM category_values
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
AND parent_value_id = #{parent_value_id}
|
||||
</select>
|
||||
|
||||
<!-- 자식 path 단건 업데이트 -->
|
||||
<update id="updateCategoryTreeChildPath" parameterType="map">
|
||||
UPDATE category_values
|
||||
SET path = #{path}, UPDATED_DATE = NOW()
|
||||
|
||||
WHERE value_id = #{value_id}
|
||||
</update>
|
||||
|
||||
<!-- 테이블의 카테고리 컬럼 목록 조회 -->
|
||||
<select id="getCategoryTreeColumnList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT column_name, column_label
|
||||
|
||||
FROM table_type_columns
|
||||
|
||||
WHERE table_name = #{table_name}
|
||||
AND input_type = 'category'
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
ORDER BY column_name
|
||||
</select>
|
||||
|
||||
<!-- 전체 카테고리 키 목록 조회 (table_name + column_name 조합) -->
|
||||
<select id="getCategoryTreeKeyList" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
cv.table_name,
|
||||
cv.column_name,
|
||||
COALESCE(tl.table_label, cv.table_name) AS table_label,
|
||||
COALESCE(ttc.column_label, cv.column_name) AS column_label
|
||||
|
||||
FROM category_values cv
|
||||
|
||||
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
|
||||
|
||||
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name
|
||||
AND ttc.column_name = cv.column_name
|
||||
AND ttc.company_code = '*'
|
||||
|
||||
WHERE (cv.company_code = #{company_code} OR cv.company_code = '*')
|
||||
OR cv.company_code = '*'
|
||||
|
||||
ORDER BY cv.table_name, cv.column_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,179 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="categoryValueCascading">
|
||||
|
||||
<sql id="groupSearchCondition">
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (RELATION_NAME ILIKE '%' || #{keyword} || '%' OR RELATION_CODE ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
<choose>
|
||||
<when test="sort_column != null and sort_column != ''">
|
||||
ORDER BY ${sortColumn}
|
||||
<if test="sort_direction != null and sort_direction != ''">
|
||||
${sortDirection}
|
||||
</if>
|
||||
</when>
|
||||
<otherwise>
|
||||
ORDER BY RELATION_NAME ASC
|
||||
</otherwise>
|
||||
</choose>
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupListCnt" parameterType="map" resultType="int">
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<include refid="groupSearchCondition"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueCascadingGroupByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CATEGORY_VALUE_CASCADING_GROUP
|
||||
WHERE RELATION_CODE = #{code}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCategoryValueCascadingGroup" parameterType="map" useGeneratedKeys="true" keyProperty="groupId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_GROUP (
|
||||
RELATION_CODE
|
||||
, RELATION_NAME
|
||||
, DESCRIPTION
|
||||
, PARENT_TABLE_NAME
|
||||
, PARENT_COLUMN_NAME
|
||||
, PARENT_MENU_OBJID
|
||||
, CHILD_TABLE_NAME
|
||||
, CHILD_COLUMN_NAME
|
||||
, CHILD_MENU_OBJID
|
||||
, CLEAR_ON_PARENT_CHANGE
|
||||
, SHOW_GROUP_LABEL
|
||||
, EMPTY_PARENT_MESSAGE
|
||||
, NO_OPTIONS_MESSAGE
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{relation_code}
|
||||
, #{relation_name}
|
||||
, #{description}
|
||||
, #{parent_table_name}
|
||||
, #{parent_column_name}
|
||||
, #{parent_menu_objid}
|
||||
, #{child_table_name}
|
||||
, #{child_column_name}
|
||||
, #{child_menu_objid}
|
||||
, COALESCE(#{clear_on_parent_change}, 'Y')
|
||||
, COALESCE(#{show_group_label}, 'Y')
|
||||
, COALESCE(#{empty_parent_message}, '상위 항목을 먼저 선택하세요')
|
||||
, COALESCE(#{no_options_message}, '선택 가능한 항목이 없습니다')
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
RELATION_NAME = COALESCE(#{relation_name}, RELATION_NAME)
|
||||
, DESCRIPTION = COALESCE(#{description}, DESCRIPTION)
|
||||
, PARENT_TABLE_NAME = COALESCE(#{parent_table_name}, PARENT_TABLE_NAME)
|
||||
, PARENT_COLUMN_NAME = COALESCE(#{parent_column_name}, PARENT_COLUMN_NAME)
|
||||
, PARENT_MENU_OBJID = COALESCE(#{parent_menu_objid}, PARENT_MENU_OBJID)
|
||||
, CHILD_TABLE_NAME = COALESCE(#{child_table_name}, CHILD_TABLE_NAME)
|
||||
, CHILD_COLUMN_NAME = COALESCE(#{child_column_name}, CHILD_COLUMN_NAME)
|
||||
, CHILD_MENU_OBJID = COALESCE(#{child_menu_objid}, CHILD_MENU_OBJID)
|
||||
, CLEAR_ON_PARENT_CHANGE = COALESCE(#{clear_on_parent_change}, CLEAR_ON_PARENT_CHANGE)
|
||||
, SHOW_GROUP_LABEL = COALESCE(#{show_group_label}, SHOW_GROUP_LABEL)
|
||||
, EMPTY_PARENT_MESSAGE = COALESCE(#{empty_parent_message}, EMPTY_PARENT_MESSAGE)
|
||||
, NO_OPTIONS_MESSAGE = COALESCE(#{no_options_message}, NO_OPTIONS_MESSAGE)
|
||||
, IS_ACTIVE = COALESCE(#{is_active}, IS_ACTIVE)
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<update id="deleteCategoryValueCascadingGroup" parameterType="map">
|
||||
UPDATE CATEGORY_VALUE_CASCADING_GROUP
|
||||
SET
|
||||
IS_ACTIVE = 'N'
|
||||
, UPDATED_BY = #{updated_by}
|
||||
, UPDATED_DATE = NOW()
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCategoryValueCascadingMappingsByGroupId" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
MAPPING_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, IS_ACTIVE
|
||||
FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
ORDER BY PARENT_VALUE_CODE, DISPLAY_ORDER, CHILD_VALUE_LABEL
|
||||
</select>
|
||||
|
||||
<delete id="deleteCategoryValueCascadingMappingsByGroupId" parameterType="map">
|
||||
DELETE FROM CATEGORY_VALUE_CASCADING_MAPPING
|
||||
WHERE GROUP_ID = #{group_id}
|
||||
</delete>
|
||||
|
||||
<insert id="insertCategoryValueCascadingMapping" parameterType="map" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO CATEGORY_VALUE_CASCADING_MAPPING (
|
||||
GROUP_ID
|
||||
, PARENT_VALUE_CODE
|
||||
, PARENT_VALUE_LABEL
|
||||
, CHILD_VALUE_CODE
|
||||
, CHILD_VALUE_LABEL
|
||||
, DISPLAY_ORDER
|
||||
, COMPANY_CODE
|
||||
, IS_ACTIVE
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{group_id}
|
||||
, #{parent_value_code}
|
||||
, #{parent_value_label}
|
||||
, #{child_value_code}
|
||||
, #{child_value_label}
|
||||
, COALESCE(#{display_order}, 0)
|
||||
, #{company_code}
|
||||
, 'Y'
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
</mapper>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="codeMerge">
|
||||
|
||||
<!--
|
||||
columnName 컬럼과 company_code 컬럼을 함께 가진 public BASE TABLE 목록 조회
|
||||
테이블명은 information_schema 검증값이므로 동적 SQL 사용 시 안전
|
||||
-->
|
||||
<select id="getTablesWithColumn" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT t.table_name
|
||||
|
||||
FROM information_schema.columns c
|
||||
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name
|
||||
|
||||
WHERE c.column_name = #{column_name}
|
||||
AND t.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns c2
|
||||
WHERE c2.table_name = t.table_name
|
||||
AND c2.column_name = 'company_code'
|
||||
)
|
||||
|
||||
ORDER BY t.table_name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -4,455 +4,436 @@
|
||||
<mapper namespace="commonCode">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_category -->
|
||||
<!-- CODE_INFO — 1레벨 그룹 마스터 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeCategoryList" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, category_code ASC
|
||||
ORDER BY SORT_ORDER ASC, CODE_INFO ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(category_code) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(category_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(category_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_INFO) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="menu_objid != null">
|
||||
AND menu_objid = #{menu_objid}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeInfoInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCodeCategory" parameterType="map">
|
||||
INSERT INTO code_category (
|
||||
category_code,
|
||||
category_name,
|
||||
category_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<insert id="insertCodeInfo" parameterType="map">
|
||||
INSERT INTO CODE_INFO (
|
||||
CODE_INFO
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, MENU_OBJID
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{category_name},
|
||||
#{category_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{menu_objid}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCodeCategory" parameterType="map">
|
||||
UPDATE code_category
|
||||
<update id="updateCodeInfo" parameterType="map">
|
||||
UPDATE CODE_INFO
|
||||
<set>
|
||||
<if test="category_name != null">category_name = #{category_name},</if>
|
||||
<if test="category_name_eng != null">category_name_eng = #{category_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="menu_objid != null">MENU_OBJID = #{menu_objid},</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCodeCategory" parameterType="map">
|
||||
DELETE FROM code_category
|
||||
<delete id="deleteCodeInfo" parameterType="map">
|
||||
DELETE FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE category_code = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeCategoryDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeInfoDuplicateByField" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_category
|
||||
FROM CODE_INFO
|
||||
|
||||
WHERE 1=1
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'categoryCode'">AND category_code = #{value}</when>
|
||||
<when test="field == 'categoryName'">AND category_name = #{value}</when>
|
||||
<when test="field == 'categoryNameEng'">AND category_name_eng = #{value}</when>
|
||||
<otherwise>AND category_code = #{value}</otherwise>
|
||||
<when test='field == "code_info"'>AND CODE_INFO = #{value}</when>
|
||||
<when test='field == "code_name"'>AND CODE_NAME = #{value}</when>
|
||||
<when test='field == "code_name_eng"'>AND CODE_NAME_ENG = #{value}</when>
|
||||
<otherwise>AND CODE_INFO = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND category_code != #{exclude_code}
|
||||
AND CODE_INFO != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- code_info -->
|
||||
<!-- CODE_DETAIL — 2레벨 ~ 무한대 트리 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeList" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
ORDER BY DEPTH ASC, SORT_ORDER ASC, CODE_VALUE ASC
|
||||
<include refid="common.pagination"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeListCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailListCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="parent_detail_id != null">
|
||||
AND PARENT_DETAIL_ID = #{parent_detail_id}
|
||||
</when>
|
||||
<when test="only_roots != null and only_roots == true">
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
</when>
|
||||
</choose>
|
||||
<if test="search != null and search != ''">
|
||||
AND (
|
||||
LOWER(code_value) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(code_name) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(code_name_eng, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
LOWER(CODE_VALUE) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(CODE_NAME) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
OR LOWER(COALESCE(CODE_NAME_ENG, '')) LIKE LOWER(CONCAT('%', #{search}, '%'))
|
||||
)
|
||||
</if>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeInfo" parameterType="map" resultType="map">
|
||||
<select id="getCodeDetailInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<insert id="insertCommonCode" parameterType="map">
|
||||
INSERT INTO code_info (
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
<!--
|
||||
그룹 전체 트리 — 재귀 CTE 로 평탄화.
|
||||
depth 오름차순 → sort_order 오름차순 → code_value 오름차순.
|
||||
-->
|
||||
<select id="getCodeDetailTree" parameterType="map" resultType="map">
|
||||
WITH RECURSIVE TREE AS (
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, ARRAY[SORT_ORDER, CODE_DETAIL_ID] AS PATH
|
||||
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND PARENT_DETAIL_ID IS NULL
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
C.CODE_DETAIL_ID
|
||||
, C.CODE_INFO
|
||||
, C.PARENT_DETAIL_ID
|
||||
, C.CODE_VALUE
|
||||
, C.CODE_NAME
|
||||
, C.CODE_NAME_ENG
|
||||
, C.DESCRIPTION
|
||||
, C.DEPTH
|
||||
, C.SORT_ORDER
|
||||
, C.IS_ACTIVE
|
||||
, C.COMPANY_CODE
|
||||
, C.CREATED_BY
|
||||
, C.UPDATED_BY
|
||||
, C.CREATED_DATE
|
||||
, C.UPDATED_DATE
|
||||
, TREE.PATH || ARRAY[C.SORT_ORDER, C.CODE_DETAIL_ID]
|
||||
|
||||
FROM CODE_DETAIL C
|
||||
INNER JOIN TREE ON C.PARENT_DETAIL_ID = TREE.CODE_DETAIL_ID
|
||||
WHERE C.CODE_INFO = #{code_info}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (C.COMPANY_CODE = #{company_code} OR C.COMPANY_CODE = '*')
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
CODE_DETAIL_ID
|
||||
, CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
|
||||
FROM TREE
|
||||
|
||||
ORDER BY PATH
|
||||
</select>
|
||||
|
||||
<insert id="insertCodeDetail" parameterType="map" useGeneratedKeys="true" keyProperty="code_detail_id" keyColumn="code_detail_id">
|
||||
INSERT INTO CODE_DETAIL (
|
||||
CODE_INFO
|
||||
, PARENT_DETAIL_ID
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, CODE_NAME_ENG
|
||||
, DESCRIPTION
|
||||
, DEPTH
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
, COMPANY_CODE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
) VALUES (
|
||||
#{category_code},
|
||||
#{code_value},
|
||||
#{code_name},
|
||||
#{code_name_eng},
|
||||
#{description},
|
||||
#{sort_order},
|
||||
#{is_active},
|
||||
#{menu_objid},
|
||||
#{company_code},
|
||||
#{parent_code_value},
|
||||
#{depth},
|
||||
#{created_by},
|
||||
#{updated_by},
|
||||
NOW(),
|
||||
NOW()
|
||||
#{code_info}
|
||||
, #{parent_detail_id}
|
||||
, #{code_value}
|
||||
, #{code_name}
|
||||
, #{code_name_eng}
|
||||
, #{description}
|
||||
, #{depth}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
, #{company_code}
|
||||
, #{created_by}
|
||||
, #{updated_by}
|
||||
, NOW()
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCommonCode" parameterType="map">
|
||||
UPDATE code_info
|
||||
<update id="updateCodeDetail" parameterType="map">
|
||||
UPDATE CODE_DETAIL
|
||||
<set>
|
||||
<if test="code_name != null">code_name = #{code_name},</if>
|
||||
<if test="code_name_eng != null">code_name_eng = #{code_name_eng},</if>
|
||||
<if test="description != null">description = #{description},</if>
|
||||
<if test="sort_order != null">sort_order = #{sort_order},</if>
|
||||
<if test="is_active != null">is_active = #{is_active},</if>
|
||||
<if test="parent_code_value != null">parent_code_value = #{parent_code_value},</if>
|
||||
<if test="depth != null">depth = #{depth},</if>
|
||||
updated_by = #{updated_by},
|
||||
updated_date = NOW()
|
||||
<if test="code_value != null">CODE_VALUE = #{code_value},</if>
|
||||
<if test="code_name != null">CODE_NAME = #{code_name},</if>
|
||||
<if test="code_name_eng != null">CODE_NAME_ENG = #{code_name_eng},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="sort_order != null">SORT_ORDER = #{sort_order},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="reparent != null and reparent == true">
|
||||
PARENT_DETAIL_ID = #{parent_detail_id},
|
||||
DEPTH = #{depth},
|
||||
</if>
|
||||
UPDATED_BY = #{updated_by},
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<delete id="deleteCommonCode" parameterType="map">
|
||||
DELETE FROM code_info
|
||||
<delete id="deleteCodeDetail" parameterType="map">
|
||||
DELETE FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<update id="updateCommonCodeSortOrder" parameterType="map">
|
||||
UPDATE code_info
|
||||
SET sort_order = #{sort_order},
|
||||
updated_date = NOW()
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<select id="getCommonCodeDuplicateCnt" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailDuplicateCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE CODE_INFO = #{code_info}
|
||||
AND CODE_VALUE = #{code_value}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="exclude_id != null">
|
||||
AND CODE_DETAIL_ID != #{exclude_id}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeDuplicateByField" parameterType="map" resultType="int">
|
||||
<select id="getCodeDetailChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<choose>
|
||||
<when test="field == 'codeValue'">AND code_value = #{value}</when>
|
||||
<when test="field == 'codeName'">AND code_name = #{value}</when>
|
||||
<when test="field == 'codeNameEng'">AND code_name_eng = #{value}</when>
|
||||
<otherwise>AND code_value = #{value}</otherwise>
|
||||
</choose>
|
||||
<if test="exclude_code != null and exclude_code != ''">
|
||||
AND code_value != #{exclude_code}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(depth, 0)
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND code_value = #{code_value}
|
||||
WHERE PARENT_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeChildrenCnt" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
<select id="getCodeDetailParentDepth" parameterType="map" resultType="int">
|
||||
SELECT COALESCE(DEPTH, 1)
|
||||
|
||||
FROM code_info
|
||||
FROM CODE_DETAIL
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
AND parent_code_value = #{code_value}
|
||||
WHERE CODE_DETAIL_ID = #{code_detail_id}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- 계층 / 트리 / 옵션 -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCommonCodeHierarchicalList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
<if test="parent_code_value != null">
|
||||
AND parent_code_value = #{parent_code_value}
|
||||
</if>
|
||||
<if test="depth != null">
|
||||
AND depth = #{depth}
|
||||
</if>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeTreeList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_category,
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng,
|
||||
description,
|
||||
sort_order,
|
||||
is_active,
|
||||
menu_objid,
|
||||
company_code,
|
||||
parent_code_value,
|
||||
depth,
|
||||
created_by,
|
||||
updated_by,
|
||||
created_date,
|
||||
updated_date
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
|
||||
ORDER BY depth ASC, sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
<select id="getCommonCodeOptionList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
code_value,
|
||||
code_name,
|
||||
code_name_eng
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{category_code}
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
<if test="is_active != null">
|
||||
AND is_active = #{is_active}
|
||||
</if>
|
||||
|
||||
ORDER BY sort_order ASC, code_value ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -23,13 +23,23 @@
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
D.DELETED_AT,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO D
|
||||
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
|
||||
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND D.DELETED_AT IS NULL
|
||||
</if>
|
||||
<if test="base_date != null and base_date != ''">
|
||||
AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date)
|
||||
AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date)
|
||||
</if>
|
||||
GROUP BY
|
||||
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
|
||||
@@ -57,7 +67,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
@@ -82,7 +98,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
@@ -302,4 +324,27 @@
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</update>
|
||||
|
||||
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
|
||||
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
|
||||
DELETE FROM DEPT_MANAGERS
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND ROLE = #{role}
|
||||
</delete>
|
||||
|
||||
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
|
||||
<insert id="insertDeptManagers" parameterType="map">
|
||||
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
|
||||
<foreach collection="user_ids" item="uid" index="idx" separator=",">
|
||||
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
|
||||
<select id="selectValidUserIds" parameterType="map" resultType="string">
|
||||
SELECT USER_ID FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND USER_ID IN
|
||||
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{code_category}
|
||||
WHERE code_info = #{code_info}
|
||||
AND is_active = 'Y'
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
정적 쿼리: table_type_columns의 code_category 조회
|
||||
정적 쿼리: table_type_columns의 code_info 조회
|
||||
================================================================ -->
|
||||
|
||||
<select id="getCodeCategoryInfo" parameterType="map" resultType="map">
|
||||
SELECT code_category
|
||||
SELECT code_info
|
||||
|
||||
FROM table_type_columns
|
||||
|
||||
WHERE table_name = #{table_name}
|
||||
AND column_name = #{column_name}
|
||||
AND code_category IS NOT NULL
|
||||
AND code_info IS NOT NULL
|
||||
|
||||
LIMIT 1
|
||||
</select>
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
FROM code_info
|
||||
|
||||
WHERE code_category = #{code_category}
|
||||
WHERE code_info = #{code_info}
|
||||
AND code_value IN
|
||||
<foreach collection="rawValues" item="v" open="(" separator="," close=")">
|
||||
#{v}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
, E.CREATED_DATE
|
||||
, E.UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS E
|
||||
WHERE E.ID = #{id}
|
||||
WHERE E.ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
|
||||
@@ -109,14 +109,14 @@
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 비밀번호만 조회 -->
|
||||
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
|
||||
SELECT PASSWORD
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 이름+회사 중복 확인 -->
|
||||
@@ -134,7 +134,7 @@
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE CONNECTION_NAME = #{connection_name}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND ID != #{exclude_id}
|
||||
AND ID != #{exclude_id}::varchar
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
@@ -166,13 +166,13 @@
|
||||
, #{description}
|
||||
, #{db_type}
|
||||
, #{host}
|
||||
, #{port}
|
||||
, #{port}::varchar
|
||||
, #{database_name}
|
||||
, #{username}
|
||||
, #{password}
|
||||
, #{connection_timeout}
|
||||
, #{query_timeout}
|
||||
, #{max_connections}
|
||||
, #{connection_timeout}::varchar
|
||||
, #{query_timeout}::varchar
|
||||
, #{max_connections}::varchar
|
||||
, #{ssl_enabled}
|
||||
, #{ssl_cert_path}
|
||||
, #{connection_options}::JSONB
|
||||
@@ -193,13 +193,13 @@
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="db_type != null">DB_TYPE = #{db_type},</if>
|
||||
<if test="host != null">HOST = #{host},</if>
|
||||
<if test="port != null">PORT = #{port},</if>
|
||||
<if test="port != null">PORT = #{port}::varchar,</if>
|
||||
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
|
||||
<if test="username != null">USERNAME = #{username},</if>
|
||||
<if test="password != null">PASSWORD = #{password},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
|
||||
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
|
||||
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
|
||||
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
|
||||
@@ -208,13 +208,13 @@
|
||||
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</update>
|
||||
|
||||
<!-- 삭제 -->
|
||||
<delete id="deleteExternalDbConnection" parameterType="map">
|
||||
DELETE FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
SELECT
|
||||
<include refid="selectColumns"/>
|
||||
FROM EXTERNAL_REST_API_CONNECTIONS E
|
||||
WHERE E.ID = #{id}
|
||||
WHERE E.ID = #{id}::varchar
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</select>
|
||||
|
||||
@@ -133,14 +133,14 @@
|
||||
<if test="save_to_history != null">SAVE_TO_HISTORY = #{save_to_history},</if>
|
||||
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
|
||||
</set>
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</update>
|
||||
|
||||
<!-- 연결 삭제 -->
|
||||
<delete id="deleteExternalRestApiConnection" parameterType="map">
|
||||
DELETE FROM EXTERNAL_REST_API_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
LAST_TEST_DATE = NOW()
|
||||
, LAST_TEST_RESULT = #{last_test_result}
|
||||
, LAST_TEST_MESSAGE = #{last_test_message}
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</update>
|
||||
|
||||
<!-- DB 토큰 조회 (db-token auth type) -->
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, COMPANY_CODE
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
category_column AS category_column,
|
||||
category_value_id AS category_value_id,
|
||||
created_by AS created_by,
|
||||
CREATED_DATE AS CREATED_DATE,
|
||||
UPDATED_DATE AS UPDATED_DATE
|
||||
created_at AS created_at,
|
||||
updated_at AS updated_at
|
||||
</sql>
|
||||
|
||||
<sql id="partColumns">
|
||||
@@ -42,7 +42,7 @@
|
||||
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
|
||||
</choose>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getRuleById" parameterType="map" resultType="map">
|
||||
@@ -61,19 +61,19 @@
|
||||
INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
category_column, category_value_id, created_by, CREATED_DATE, UPDATED_DATE
|
||||
category_column, category_value_id, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
#{rule_id},
|
||||
#{rule_name},
|
||||
#{description, jdbcType=VARCHAR},
|
||||
#{separator, jdbcType=VARCHAR},
|
||||
#{reset_period, jdbcType=VARCHAR},
|
||||
#{current_sequence, jdbcType=INTEGER},
|
||||
#{current_sequence, jdbcType=VARCHAR},
|
||||
#{table_name, jdbcType=VARCHAR},
|
||||
#{column_name, jdbcType=VARCHAR},
|
||||
#{company_code},
|
||||
#{category_column, jdbcType=VARCHAR},
|
||||
#{category_value_id, jdbcType=INTEGER},
|
||||
#{category_value_id, jdbcType=VARCHAR},
|
||||
#{created_by, jdbcType=VARCHAR},
|
||||
NOW(), NOW()
|
||||
)
|
||||
@@ -89,8 +89,8 @@
|
||||
table_name = COALESCE(#{table_name, jdbcType=VARCHAR}, table_name),
|
||||
column_name = COALESCE(#{column_name, jdbcType=VARCHAR}, column_name),
|
||||
category_column = COALESCE(#{category_column, jdbcType=VARCHAR}, category_column),
|
||||
category_value_id = COALESCE(#{category_value_id, jdbcType=INTEGER}, category_value_id),
|
||||
UPDATED_DATE = NOW()
|
||||
category_value_id = COALESCE(#{category_value_id, jdbcType=VARCHAR}, category_value_id),
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
@@ -122,7 +122,7 @@
|
||||
<insert id="insertRulePart" parameterType="map">
|
||||
INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, CREATED_DATE
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES (
|
||||
#{rule_id},
|
||||
#{order},
|
||||
@@ -164,7 +164,17 @@
|
||||
<update id="updateCurrentSequenceInRule" parameterType="map">
|
||||
UPDATE numbering_rules
|
||||
SET current_sequence = GREATEST(COALESCE(current_sequence, '0'), #{current_sequence}),
|
||||
UPDATED_DATE = NOW()
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
</update>
|
||||
|
||||
<!-- admin 전용: GREATEST 없이 직접 SET. 임의 값 (0 포함) 으로 내릴 수 있음 -->
|
||||
<update id="setCurrentSequenceInRule" parameterType="map">
|
||||
UPDATE numbering_rules
|
||||
SET current_sequence = #{current_sequence},
|
||||
updated_at = NOW()
|
||||
|
||||
WHERE rule_id = #{rule_id}
|
||||
AND (company_code = #{company_code} OR company_code = '*')
|
||||
@@ -183,7 +193,7 @@
|
||||
<otherwise>AND (company_code = #{company_code} OR company_code = '*')</otherwise>
|
||||
</choose>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getAvailableRulesForScreen" parameterType="map" resultType="map">
|
||||
@@ -200,7 +210,7 @@
|
||||
AND table_name = #{table_name}
|
||||
</if>
|
||||
|
||||
ORDER BY CREATED_DATE DESC
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<select id="getRuleByColumn" parameterType="map" resultType="map">
|
||||
@@ -218,8 +228,8 @@
|
||||
r.category_value_id AS category_value_id,
|
||||
cv.value_label AS category_value_label,
|
||||
r.created_by AS created_by,
|
||||
r.CREATED_DATE AS CREATED_DATE,
|
||||
r.UPDATED_DATE AS UPDATED_DATE
|
||||
r.created_at AS created_at,
|
||||
r.updated_at AS updated_at
|
||||
|
||||
FROM numbering_rules r
|
||||
|
||||
@@ -247,8 +257,8 @@
|
||||
r.category_value_id AS category_value_id,
|
||||
cv.value_label AS category_value_label,
|
||||
r.created_by AS created_by,
|
||||
r.CREATED_DATE AS CREATED_DATE,
|
||||
r.UPDATED_DATE AS UPDATED_DATE
|
||||
r.created_at AS created_at,
|
||||
r.updated_at AS updated_at
|
||||
|
||||
FROM numbering_rules r
|
||||
|
||||
@@ -259,7 +269,7 @@
|
||||
AND (r.column_name IS NULL OR r.column_name = '')
|
||||
AND r.category_value_id IS NULL
|
||||
|
||||
ORDER BY r.UPDATED_DATE DESC
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
@@ -280,7 +290,7 @@
|
||||
|
||||
WHERE (company_code = #{company_code} OR company_code = '*')
|
||||
|
||||
ORDER BY CREATED_DATE
|
||||
ORDER BY created_at
|
||||
</select>
|
||||
|
||||
<select id="getRulePartsForCopy" parameterType="map" resultType="map">
|
||||
|
||||
@@ -704,11 +704,19 @@
|
||||
</foreach>
|
||||
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
|
||||
AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL
|
||||
<!-- table-like 화면 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
|
||||
중 체크박스가 활성화된 것이 있으면 제외.
|
||||
체크박스 config 경로가 두 가지로 구분된다:
|
||||
- legacy table-list / v2-table-list : componentConfig.checkbox.enabled (boolean)
|
||||
- canonical table : componentConfig.showCheckbox (boolean) -->
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
|
||||
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
|
||||
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list'
|
||||
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
|
||||
AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
|
||||
AND (
|
||||
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
|
||||
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
|
||||
|
||||
@@ -1091,12 +1091,12 @@
|
||||
<select id="selectCodeCategoryForCopy" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
*
|
||||
FROM CODE_CATEGORY
|
||||
FROM CODE_INFO
|
||||
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<insert id="upsertCodeCategory" parameterType="map">
|
||||
INSERT INTO CODE_CATEGORY (
|
||||
INSERT INTO CODE_INFO (
|
||||
CATEGORY_CODE
|
||||
, CATEGORY_NAME
|
||||
, COMPANY_CODE
|
||||
@@ -1117,26 +1117,26 @@
|
||||
*
|
||||
FROM CODE_INFO
|
||||
WHERE (COMPANY_CODE = #{source_company_code} OR COMPANY_CODE = '*')
|
||||
AND CODE_CATEGORY = #{code_category}
|
||||
AND CODE_INFO = #{code_info}
|
||||
</select>
|
||||
|
||||
<insert id="upsertCodeInfo" parameterType="map">
|
||||
INSERT INTO CODE_INFO (
|
||||
CODE_CATEGORY
|
||||
CODE_INFO
|
||||
, CODE_VALUE
|
||||
, CODE_NAME
|
||||
, COMPANY_CODE
|
||||
, SORT_ORDER
|
||||
, IS_ACTIVE
|
||||
) VALUES (
|
||||
#{code_category}
|
||||
#{code_info}
|
||||
, #{code_value}
|
||||
, #{code_name}
|
||||
, #{target_company_code}
|
||||
, #{sort_order}
|
||||
, #{is_active}
|
||||
)
|
||||
ON CONFLICT (CODE_CATEGORY, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
|
||||
ON CONFLICT (CODE_INFO, CODE_VALUE, COMPANY_CODE) DO UPDATE SET
|
||||
CODE_NAME = EXCLUDED.CODE_NAME
|
||||
, SORT_ORDER = EXCLUDED.SORT_ORDER
|
||||
, IS_ACTIVE = EXCLUDED.IS_ACTIVE
|
||||
@@ -1359,7 +1359,7 @@
|
||||
COLUMN_NAME
|
||||
, INPUT_TYPE
|
||||
, COLUMN_LABEL
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tableCategoryValue">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Columns
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCategoryColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.TABLE_NAME
|
||||
, TC.COLUMN_NAME
|
||||
, TC.COLUMN_NAME AS column_label
|
||||
, COUNT(CV.VALUE_ID) AS value_count
|
||||
|
||||
FROM TABLE_TYPE_COLUMNS TC
|
||||
|
||||
LEFT JOIN CATEGORY_VALUES CV
|
||||
ON TC.TABLE_NAME = CV.TABLE_NAME
|
||||
AND TC.COLUMN_NAME = CV.COLUMN_NAME
|
||||
AND CV.IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
WHERE TC.TABLE_NAME = #{table_name}
|
||||
AND TC.INPUT_TYPE = 'category'
|
||||
|
||||
GROUP BY TC.TABLE_NAME, TC.COLUMN_NAME, TC.DISPLAY_ORDER
|
||||
|
||||
ORDER BY TC.DISPLAY_ORDER, TC.COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<select id="getAllCategoryColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.TABLE_NAME
|
||||
, TC.COLUMN_NAME
|
||||
, TC.COLUMN_NAME AS column_label
|
||||
, COALESCE(CV_COUNT.cnt, 0) AS value_count
|
||||
|
||||
FROM (
|
||||
SELECT DISTINCT TABLE_NAME, COLUMN_NAME, MIN(DISPLAY_ORDER) AS display_order
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
WHERE INPUT_TYPE = 'category'
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME
|
||||
) TC
|
||||
|
||||
LEFT JOIN (
|
||||
SELECT TABLE_NAME, COLUMN_NAME, COUNT(*) AS cnt
|
||||
FROM CATEGORY_VALUES
|
||||
WHERE IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME
|
||||
) CV_COUNT
|
||||
ON TC.TABLE_NAME = CV_COUNT.TABLE_NAME
|
||||
AND TC.COLUMN_NAME = CV_COUNT.COLUMN_NAME
|
||||
|
||||
ORDER BY TC.TABLE_NAME, TC.DISPLAY_ORDER, TC.COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Read
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getCategoryValueList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
VALUE_ID
|
||||
, TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
, VALUE_ORDER
|
||||
, PARENT_VALUE_ID
|
||||
, DEPTH
|
||||
, DESCRIPTION
|
||||
, COLOR
|
||||
, ICON
|
||||
, IS_ACTIVE
|
||||
, IS_DEFAULT
|
||||
, COMPANY_CODE
|
||||
, MENU_OBJID
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
<choose>
|
||||
<when test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</when>
|
||||
<otherwise>
|
||||
AND COMPANY_CODE = '*'
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="include_inactive == null or include_inactive == false">
|
||||
AND IS_ACTIVE = TRUE
|
||||
</if>
|
||||
|
||||
ORDER BY VALUE_ORDER, VALUE_LABEL
|
||||
</select>
|
||||
|
||||
<select id="getCategoryValueInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
VALUE_ID
|
||||
, TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
, VALUE_ORDER
|
||||
, PARENT_VALUE_ID
|
||||
, DEPTH
|
||||
, DESCRIPTION
|
||||
, COLOR
|
||||
, ICON
|
||||
, IS_ACTIVE
|
||||
, IS_DEFAULT
|
||||
, COMPANY_CODE
|
||||
, MENU_OBJID
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
, CREATED_BY
|
||||
, UPDATED_BY
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- 사용 여부 확인용: table_name, column_name, value_code, value_label 반환 -->
|
||||
<select id="getCategoryValueUsageInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 수정 시 라벨 중복 체크용: table_name, column_name, company_code 반환 -->
|
||||
<select id="getCategoryValueLabelInfo" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TABLE_NAME
|
||||
, COLUMN_NAME
|
||||
, COMPANY_CODE
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Write
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="countDuplicateCode" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_CODE = #{value_code}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="countDuplicateLabel" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_LABEL = #{value_label}
|
||||
AND IS_ACTIVE = TRUE
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 수정 시 자기 자신 제외 라벨 중복 체크 (항상 company_code 필터) -->
|
||||
<select id="countDuplicateLabelExcludeSelf" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM CATEGORY_VALUES
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND COLUMN_NAME = #{column_name}
|
||||
AND VALUE_LABEL = #{value_label}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND IS_ACTIVE = TRUE
|
||||
AND VALUE_ID != #{value_id}
|
||||
</select>
|
||||
|
||||
<insert id="insertCategoryValue" parameterType="map"
|
||||
useGeneratedKeys="true" keyProperty="valueId" keyColumn="value_id">
|
||||
INSERT INTO CATEGORY_VALUES (
|
||||
TABLE_NAME, COLUMN_NAME, VALUE_CODE, VALUE_LABEL, VALUE_ORDER,
|
||||
PARENT_VALUE_ID, DEPTH, DESCRIPTION, COLOR, ICON,
|
||||
IS_ACTIVE, IS_DEFAULT, COMPANY_CODE, MENU_OBJID, CREATED_BY
|
||||
) VALUES (
|
||||
#{table_name}, #{column_name}, #{value_code}, #{value_label}, #{value_order},
|
||||
#{parent_value_id}, #{depth}, #{description}, #{color}, #{icon},
|
||||
#{is_active}, #{is_default}, #{company_code}, #{menu_objid}, #{user_id}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateCategoryValue" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
<set>
|
||||
<if test="value_label != null">VALUE_LABEL = #{value_label},</if>
|
||||
<if test="value_order != null">VALUE_ORDER = #{value_order},</if>
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="color != null">COLOR = #{color},</if>
|
||||
<if test="icon != null">ICON = #{icon},</if>
|
||||
<if test="is_active != null">IS_ACTIVE = #{is_active},</if>
|
||||
<if test="is_default != null">IS_DEFAULT = #{is_default},</if>
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = #{user_id}
|
||||
</set>
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Category Values — Delete
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="checkTableExistsForUsage" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
</select>
|
||||
|
||||
<!--
|
||||
동적 테이블 쿼리: safeTableName, safeColumnName 은 서비스에서
|
||||
[a-zA-Z0-9_] 로 sanitize 후 전달. ${} 는 리터럴 치환.
|
||||
-->
|
||||
<select id="countValueUsageInTable" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
FROM ${safeTableName}
|
||||
|
||||
WHERE ${safeColumnName} = #{value_code}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getMenuListUsingTable" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
MI.OBJID AS menu_objid
|
||||
, MI.MENU_NAME_KOR AS menu_name
|
||||
, MI.MENU_URL
|
||||
|
||||
FROM MENU_INFO MI
|
||||
|
||||
INNER JOIN SCREEN_MENU_ASSIGNMENTS SMA ON SMA.MENU_OBJID = MI.OBJID
|
||||
INNER JOIN SCREEN_DEFINITIONS SD ON SD.SCREEN_ID = SMA.SCREEN_ID
|
||||
|
||||
WHERE SD.TABLE_NAME = #{table_name}
|
||||
AND (MI.COMPANY_CODE = #{company_code} OR MI.COMPANY_CODE = '*')
|
||||
|
||||
ORDER BY MI.MENU_NAME_KOR
|
||||
</select>
|
||||
|
||||
<!-- 재귀 CTE 로 모든 하위 value_id 수집 -->
|
||||
<select id="getChildValueIdList" parameterType="map" resultType="map">
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT VALUE_ID
|
||||
FROM CATEGORY_VALUES
|
||||
WHERE PARENT_VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
UNION ALL
|
||||
SELECT CV.VALUE_ID
|
||||
FROM CATEGORY_VALUES CV
|
||||
INNER JOIN category_tree CT ON CV.PARENT_VALUE_ID = CT.VALUE_ID
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
WHERE (CV.COMPANY_CODE = #{company_code} OR CV.COMPANY_CODE = '*')
|
||||
</if>
|
||||
)
|
||||
SELECT VALUE_ID FROM category_tree
|
||||
</select>
|
||||
|
||||
<delete id="deleteValueById" parameterType="map">
|
||||
DELETE FROM CATEGORY_VALUES
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Bulk / Reorder
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<update id="bulkSoftDeleteValues" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
SET IS_ACTIVE = FALSE,
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = #{user_id}
|
||||
WHERE VALUE_ID IN
|
||||
<foreach collection="valueIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<update id="updateValueOrder" parameterType="map">
|
||||
UPDATE CATEGORY_VALUES
|
||||
SET VALUE_ORDER = #{value_order},
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE VALUE_ID = #{value_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Column Mapping
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getColumnMappingList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
LOGICAL_COLUMN_NAME
|
||||
, PHYSICAL_COLUMN_NAME
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<select id="getLogicalColumnList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
MAPPING_ID
|
||||
, LOGICAL_COLUMN_NAME
|
||||
, PHYSICAL_COLUMN_NAME
|
||||
, DESCRIPTION
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
ORDER BY LOGICAL_COLUMN_NAME
|
||||
</select>
|
||||
|
||||
<select id="checkPhysicalColumnExists" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = #{table_name}
|
||||
AND column_name = #{physical_column_name}
|
||||
</select>
|
||||
|
||||
<!-- UPSERT: ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) -->
|
||||
<insert id="upsertColumnMapping" parameterType="map">
|
||||
INSERT INTO CATEGORY_COLUMN_MAPPING (
|
||||
TABLE_NAME, LOGICAL_COLUMN_NAME, PHYSICAL_COLUMN_NAME,
|
||||
MENU_OBJID, COMPANY_CODE, DESCRIPTION, CREATED_BY, UPDATED_BY
|
||||
) VALUES (
|
||||
#{table_name}, #{logical_column_name}, #{physical_column_name},
|
||||
#{menu_objid}, #{company_code}, #{description}, #{user_id}, #{user_id}
|
||||
)
|
||||
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||
DO UPDATE SET
|
||||
PHYSICAL_COLUMN_NAME = EXCLUDED.PHYSICAL_COLUMN_NAME,
|
||||
DESCRIPTION = EXCLUDED.DESCRIPTION,
|
||||
UPDATED_DATE = NOW(),
|
||||
UPDATED_BY = EXCLUDED.UPDATED_BY
|
||||
</insert>
|
||||
|
||||
<select id="getColumnMappingInfo" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
|
||||
FROM CATEGORY_COLUMN_MAPPING
|
||||
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND LOGICAL_COLUMN_NAME = #{logical_column_name}
|
||||
AND MENU_OBJID = #{menu_objid}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</select>
|
||||
|
||||
<delete id="deleteColumnMappingById" parameterType="map">
|
||||
DELETE FROM CATEGORY_COLUMN_MAPPING
|
||||
WHERE MAPPING_ID = #{mapping_id}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<delete id="deleteColumnMappingsByColumn" parameterType="map">
|
||||
DELETE FROM CATEGORY_COLUMN_MAPPING
|
||||
WHERE TABLE_NAME = #{table_name}
|
||||
AND LOGICAL_COLUMN_NAME = #{column_name}
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Labels by Codes
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="getLabelListByCodes" parameterType="map" resultType="map">
|
||||
SELECT DISTINCT
|
||||
VALUE_CODE
|
||||
, VALUE_LABEL
|
||||
|
||||
FROM CATEGORY_VALUES
|
||||
|
||||
WHERE VALUE_CODE IN
|
||||
<foreach collection="valueCodes" item="code" open="(" separator="," close=")">
|
||||
#{code}
|
||||
</foreach>
|
||||
<if test='company_code != null and company_code != "*"'>
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
Second-Level Menus
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<select id="checkMenuInfoHasCompanyCode" resultType="int">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'company_code'
|
||||
</select>
|
||||
|
||||
<select id="getSecondLevelMenuList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
M1.OBJID AS menu_objid
|
||||
, M1.MENU_NAME_KOR AS menu_name
|
||||
, M0.MENU_NAME_KOR AS parent_menu_name
|
||||
, M1.SCREEN_CODE AS screen_code
|
||||
|
||||
FROM MENU_INFO M1
|
||||
|
||||
INNER JOIN MENU_INFO M0 ON M1.PARENT_OBJ_ID = M0.OBJID
|
||||
|
||||
WHERE M1.MENU_TYPE = '1'
|
||||
AND M1.STATUS = 'active'
|
||||
AND M0.PARENT_OBJ_ID = '0'
|
||||
<if test='has_company_code and company_code != null and company_code != "*"'>
|
||||
AND (M1.COMPANY_CODE = #{company_code} OR M1.COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
ORDER BY M0.SEQ, M1.SEQ
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -57,7 +57,7 @@
|
||||
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
|
||||
, C.NUMERIC_PRECISION
|
||||
, C.NUMERIC_SCALE
|
||||
, CL.CODE_CATEGORY
|
||||
, CL.CODE_INFO
|
||||
, CL.CODE_VALUE
|
||||
, CL.REFERENCE_TABLE
|
||||
, CL.REFERENCE_COLUMN
|
||||
@@ -110,7 +110,7 @@
|
||||
, C.CHARACTER_MAXIMUM_LENGTH AS MAX_LENGTH
|
||||
, C.NUMERIC_PRECISION
|
||||
, C.NUMERIC_SCALE
|
||||
, COALESCE(TTC.CODE_CATEGORY, CL.CODE_CATEGORY) AS CODE_CATEGORY
|
||||
, COALESCE(TTC.CODE_INFO, CL.CODE_INFO) AS CODE_INFO
|
||||
, COALESCE(TTC.CODE_VALUE, CL.CODE_VALUE) AS CODE_VALUE
|
||||
, COALESCE(TTC.REFERENCE_TABLE, CL.REFERENCE_TABLE) AS REFERENCE_TABLE
|
||||
, COALESCE(TTC.REFERENCE_COLUMN, CL.REFERENCE_COLUMN) AS REFERENCE_COLUMN
|
||||
@@ -253,7 +253,7 @@
|
||||
, DESCRIPTION
|
||||
, DISPLAY_ORDER
|
||||
, IS_VISIBLE
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
@@ -275,7 +275,7 @@
|
||||
, COLUMN_LABEL
|
||||
, INPUT_TYPE
|
||||
, DETAIL_SETTINGS
|
||||
, CODE_CATEGORY
|
||||
, CODE_INFO
|
||||
, CODE_VALUE
|
||||
, REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN
|
||||
@@ -293,14 +293,14 @@
|
||||
, #{column_label}
|
||||
, #{input_type}
|
||||
, #{detail_settings}::JSONB
|
||||
, #{code_category}
|
||||
, #{code_info}
|
||||
, #{code_value}
|
||||
, #{reference_table}
|
||||
, #{reference_column}
|
||||
, #{display_column}
|
||||
, #{display_order}
|
||||
, #{is_visible}
|
||||
, 'Y'
|
||||
, COALESCE(#{is_nullable}, 'Y')
|
||||
, #{company_code}
|
||||
, #{category_ref}
|
||||
, NOW()
|
||||
@@ -311,13 +311,14 @@
|
||||
COLUMN_LABEL = COALESCE(EXCLUDED.COLUMN_LABEL, TABLE_TYPE_COLUMNS.COLUMN_LABEL)
|
||||
, INPUT_TYPE = COALESCE(EXCLUDED.INPUT_TYPE, TABLE_TYPE_COLUMNS.INPUT_TYPE)
|
||||
, DETAIL_SETTINGS = COALESCE(EXCLUDED.DETAIL_SETTINGS, TABLE_TYPE_COLUMNS.DETAIL_SETTINGS)
|
||||
, CODE_CATEGORY = EXCLUDED.CODE_CATEGORY
|
||||
, CODE_INFO = EXCLUDED.CODE_INFO
|
||||
, CODE_VALUE = EXCLUDED.CODE_VALUE
|
||||
, REFERENCE_TABLE = EXCLUDED.REFERENCE_TABLE
|
||||
, REFERENCE_COLUMN = EXCLUDED.REFERENCE_COLUMN
|
||||
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
|
||||
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
|
||||
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
|
||||
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
|
||||
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
|
||||
, UPDATED_DATE = NOW()
|
||||
</insert>
|
||||
@@ -354,7 +355,7 @@
|
||||
, REFERENCE_TABLE = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_TABLE END
|
||||
, REFERENCE_COLUMN= CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.REFERENCE_COLUMN END
|
||||
, DISPLAY_COLUMN = CASE WHEN #{clear_entity} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.DISPLAY_COLUMN END
|
||||
, CODE_CATEGORY = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_CATEGORY END
|
||||
, CODE_INFO = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_INFO END
|
||||
, CODE_VALUE = CASE WHEN #{clear_code} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CODE_VALUE END
|
||||
, CATEGORY_REF = CASE WHEN #{clear_category} = 'true' THEN NULL ELSE TABLE_TYPE_COLUMNS.CATEGORY_REF END
|
||||
, UPDATED_DATE = NOW()
|
||||
@@ -389,7 +390,7 @@
|
||||
<select id="getTablePrimaryKeyList" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
TC.CONNAME AS constraint_name
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_CONSTRAINT TC
|
||||
JOIN PG_CLASS C
|
||||
ON TC.CONRELID = C.OID
|
||||
@@ -411,7 +412,7 @@
|
||||
SELECT
|
||||
I.RELNAME AS index_name
|
||||
, IX.INDISUNIQUE AS is_unique
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N) AS columns
|
||||
, ARRAY_AGG(A.ATTNAME ORDER BY X.N)::text AS columns
|
||||
FROM PG_INDEX IX
|
||||
JOIN PG_CLASS T
|
||||
ON IX.INDRELID = T.OID
|
||||
@@ -667,15 +668,15 @@
|
||||
SET
|
||||
PROPERTIES = JSONB_SET(
|
||||
JSONB_SET(
|
||||
SL.PROPERTIES,
|
||||
SL.PROPERTIES::JSONB,
|
||||
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
|
||||
),
|
||||
'{componentType}', TO_JSONB(#{component_id}::TEXT)
|
||||
)
|
||||
)::TEXT
|
||||
FROM SCREEN_DEFINITIONS SD
|
||||
WHERE SL.SCREEN_ID = SD.SCREEN_ID
|
||||
AND SL.PROPERTIES->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES->>'columnName' = #{column_name}
|
||||
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
|
||||
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
|
||||
</update>
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.erp.batch;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 3 검증 — vexplor_rps L550~617 알고리즘 1:1 이식 결과가 정상 동작하는지.
|
||||
*
|
||||
* 외부 의존 없는 순수 함수만 검증.
|
||||
*/
|
||||
class MappingTransformerTest {
|
||||
|
||||
// ── evaluateConditional ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void evaluateConditional_단순_매칭() {
|
||||
MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig();
|
||||
cfg.rules.add(new MappingTransformer.ConditionalRule("1", "Y"));
|
||||
cfg.rules.add(new MappingTransformer.ConditionalRule("0", "N"));
|
||||
cfg.defaultValue = "?";
|
||||
|
||||
assertEquals("Y", MappingTransformer.evaluateConditional("1", cfg));
|
||||
assertEquals("N", MappingTransformer.evaluateConditional("0", cfg));
|
||||
assertEquals("?", MappingTransformer.evaluateConditional("9", cfg)); // 매칭 없음 → default
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluateConditional_null_cfg_안전() {
|
||||
assertNull(MappingTransformer.evaluateConditional("anything", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluateConditional_빈_rules_default만() {
|
||||
MappingTransformer.ConditionalConfig cfg = new MappingTransformer.ConditionalConfig();
|
||||
cfg.defaultValue = "fallback";
|
||||
assertEquals("fallback", MappingTransformer.evaluateConditional("anything", cfg));
|
||||
}
|
||||
|
||||
// ── parseConditionalConfig (JSONB normalize) ──────────────────────────
|
||||
|
||||
@Test
|
||||
void parseConditionalConfig_Map_입력() {
|
||||
Map<String, Object> raw = new LinkedHashMap<>();
|
||||
raw.put("rules", List.of(Map.of("when", "1", "then", "Y")));
|
||||
raw.put("default", "?");
|
||||
|
||||
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(raw);
|
||||
assertEquals(1, cfg.rules.size());
|
||||
assertEquals("1", cfg.rules.get(0).when);
|
||||
assertEquals("Y", cfg.rules.get(0).then);
|
||||
assertEquals("?", cfg.defaultValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseConditionalConfig_String_JSON_입력() {
|
||||
String json = "{\"rules\":[{\"when\":\"J01\",\"then\":\"active\"}],\"default\":\"\"}";
|
||||
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(json);
|
||||
assertEquals(1, cfg.rules.size());
|
||||
assertEquals("J01", cfg.rules.get(0).when);
|
||||
assertEquals("active", cfg.rules.get(0).then);
|
||||
assertEquals("", cfg.defaultValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseConditionalConfig_null_빈cfg() {
|
||||
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig(null);
|
||||
assertNotNull(cfg);
|
||||
assertTrue(cfg.rules.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseConditionalConfig_손상된_JSON_빈cfg() {
|
||||
MappingTransformer.ConditionalConfig cfg = MappingTransformer.parseConditionalConfig("{not json");
|
||||
assertNotNull(cfg);
|
||||
assertTrue(cfg.rules.isEmpty());
|
||||
}
|
||||
|
||||
// ── getValueByPath (점 표기법) ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getValueByPath_단순_키() {
|
||||
Map<String, Object> obj = Map.of("name", "alice");
|
||||
assertEquals("alice", MappingTransformer.getValueByPath(obj, "name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getValueByPath_중첩_경로() {
|
||||
Map<String, Object> obj = Map.of("response", Map.of("access_token", "xyz"));
|
||||
assertEquals("xyz", MappingTransformer.getValueByPath(obj, "response.access_token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getValueByPath_없는_경로_null() {
|
||||
Map<String, Object> obj = Map.of("name", "alice");
|
||||
assertNull(MappingTransformer.getValueByPath(obj, "missing.path"));
|
||||
assertNull(MappingTransformer.getValueByPath(obj, "name.deeper"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getValueByPath_null_obj_안전() {
|
||||
assertNull(MappingTransformer.getValueByPath(null, "anything"));
|
||||
}
|
||||
|
||||
// ── partitionFixed ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void partitionFixed_분리() {
|
||||
List<Map<String, Object>> mappings = List.of(
|
||||
Map.of("mapping_type", "direct", "to_column_name", "a"),
|
||||
Map.of("mapping_type", "fixed", "to_column_name", "b"),
|
||||
Map.of("mapping_type", "conditional", "to_column_name", "c")
|
||||
);
|
||||
MappingTransformer.Partition p = MappingTransformer.partitionFixed(mappings);
|
||||
assertEquals(2, p.nonFixed.size());
|
||||
assertEquals(1, p.fixed.size());
|
||||
assertEquals("b", p.fixed.get(0).get("to_column_name"));
|
||||
}
|
||||
|
||||
// ── transformRow (통합) ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void transformRow_direct_매핑() {
|
||||
Map<String, Object> row = Map.of("user_id", "alice", "email", "a@x.com");
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
Map.of("mapping_type", "direct",
|
||||
"from_column_name", "user_id",
|
||||
"to_column_name", "USER_ID"),
|
||||
Map.of("mapping_type", "direct",
|
||||
"from_column_name", "email",
|
||||
"to_column_name", "EMAIL_ADDR")
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, List.of(), "internal", "COMPANY_1");
|
||||
assertEquals("alice", mapped.get("USER_ID"));
|
||||
assertEquals("a@x.com", mapped.get("EMAIL_ADDR"));
|
||||
assertEquals("COMPANY_1", mapped.get("company_code")); // 자동 주입
|
||||
}
|
||||
|
||||
@Test
|
||||
void transformRow_conditional_매핑_1을_Y로() {
|
||||
Map<String, Object> row = Map.of("active_flag", "1");
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
new HashMap<>(Map.of(
|
||||
"mapping_type", "conditional",
|
||||
"from_column_name", "active_flag",
|
||||
"to_column_name", "IS_ACTIVE",
|
||||
"mapping_config", Map.of(
|
||||
"rules", List.of(
|
||||
Map.of("when", "1", "then", "Y"),
|
||||
Map.of("when", "0", "then", "N")),
|
||||
"default", "?")))
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, List.of(), "internal", null);
|
||||
assertEquals("Y", mapped.get("IS_ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transformRow_conditional_매핑_default_폴백() {
|
||||
Map<String, Object> row = Map.of("active_flag", "9"); // 어떤 룰에도 매칭 안 됨
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
new HashMap<>(Map.of(
|
||||
"mapping_type", "conditional",
|
||||
"from_column_name", "active_flag",
|
||||
"to_column_name", "IS_ACTIVE",
|
||||
"mapping_config", Map.of(
|
||||
"rules", List.of(Map.of("when", "1", "then", "Y")),
|
||||
"default", "?")))
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, List.of(), "internal", null);
|
||||
assertEquals("?", mapped.get("IS_ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transformRow_fixed_매핑_적용() {
|
||||
Map<String, Object> row = Map.of("user_id", "alice");
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
Map.of("mapping_type", "direct",
|
||||
"from_column_name", "user_id",
|
||||
"to_column_name", "USER_ID")
|
||||
);
|
||||
List<Map<String, Object>> fixed = List.of(
|
||||
Map.of("mapping_type", "fixed",
|
||||
"from_column_name", "BATCH_001",
|
||||
"to_column_name", "SOURCE_BATCH")
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, fixed, "internal", null);
|
||||
assertEquals("alice", mapped.get("USER_ID"));
|
||||
assertEquals("BATCH_001", mapped.get("SOURCE_BATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transformRow_점_표기법_API_응답() {
|
||||
Map<String, Object> row = Map.of(
|
||||
"user", Map.of("profile", Map.of("name", "박창현"))
|
||||
);
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
Map.of("mapping_type", "direct",
|
||||
"from_column_name", "user.profile.name",
|
||||
"to_column_name", "USER_NAME")
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, List.of(), "internal", null);
|
||||
assertEquals("박창현", mapped.get("USER_NAME"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transformRow_to_가_restapi_면_company_code_자동주입_안함() {
|
||||
Map<String, Object> row = Map.of("user_id", "alice");
|
||||
List<Map<String, Object>> nonFixed = List.of(
|
||||
Map.of("mapping_type", "direct",
|
||||
"from_column_name", "user_id",
|
||||
"to_column_name", "USER_ID")
|
||||
);
|
||||
Map<String, Object> mapped = MappingTransformer.transformRow(
|
||||
row, nonFixed, List.of(), "restapi", "COMPANY_1");
|
||||
assertFalse(mapped.containsKey("company_code"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# 087 마이그레이션 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가
|
||||
|
||||
작성일: 2026-05-13
|
||||
작성자: hjjeong
|
||||
관련: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md` (Phase 1)
|
||||
|
||||
## 목적
|
||||
|
||||
vexplor_rps 의 conditional 매핑(파이프라인) 기능을 INVYONE 으로 이식하기 위한 첫 단계.
|
||||
`BATCH_MAPPINGS` 행마다 매핑 규칙(when/then/default) 을 JSONB 로 저장할 컬럼 추가.
|
||||
|
||||
- `mapping_type='direct'` / `'fixed'` → `MAPPING_CONFIG` 는 NULL
|
||||
- `mapping_type='conditional'` → `MAPPING_CONFIG` 에 `{"rules":[{"when":"1","then":"Y"}],"default":"?"}` 형태 저장
|
||||
|
||||
Phase 2 (frontend ConditionalEditor + API 확장) 와 Phase 3 (Backend MappingTransformer) 가
|
||||
이 컬럼을 읽고 쓰는 전제로 동작한다.
|
||||
|
||||
## 스키마
|
||||
|
||||
### BATCH_MAPPINGS ALTER
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `MAPPING_CONFIG` | JSONB | NULL 허용 | conditional 평가 규칙. direct/fixed 면 NULL |
|
||||
|
||||
저장 포맷(`mapping_type='conditional'`):
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{ "when": "1", "then": "Y" },
|
||||
{ "when": "0", "then": "N" }
|
||||
],
|
||||
"default": "?"
|
||||
}
|
||||
```
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 087: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
ALTER TABLE BATCH_MAPPINGS
|
||||
ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB;
|
||||
```
|
||||
|
||||
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 ALTER 를
|
||||
`IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
|
||||
별도 환경(콜드 백업 복원 등)에서 수동 실행이 필요할 때 위 SQL 한 줄을 그대로 사용.
|
||||
|
||||
## 사전 점검
|
||||
|
||||
```sql
|
||||
-- A. 컬럼 사전 상태
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config';
|
||||
-- 빈 결과여야 정상. 이미 있으면 ALTER 의 IF NOT EXISTS 가 안전.
|
||||
|
||||
-- B. 기존 데이터 행수 (마이그레이션 영향 범위 확인)
|
||||
SELECT COUNT(*) FROM BATCH_MAPPINGS;
|
||||
-- 컬럼만 추가하므로 기존 행은 MAPPING_CONFIG = NULL 로 유지됨.
|
||||
```
|
||||
|
||||
## 사후 검증
|
||||
|
||||
```sql
|
||||
-- C. 컬럼 추가 확인
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config';
|
||||
-- 기대: data_type = 'jsonb'
|
||||
|
||||
-- D. JSONB 동작 확인 (테스트)
|
||||
BEGIN;
|
||||
UPDATE BATCH_MAPPINGS
|
||||
SET MAPPING_CONFIG = '{"rules":[{"when":"1","then":"Y"}],"default":"?"}'::jsonb
|
||||
WHERE ID = (SELECT ID FROM BATCH_MAPPINGS LIMIT 1);
|
||||
SELECT MAPPING_CONFIG->'rules'->0->>'when' AS sample
|
||||
FROM BATCH_MAPPINGS
|
||||
WHERE MAPPING_CONFIG IS NOT NULL
|
||||
LIMIT 1;
|
||||
-- 기대: sample = '1'
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 1) 메타 DB
|
||||
psql -h <host> -U postgres -d invyone -f RUN_087.sql
|
||||
|
||||
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
|
||||
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||
echo "=== $db ==="
|
||||
psql -h <host> -U postgres -d "$db" -f RUN_087.sql
|
||||
done
|
||||
```
|
||||
|
||||
`RUN_087.sql` 은 위 "SQL" 섹션의 ALTER 한 줄을 그대로 담은 파일입니다.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
-- MAPPING_CONFIG 컬럼 제거 (저장된 conditional 규칙은 함께 삭제됨)
|
||||
ALTER TABLE BATCH_MAPPINGS DROP COLUMN IF EXISTS MAPPING_CONFIG;
|
||||
```
|
||||
|
||||
## 적용 환경 체크리스트
|
||||
|
||||
- [ ] 로컬 docker `naengangi-pg` (메타 + 활성 테넌트 전부)
|
||||
- [ ] wace 개발서버 PostgreSQL
|
||||
- [ ] 운영 메타 DB (`invyone`)
|
||||
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
|
||||
|
||||
## 관련 코드
|
||||
|
||||
- Flyway: `backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql`
|
||||
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목)
|
||||
- Mapper: `backend-spring/src/main/resources/mapper/batch.xml`
|
||||
- `getBatchMappingsByConfigId` 의 SELECT 절: `MAPPING_CONFIG::TEXT AS MAPPING_CONFIG`
|
||||
- `insertBatchMapping` 의 VALUES 절: `#{mapping_config,jdbcType=OTHER}::jsonb`
|
||||
- Service: `backend-spring/src/main/java/com/erp/service/BatchService.java`
|
||||
- `syncMappings()` 가 `stringifyJsonField(row, "mapping_config")` 로 직렬화 후 INSERT
|
||||
- `attachMappings()` 가 `parseJsonField(row, "mapping_config")` 로 SELECT 결과 역직렬화
|
||||
@@ -0,0 +1,133 @@
|
||||
# 088 마이그레이션 — DEPT_MANAGERS 테이블 추가 (다중 관리자 + 조직장)
|
||||
|
||||
작성일: 2026-05-14
|
||||
작성자: johngreen
|
||||
관련: RPS 더존 ERP UJA1040 레퍼런스 대비 누락 기능 (A 단계 — 다중 관리자 + 조직장)
|
||||
|
||||
## 목적
|
||||
|
||||
부서별로 결재 관리자 / 부서 관리자 / 조직장을 각각 **다중 등록 (최대 10명)** 할 수 있도록 매핑 테이블 신설.
|
||||
|
||||
- 기존 `DEPT_INFO.APPROVAL_MANAGER` / `DEPT_INFO.DEPT_MANAGER` 컬럼은 단일 `user_id` 만 저장 가능
|
||||
- 신규 `DEPT_MANAGERS` 매핑 테이블이 SoT(source of truth). `ROLE` 컬럼으로 3 종류 구분
|
||||
- `approval` = 결재 관리자 (자동 결재라인 등록 시 호출)
|
||||
- `dept` = 부서 관리자 (행정 책임자)
|
||||
- `org_leader` = 조직장 (본인 부서 + 하위 부서의 경비/근태 조회·승인 권한)
|
||||
- 기존 단일 컬럼은 **호환 위해 일단 유지**. 향후 cleanup PR 에서 제거 예정
|
||||
|
||||
## 스키마
|
||||
|
||||
### DEPT_MANAGERS (신규)
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `DEPT_CODE` | VARCHAR(1024) | NOT NULL, FK → DEPT_INFO ON DELETE CASCADE | 부서 코드 |
|
||||
| `USER_ID` | VARCHAR(50) | NOT NULL | 사용자 ID |
|
||||
| `ROLE` | VARCHAR(20) | NOT NULL, CHECK | `approval` \| `dept` \| `org_leader` |
|
||||
| `SORT_ORDER` | INTEGER | NOT NULL DEFAULT 1 | 표시 순서 |
|
||||
| `CREATED_AT` | TIMESTAMP | NOT NULL DEFAULT NOW() | 등록 시각 |
|
||||
|
||||
PK: `(DEPT_CODE, USER_ID, ROLE)` — 같은 사용자가 같은 부서에 같은 role 로 중복 등록 차단.
|
||||
인덱스: `(DEPT_CODE, ROLE, SORT_ORDER)` — 부서별 role 조회 + 정렬 가속.
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 088: DEPT_MANAGERS 테이블 (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 DDL 을 `IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
|
||||
|
||||
## 사전 점검
|
||||
|
||||
```sql
|
||||
-- A. 테이블 사전 상태
|
||||
SELECT table_name FROM information_schema.tables WHERE table_name = 'dept_managers';
|
||||
-- 빈 결과여야 정상. 이미 있으면 CREATE 의 IF NOT EXISTS 가 안전.
|
||||
|
||||
-- B. DEPT_INFO 행수 (FK 영향 범위)
|
||||
SELECT COUNT(*) FROM DEPT_INFO;
|
||||
```
|
||||
|
||||
## 사후 검증
|
||||
|
||||
```sql
|
||||
-- C. 테이블 추가 확인
|
||||
SELECT column_name, data_type, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dept_managers'
|
||||
ORDER BY ordinal_position;
|
||||
-- 기대: 5 행 (DEPT_CODE/USER_ID/ROLE/SORT_ORDER/CREATED_AT)
|
||||
|
||||
-- D. CHECK 제약 확인
|
||||
SELECT constraint_name, check_clause FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'chk_dept_managers_role';
|
||||
-- 기대: ROLE IN ('approval', 'dept', 'org_leader')
|
||||
|
||||
-- E. FK 동작 확인 (테스트)
|
||||
BEGIN;
|
||||
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE)
|
||||
VALUES ('NON_EXISTENT_DEPT', 'tester', 'approval');
|
||||
-- 기대: FK 위반 에러 (foreign key constraint "fk_dept_managers_dept")
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 1) 메타 DB
|
||||
psql -h <host> -U postgres -d invyone -f RUN_088.sql
|
||||
|
||||
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
|
||||
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||
echo "=== $db ==="
|
||||
psql -h <host> -U postgres -d "$db" -f RUN_088.sql
|
||||
done
|
||||
```
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
-- DEPT_MANAGERS 테이블 제거 (저장된 다중 관리자 매핑 함께 삭제됨)
|
||||
DROP INDEX IF EXISTS idx_dept_managers_role;
|
||||
DROP TABLE IF EXISTS DEPT_MANAGERS;
|
||||
```
|
||||
|
||||
롤백 후엔 백엔드/프론트가 단일 `APPROVAL_MANAGER` / `DEPT_MANAGER` 컬럼만 사용하는 이전 동작으로 자연스럽게 복귀 (호환 컬럼 유지하기 때문).
|
||||
|
||||
## 적용 환경 체크리스트
|
||||
|
||||
- [ ] 로컬 docker `naengangi-pg` (관련 없음 — invyone DB 는 wace/운영에만 존재)
|
||||
- [ ] wace 개발서버 PostgreSQL
|
||||
- [ ] 운영 메타 DB (`invyone`)
|
||||
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
|
||||
|
||||
## 관련 코드
|
||||
|
||||
- Flyway: `backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql`
|
||||
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목으로 추가)
|
||||
- Mapper: `backend-spring/src/main/resources/mapper/department.xml`
|
||||
- `selectDepartments` / `selectDepartmentByCode` 의 SELECT 절에 `APPROVAL_MANAGERS`/`DEPT_MANAGERS`/`ORG_LEADERS` json_agg 컬럼 추가
|
||||
- 신규 query: `insertDeptManagers`, `deleteDeptManagersByDept`
|
||||
- Service: `DepartmentService.java`
|
||||
- `createDepartment` / `updateDepartment` 가 body 의 `approval_managers[]`/`dept_managers[]`/`org_leaders[]` 배열을 `DEPT_MANAGERS` 에 sync (트랜잭션, 최대 10명 검증)
|
||||
- Frontend: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
|
||||
- BasicInfoForm 에 다중 chip UI + ManagerPicker 모달
|
||||
@@ -0,0 +1,143 @@
|
||||
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련:
|
||||
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
|
||||
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
|
||||
|
||||
## 목적
|
||||
|
||||
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
|
||||
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
|
||||
|
||||
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
|
||||
|
||||
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
|
||||
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
|
||||
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
|
||||
|
||||
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
|
||||
|
||||
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
|
||||
`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
|
||||
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
|
||||
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
|
||||
|
||||
```
|
||||
ERROR: column cl.code_info does not exist
|
||||
```
|
||||
|
||||
본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다.
|
||||
|
||||
## 스키마
|
||||
|
||||
### MENU_INFO (V023)
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
|
||||
|
||||
### TABLE_TYPE_COLUMNS (V024)
|
||||
|
||||
| 변경 | 설명 |
|
||||
|---|---|
|
||||
| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
|
||||
-- =================================================================
|
||||
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
|
||||
-- =================================================================
|
||||
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
|
||||
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
|
||||
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
|
||||
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
|
||||
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
|
||||
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요.
|
||||
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
|
||||
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
|
||||
|
||||
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
|
||||
-- 위 SQL 본문 붙여넣기
|
||||
SQL
|
||||
```
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- V023
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
|
||||
-- → 1 row
|
||||
|
||||
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
|
||||
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
|
||||
-- → 모두 IS_SOLUTION_ONLY = TRUE
|
||||
|
||||
-- V024
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
|
||||
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
|
||||
```
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
|
||||
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
|
||||
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
|
||||
만약 필요하면:
|
||||
```sql
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
|
||||
```
|
||||
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
|
||||
|
||||
## 증상
|
||||
|
||||
```
|
||||
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
|
||||
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
|
||||
```
|
||||
|
||||
## 원인
|
||||
|
||||
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
|
||||
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
|
||||
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
|
||||
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
|
||||
|
||||
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
|
||||
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
|
||||
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
|
||||
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
|
||||
|
||||
## 조치
|
||||
|
||||
### (1) 중복 행 정리
|
||||
|
||||
각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
|
||||
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
|
||||
|
||||
```sql
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
);
|
||||
```
|
||||
|
||||
실측(2026-05-15) 중복:
|
||||
|
||||
| DB | 중복 그룹 | 삭제될 row |
|
||||
|---|---|---|
|
||||
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
|
||||
| `siflex_invyone` | 0 | 0 |
|
||||
| `test01_invyone` | 0 | 0 |
|
||||
| `test02_invyone` | 0 | 0 |
|
||||
|
||||
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
|
||||
|
||||
### (2) UNIQUE INDEX 추가
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
|
||||
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
|
||||
```
|
||||
|
||||
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
|
||||
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
|
||||
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
|
||||
테넌트 DB 에 차례로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 중복 없음
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM TABLE_TYPE_COLUMNS
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
|
||||
) d;
|
||||
-- → 0
|
||||
|
||||
-- 인덱스 존재
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
|
||||
-- → 1 row
|
||||
```
|
||||
|
||||
브라우저 검증:
|
||||
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
|
||||
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
|
||||
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
|
||||
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
|
||||
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
|
||||
```
|
||||
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
|
||||
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
|
||||
|
||||
작성일: 2026-05-16
|
||||
작성자: johngreen
|
||||
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
|
||||
|
||||
## 배경
|
||||
|
||||
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
|
||||
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
|
||||
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
|
||||
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
|
||||
|
||||
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
|
||||
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
|
||||
|
||||
## 매핑
|
||||
|
||||
| Legacy | → | Standard | 사유 |
|
||||
|---|---|---|---|
|
||||
| `category` | → | `code` | commonCode 통합 의도와 일치 |
|
||||
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
|
||||
| `radio` | → | `code` | enum 선택 |
|
||||
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
|
||||
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
|
||||
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
|
||||
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
|
||||
|
||||
## 영향 범위 (실측 2026-05-16)
|
||||
|
||||
| DB | 갱신 row |
|
||||
|---|---|
|
||||
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
|
||||
| `siflex_invyone` | 0 (테이블 비어있음) |
|
||||
| `test01_invyone` | 0 |
|
||||
| `test02_invyone` | 0 |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
|
||||
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 화이트리스트 밖 row 0 이어야 함
|
||||
SELECT input_type, COUNT(*) FROM table_type_columns
|
||||
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
|
||||
GROUP BY 1;
|
||||
-- → 0 rows
|
||||
```
|
||||
|
||||
## 후속 cleanup (별도 PR 거리)
|
||||
|
||||
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
|
||||
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
|
||||
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
|
||||
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
|
||||
|
||||
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Company } from "@/types/company";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
|
||||
const RESOURCE_TYPE_CONFIG: Record<
|
||||
string,
|
||||
@@ -78,7 +80,7 @@ const RESOURCE_TYPE_CONFIG: Record<
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE_INFO: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.company_code === "*";
|
||||
|
||||
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
if (hostBlocked) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트
|
||||
*/
|
||||
export default function AutoFillRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/admin/cascading-management?tab=autofill");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export default function BatchCreatePage() {
|
||||
toast.success("매핑을 삭제했어요");
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName.trim()) { toast.error("배치 이름을 입력해주세요"); return; }
|
||||
|
||||
@@ -25,8 +25,14 @@ import {
|
||||
ConnectionInfo,
|
||||
type NodeFlowInfo,
|
||||
type BatchExecutionType,
|
||||
type ConditionalConfig,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
import {
|
||||
ConditionalEditor,
|
||||
emptyConditionalConfig,
|
||||
normalizeConditionalConfig,
|
||||
} from "@/components/admin/batch/ConditionalEditor";
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
|
||||
@@ -165,12 +171,17 @@ export default function BatchEditPage() {
|
||||
const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static");
|
||||
|
||||
// 매핑 리스트 (새로운 UI용)
|
||||
// sourceType:
|
||||
// - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct)
|
||||
// - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed)
|
||||
// - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional)
|
||||
interface MappingItem {
|
||||
id: string;
|
||||
dbColumn: string;
|
||||
sourceType: "api" | "fixed";
|
||||
sourceType: "api" | "fixed" | "conditional";
|
||||
apiField: string;
|
||||
fixedValue: string;
|
||||
conditionalConfig?: ConditionalConfig;
|
||||
}
|
||||
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
|
||||
|
||||
@@ -377,13 +388,27 @@ export default function BatchEditPage() {
|
||||
});
|
||||
|
||||
// 기존 매핑을 mappingList로 변환
|
||||
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
|
||||
id: `mapping-${index}-${Date.now()}`,
|
||||
dbColumn: mapping.to_column_name || "",
|
||||
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
|
||||
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
|
||||
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
|
||||
}));
|
||||
// mapping_type 분기:
|
||||
// "fixed" → from_column_name 자체가 고정값 → fixedValue
|
||||
// "conditional" → from_column_name 이 평가 필드명 → apiField + conditionalConfig
|
||||
// 그 외(direct) → from_column_name 이 API 필드명 → apiField
|
||||
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => {
|
||||
const mt = (mapping as any).mapping_type || "direct";
|
||||
const sourceType: MappingItem["sourceType"] =
|
||||
mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api";
|
||||
const conditionalConfig =
|
||||
sourceType === "conditional"
|
||||
? normalizeConditionalConfig((mapping as any).mapping_config)
|
||||
: undefined;
|
||||
return {
|
||||
id: `mapping-${index}-${Date.now()}`,
|
||||
dbColumn: mapping.to_column_name || "",
|
||||
sourceType,
|
||||
apiField: sourceType === "fixed" ? "" : mapping.from_column_name || "",
|
||||
fixedValue: sourceType === "fixed" ? mapping.from_column_name || "" : "",
|
||||
conditionalConfig,
|
||||
};
|
||||
});
|
||||
setMappingList(convertedMappingList);
|
||||
console.log("🔄 변환된 mappingList:", convertedMappingList);
|
||||
}
|
||||
@@ -651,7 +676,7 @@ export default function BatchEditPage() {
|
||||
nodeFlowContext: parsedContext,
|
||||
});
|
||||
toast.success("배치 설정이 저장되었습니다!");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
} catch (error) {
|
||||
console.error("배치 저장 실패:", error);
|
||||
toast.error("배치 저장에 실패했습니다.");
|
||||
@@ -679,26 +704,46 @@ export default function BatchEditPage() {
|
||||
const first = batchConfig.batch_mappings[0] as any;
|
||||
finalMappings = mappingList
|
||||
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
|
||||
.map((m, index) => ({
|
||||
// FROM: REST API (기존 설정 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
|
||||
from_column_type: m.sourceType === "fixed" ? "text" : "text",
|
||||
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
|
||||
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
|
||||
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
|
||||
// TO: DB (기존 설정 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: toTable || first.to_table_name,
|
||||
to_column_name: m.dbColumn,
|
||||
to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
|
||||
mapping_type: m.sourceType === "fixed" ? "fixed" : "direct",
|
||||
mapping_order: index + 1,
|
||||
})) as BatchMapping[];
|
||||
.map((m, index) => {
|
||||
// from_column_name 결정:
|
||||
// fixed → fixedValue 자체가 저장됨
|
||||
// conditional → apiField (평가할 API 필드)
|
||||
// direct(api) → apiField
|
||||
const fromColumnName =
|
||||
m.sourceType === "fixed" ? m.fixedValue : m.apiField;
|
||||
const mappingType: "direct" | "fixed" | "conditional" =
|
||||
m.sourceType === "fixed"
|
||||
? "fixed"
|
||||
: m.sourceType === "conditional"
|
||||
? "conditional"
|
||||
: "direct";
|
||||
return {
|
||||
// FROM: REST API (기존 설정 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: fromColumnName,
|
||||
from_column_type: "text",
|
||||
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
|
||||
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
|
||||
from_api_body: mappings[0]?.from_api_body || first.from_api_body,
|
||||
// TO: DB (기존 설정 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: toTable || first.to_table_name,
|
||||
to_column_name: m.dbColumn,
|
||||
to_column_type:
|
||||
toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
|
||||
mapping_type: mappingType,
|
||||
// conditional 일 때만 룰 객체를 함께 전송. 백엔드가 JSONB 로 저장.
|
||||
mapping_config:
|
||||
m.sourceType === "conditional" && m.conditionalConfig
|
||||
? m.conditionalConfig
|
||||
: null,
|
||||
mapping_order: index + 1,
|
||||
};
|
||||
}) as BatchMapping[];
|
||||
}
|
||||
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
@@ -714,7 +759,7 @@ export default function BatchEditPage() {
|
||||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
@@ -724,7 +769,7 @@ export default function BatchEditPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
@@ -739,7 +784,7 @@ export default function BatchEditPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="h-full w-full space-y-7 overflow-y-auto p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
@@ -1617,14 +1662,22 @@ export default function BatchEditPage() {
|
||||
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
|
||||
{/* 소스 타입 선택 */}
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="w-28 shrink-0">
|
||||
<Select
|
||||
value={mapping.sourceType}
|
||||
onValueChange={(value: "api" | "fixed") =>
|
||||
onValueChange={(value: "api" | "fixed" | "conditional") =>
|
||||
updateMappingListItem(mapping.id, {
|
||||
sourceType: value,
|
||||
apiField: value === "fixed" ? "" : mapping.apiField,
|
||||
fixedValue: value === "api" ? "" : mapping.fixedValue,
|
||||
// 모드 전환 시 입력값 정리
|
||||
apiField:
|
||||
value === "api" || value === "conditional"
|
||||
? mapping.apiField
|
||||
: "",
|
||||
fixedValue: value === "fixed" ? mapping.fixedValue : "",
|
||||
conditionalConfig:
|
||||
value === "conditional"
|
||||
? mapping.conditionalConfig || emptyConditionalConfig()
|
||||
: mapping.conditionalConfig,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -1634,13 +1687,14 @@ export default function BatchEditPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="api">API 필드</SelectItem>
|
||||
<SelectItem value="fixed">고정값</SelectItem>
|
||||
<SelectItem value="conditional">조건 변환</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
|
||||
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{mapping.sourceType === "api" ? (
|
||||
{mapping.sourceType === "api" && (
|
||||
<Select
|
||||
value={mapping.apiField || "none"}
|
||||
onValueChange={(value) =>
|
||||
@@ -1667,7 +1721,8 @@ export default function BatchEditPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
)}
|
||||
{mapping.sourceType === "fixed" && (
|
||||
<Input
|
||||
value={mapping.fixedValue}
|
||||
onChange={(e) => updateMappingListItem(mapping.id, { fixedValue: e.target.value })}
|
||||
@@ -1675,6 +1730,19 @@ export default function BatchEditPage() {
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
{mapping.sourceType === "conditional" && (
|
||||
<ConditionalEditor
|
||||
evaluateField={mapping.apiField}
|
||||
fieldOptions={fromApiFields}
|
||||
config={mapping.conditionalConfig || emptyConditionalConfig()}
|
||||
onEvaluateFieldChange={(v) =>
|
||||
updateMappingListItem(mapping.id, { apiField: v })
|
||||
}
|
||||
onConfigChange={(cfg) =>
|
||||
updateMappingListItem(mapping.id, { conditionalConfig: cfg })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/api/batch";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
|
||||
import { Pagination } from "@/components/common/Pagination";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function cronToKorean(cron: string): string {
|
||||
@@ -127,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasSuccess = success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
|
||||
const colorClass = hasFail
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
@@ -140,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -277,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
function GlobalSparkline({ data }: { data: SparklineData[] }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
|
||||
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -293,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
{data.map((slot, i) => {
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasExec = success > 0 || hasFail;
|
||||
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
|
||||
const h = hasFail
|
||||
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
|
||||
: hasExec
|
||||
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
|
||||
: 6;
|
||||
const hour = slot.hour_slot?.slice(11, 16) || "";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
title={`${hour} | 성공 ${success} 실패 ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>24시간 전</span>
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,18 +340,25 @@ export default function BatchManagementPage() {
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
const [configsResponse, statsData, globalSpark] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
BatchAPI.getGlobalSparkline(),
|
||||
]);
|
||||
setGlobalSparkline(globalSpark);
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
@@ -364,6 +385,9 @@ export default function BatchManagementPage() {
|
||||
|
||||
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
|
||||
|
||||
// 검색/필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
|
||||
|
||||
const handleRowClick = async (batchId: number) => {
|
||||
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
|
||||
setExpandedBatch(batchId);
|
||||
@@ -427,12 +451,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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,14 +467,26 @@ export default function BatchManagementPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalItems = filteredBatches.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const startIdx = (safePage - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
|
||||
const pagedBatches = filteredBatches.slice(startIdx, endIdx);
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
const todayExec = Number(stats?.today_count) || 0;
|
||||
const todayFail = Number(stats?.today_failed_count) || 0;
|
||||
const yestExec = Number(stats?.yesterday_count) || 0;
|
||||
const yestFail = Number(stats?.yesterday_failed_count) || 0;
|
||||
const execDiff = todayExec - yestExec;
|
||||
const failDiff = todayFail - yestFail;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-6 sm:px-6">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -486,7 +522,7 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
|
||||
{execDiff !== 0 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
@@ -496,8 +532,8 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{todayFail}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
@@ -509,7 +545,7 @@ export default function BatchManagementPage() {
|
||||
)}
|
||||
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
<GlobalSparkline data={globalSparkline} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -534,8 +570,8 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
{/* 배치 리스트 - 자체 스크롤 */}
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@@ -549,7 +585,7 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredBatches.map((batch) => {
|
||||
{pagedBatches.map((batch) => {
|
||||
const batchId = batch.id!;
|
||||
const isExpanded = expandedBatch === batchId;
|
||||
const isExecuting = executingBatch === batchId;
|
||||
@@ -564,7 +600,7 @@ export default function BatchManagementPage() {
|
||||
const isSuccess = lastStatus === "SUCCESS";
|
||||
|
||||
return (
|
||||
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
|
||||
<div key={`${batch.company_code ?? "x"}-${batchId}`} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
|
||||
{/* 행 */}
|
||||
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
|
||||
{/* 토글 */}
|
||||
@@ -638,7 +674,7 @@ export default function BatchManagementPage() {
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
|
||||
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, admin_url: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
|
||||
title="수정하기"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -674,6 +710,29 @@ export default function BatchManagementPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 — 리스트 영역 아래 고정 */}
|
||||
{!loading && (
|
||||
<div className="shrink-0 rounded-lg border bg-card p-2 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
|
||||
@@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() {
|
||||
) },
|
||||
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
|
||||
disabled={testingConnections.has(row.id!)}
|
||||
className="h-9 text-sm">
|
||||
className="h-7 px-2 text-xs">
|
||||
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(row.id!) && (
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(row.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
<div className="shrink-0 space-y-0.5 border-b pb-3">
|
||||
<h1 className="text-lg font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
데이터베이스 연결
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
REST API 연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 데이터베이스 연결 탭 */}
|
||||
<TabsContent value="database" className="space-y-6">
|
||||
<TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="연결명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
|
||||
<SelectValue placeholder="DB 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedDbTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
|
||||
isLoading={loading}
|
||||
emptyMessage="등록된 연결이 없습니다"
|
||||
skeletonCount={5}
|
||||
compact
|
||||
scrollContainer
|
||||
cardTitle={(c) => c.connection_name}
|
||||
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
||||
cardHeaderRight={(c) => (
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
||||
disabled={testingConnections.has(c.id!)}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
|
||||
setSelectedConnection(c);
|
||||
setSqlModalOpen(true);
|
||||
}}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Terminal className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
SQL
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
편집
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
|
||||
</TabsContent>
|
||||
|
||||
{/* REST API 연결 탭 */}
|
||||
<TabsContent value="rest-api" className="space-y-6">
|
||||
<TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
<RestApiConnectionList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -13,6 +13,15 @@ import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
import type { ConditionalConfig } from "@/lib/api/batch";
|
||||
import {
|
||||
ConditionalEditor,
|
||||
emptyConditionalConfig,
|
||||
} from "@/components/admin/batch/ConditionalEditor";
|
||||
import {
|
||||
ExternalRestApiConnectionAPI,
|
||||
type ExternalRestApiConnection,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
|
||||
// 타입 정의
|
||||
type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi";
|
||||
@@ -36,12 +45,17 @@ interface BatchColumnInfo {
|
||||
}
|
||||
|
||||
// 통합 매핑 아이템 타입
|
||||
// sourceType:
|
||||
// - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct)
|
||||
// - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed)
|
||||
// - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional)
|
||||
interface MappingItem {
|
||||
id: string;
|
||||
dbColumn: string;
|
||||
sourceType: "api" | "fixed";
|
||||
sourceType: "api" | "fixed" | "conditional";
|
||||
apiField: string;
|
||||
fixedValue: string;
|
||||
conditionalConfig?: ConditionalConfig;
|
||||
}
|
||||
|
||||
interface RestApiToDbMappingCardProps {
|
||||
@@ -117,6 +131,15 @@ export default function BatchManagementNewPage() {
|
||||
const [fromApiData, setFromApiData] = useState<any[]>([]);
|
||||
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
|
||||
|
||||
// 등록된 REST API 연결 (외부 커넥션 관리에서 등록한 연결 선택)
|
||||
// - 선택 시 폼(URL/엔드포인트/메서드/Body/인증) 자동 채움
|
||||
// - 자동으로 API 호출하여 응답 필드 추출 → 매핑 드롭다운 즉시 활성화
|
||||
const [registeredRestApis, setRegisteredRestApis] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [selectedRestApiId, setSelectedRestApiId] = useState<string>("manual"); // "manual" = 직접 입력
|
||||
const [rawResponse, setRawResponse] = useState<unknown>(null);
|
||||
const [rawResponseLoading, setRawResponseLoading] = useState(false);
|
||||
const [rawResponseError, setRawResponseError] = useState<string>("");
|
||||
|
||||
// 통합 매핑 리스트
|
||||
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
|
||||
|
||||
@@ -145,8 +168,110 @@ export default function BatchManagementNewPage() {
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
loadAuthServiceNames();
|
||||
loadRegisteredRestApis();
|
||||
}, []);
|
||||
|
||||
// TO DB 자동 선택 — REST API → DB 모드에서 connections 로드 완료 후 TO 가 비어있으면 internal 자동.
|
||||
// 사용자가 외부 DB 로 직접 변경하면 toConnection != null 이 되어 더 이상 동작 안 함.
|
||||
// 대부분의 배치가 internal DB 적재라 디폴트로 들어가는 게 UX 상 자연스러움.
|
||||
useEffect(() => {
|
||||
if (batchType === "restapi-to-db" && !toConnection && connections.length > 0) {
|
||||
handleToConnectionChange("internal");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchType, connections, toConnection]);
|
||||
|
||||
// 등록된 REST API 연결 목록 로드
|
||||
const loadRegisteredRestApis = async () => {
|
||||
try {
|
||||
const list = await ExternalRestApiConnectionAPI.getConnections();
|
||||
setRegisteredRestApis(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
console.error("등록된 REST API 연결 목록 로드 실패:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// 등록된 연결 선택 시 폼 자동 채우기 + API 호출 + 응답 필드 추출 (자동 매핑 준비).
|
||||
// vexplor_rps 의 applyRegisteredRestApi 에서 회사 전용 프리셋(Amaranth) 분기는 의도적으로 제외.
|
||||
const applyRegisteredRestApi = async (id: string) => {
|
||||
setSelectedRestApiId(id);
|
||||
if (id === "manual") return;
|
||||
const conn = registeredRestApis.find((c) => String(c.id) === id);
|
||||
if (!conn) return;
|
||||
|
||||
// 폼 자동 채움
|
||||
setFromApiUrl(conn.base_url || "");
|
||||
setFromEndpoint(conn.endpoint_path || "");
|
||||
setFromApiMethod((conn.default_method as "GET" | "POST" | "PUT" | "DELETE") || "GET");
|
||||
setFromApiBody(conn.default_body || "");
|
||||
|
||||
// 인증 토큰 자동 채움 (직접 입력 모드)
|
||||
setAuthTokenMode("direct");
|
||||
setAuthServiceName("");
|
||||
if (conn.auth_type === "bearer" && conn.auth_config?.token) {
|
||||
setFromApiKey(`Bearer ${conn.auth_config.token}`);
|
||||
} else if (conn.auth_type === "api-key" && conn.auth_config?.keyValue) {
|
||||
setFromApiKey(conn.auth_config.keyValue);
|
||||
} else {
|
||||
// wehago 등 백엔드 자동 서명 타입은 토큰 입력 불필요 — 비워둠
|
||||
setFromApiKey("");
|
||||
}
|
||||
|
||||
// 자동으로 API 호출 → 응답 본문 + 필드 추출하여 매핑 드롭다운 즉시 활성화
|
||||
setRawResponseError("");
|
||||
setRawResponseLoading(true);
|
||||
setRawResponse(null);
|
||||
try {
|
||||
const result = await ExternalRestApiConnectionAPI.testConnectionById(
|
||||
Number(id),
|
||||
conn.endpoint_path || undefined,
|
||||
);
|
||||
if (result.success) {
|
||||
setRawResponse(result.response_data);
|
||||
// 응답 안에서 배열을 자동 탐색 (dataArrayPath 가 아직 안 박혀도 동작)
|
||||
const findArr = (o: unknown, depth = 0): unknown[] | null => {
|
||||
if (Array.isArray(o)) return o;
|
||||
if (depth >= 4 || typeof o !== "object" || o === null) return null;
|
||||
for (const v of Object.values(o)) {
|
||||
const a = findArr(v, depth + 1);
|
||||
if (a) return a;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const arr = findArr(result.response_data);
|
||||
if (arr && arr.length > 0 && typeof arr[0] === "object" && arr[0] !== null) {
|
||||
const fields = Object.keys(arr[0] as Record<string, unknown>);
|
||||
setFromApiFields(fields);
|
||||
setFromApiData(arr as Record<string, unknown>[]);
|
||||
toast.success(
|
||||
`'${conn.connection_name}' API 호출 완료 — 배열 ${arr.length}건 / 필드 ${fields.length}개 추출`,
|
||||
);
|
||||
} else if (
|
||||
result.response_data &&
|
||||
typeof result.response_data === "object" &&
|
||||
!Array.isArray(result.response_data)
|
||||
) {
|
||||
const fields = Object.keys(result.response_data as Record<string, unknown>);
|
||||
setFromApiFields(fields);
|
||||
setFromApiData([result.response_data as Record<string, unknown>]);
|
||||
toast.success(`'${conn.connection_name}' API 호출 완료 — 필드 ${fields.length}개 추출`);
|
||||
} else {
|
||||
toast.success(`'${conn.connection_name}' API 호출 완료 — 응답을 받았어요`);
|
||||
}
|
||||
} else {
|
||||
const msg = result.message || result.error_details || "API 호출 실패";
|
||||
setRawResponseError(msg);
|
||||
toast.error(`'${conn.connection_name}' API 호출 실패: ${msg}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setRawResponseError(msg);
|
||||
toast.error(`API 호출 중 오류: ${msg}`);
|
||||
} finally {
|
||||
setRawResponseLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 인증 서비스명 목록 로드
|
||||
const loadAuthServiceNames = async () => {
|
||||
try {
|
||||
@@ -206,14 +331,14 @@ export default function BatchManagementNewPage() {
|
||||
// 내부 데이터베이스 선택
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
// 외부 데이터베이스 선택
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
setToTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
@@ -258,12 +383,12 @@ export default function BatchManagementNewPage() {
|
||||
if (connectionValue === "internal") {
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
setFromTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
@@ -409,10 +534,21 @@ export default function BatchManagementNewPage() {
|
||||
|
||||
// 배치 타입별 검증 및 저장
|
||||
if (batchType === "restapi-to-db") {
|
||||
// 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것)
|
||||
const validMappings = mappingList.filter(
|
||||
(m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue),
|
||||
);
|
||||
// 유효한 매핑만 필터링:
|
||||
// api → dbColumn + apiField 둘 다 필요
|
||||
// conditional → dbColumn + apiField (평가 필드) + 최소 1개 룰 또는 default 필요
|
||||
// fixed → dbColumn + fixedValue 둘 다 필요
|
||||
const validMappings = mappingList.filter((m) => {
|
||||
if (!m.dbColumn) return false;
|
||||
if (m.sourceType === "fixed") return !!m.fixedValue;
|
||||
if (m.sourceType === "conditional") {
|
||||
if (!m.apiField) return false;
|
||||
const cfg = m.conditionalConfig;
|
||||
if (!cfg) return false;
|
||||
return cfg.rules.some((r) => r.when || r.then) || !!cfg.default;
|
||||
}
|
||||
return !!m.apiField;
|
||||
});
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
toast.error("최소 하나의 매핑을 설정해주세요.");
|
||||
@@ -427,26 +563,45 @@ export default function BatchManagementNewPage() {
|
||||
|
||||
// 통합 매핑 리스트를 배치 매핑 형태로 변환
|
||||
// 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨
|
||||
const apiMappings = validMappings.map((mapping) => ({
|
||||
from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용
|
||||
from_table_name: fromEndpoint,
|
||||
from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue,
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
||||
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
|
||||
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: mapping.dbColumn,
|
||||
mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const),
|
||||
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
|
||||
}));
|
||||
const apiMappings = validMappings.map((mapping) => {
|
||||
// from_column_name 결정:
|
||||
// fixed → fixedValue 자체가 저장됨
|
||||
// conditional → apiField (평가할 API 필드)
|
||||
// api(direct) → apiField
|
||||
const fromColumnName =
|
||||
mapping.sourceType === "fixed" ? mapping.fixedValue : mapping.apiField;
|
||||
const mappingType: "direct" | "fixed" | "conditional" =
|
||||
mapping.sourceType === "fixed"
|
||||
? "fixed"
|
||||
: mapping.sourceType === "conditional"
|
||||
? "conditional"
|
||||
: "direct";
|
||||
return {
|
||||
from_connection_type: "restapi" as const,
|
||||
from_table_name: fromEndpoint,
|
||||
from_column_name: fromColumnName,
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: authTokenMode === "direct" ? fromApiKey : "",
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined,
|
||||
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
|
||||
to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: mapping.dbColumn,
|
||||
mapping_type: mappingType,
|
||||
fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined,
|
||||
// conditional 일 때만 룰 객체를 함께 전송 — 백엔드가 JSONB 직렬화 처리
|
||||
mapping_config:
|
||||
mapping.sourceType === "conditional" && mapping.conditionalConfig
|
||||
? mapping.conditionalConfig
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
// 실제 API 호출
|
||||
try {
|
||||
@@ -465,7 +620,7 @@ export default function BatchManagementNewPage() {
|
||||
if (result.success) {
|
||||
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
@@ -556,7 +711,7 @@ export default function BatchManagementNewPage() {
|
||||
if (result.success) {
|
||||
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
|
||||
setTimeout(() => {
|
||||
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(result.message || "배치 저장에 실패했습니다.");
|
||||
@@ -573,10 +728,10 @@ export default function BatchManagementNewPage() {
|
||||
toast.error("지원하지 않는 배치 타입입니다.");
|
||||
};
|
||||
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
|
||||
const goBack = () => openTab({ type: "admin", title: "배치 관리", admin_url: "/admin/automaticMng/batchmngList" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
|
||||
<div className="h-full w-full space-y-6 overflow-y-auto p-4 sm:p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -590,49 +745,48 @@ export default function BatchManagementNewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 타입 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
|
||||
batchType === option.value
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 배치 타입 + 기본 정보 — 한 행으로 통합 (xl+ 한 줄, 그 미만은 stack) */}
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-[minmax(28rem,1.4fr)_1fr_1fr_1.5fr]">
|
||||
{/* 모드 토글 2개 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{batchTypeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setBatchType(option.value)}
|
||||
className={`group relative flex items-center gap-2 rounded-lg border p-3 text-left transition-all ${
|
||||
batchType === option.value
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
|
||||
{option.value === "restapi-to-db" ? <Globe className="h-4 w-4" /> : <Database className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-tight">{option.label}</div>
|
||||
<div className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{option.description}</div>
|
||||
</div>
|
||||
{batchType === option.value && <div className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
기본 정보
|
||||
{/* 배치명 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="batchName" className="text-xs">배치명 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="batchName" className="text-xs">배치명 <span className="text-destructive">*</span></Label>
|
||||
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cronSchedule" className="text-xs">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||||
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
{/* 실행 스케줄 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cronSchedule" className="text-xs">실행 스케줄 <span className="text-destructive">*</span></Label>
|
||||
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
{/* 설명 (textarea 한 줄 높이 — 다른 입력과 정렬) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
|
||||
<Input id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="설명 (선택)" className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -659,125 +813,130 @@ export default function BatchManagementNewPage() {
|
||||
{/* REST API 설정 (REST API → DB) */}
|
||||
{batchType === "restapi-to-db" && (
|
||||
<div className="space-y-4">
|
||||
{/* API 서버 URL */}
|
||||
{/* 등록된 연결 선택 — 외부 커넥션 관리에 등록한 REST API 연결을 골라 자동 호출 */}
|
||||
<div>
|
||||
<Label htmlFor="fromApiUrl">API 서버 URL *</Label>
|
||||
<Input
|
||||
id="fromApiUrl"
|
||||
value={fromApiUrl}
|
||||
onChange={(e) => setFromApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 토큰 설정 */}
|
||||
<div>
|
||||
<Label>인증 토큰 (Authorization)</Label>
|
||||
{/* 토큰 설정 방식 선택 */}
|
||||
<div className="mt-2 flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authTokenMode"
|
||||
value="direct"
|
||||
checked={authTokenMode === "direct"}
|
||||
onChange={() => {
|
||||
setAuthTokenMode("direct");
|
||||
setAuthServiceName("");
|
||||
}}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-xs">직접 입력</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authTokenMode"
|
||||
value="db"
|
||||
checked={authTokenMode === "db"}
|
||||
onChange={() => setAuthTokenMode("db")}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-xs">DB에서 선택</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* 직접 입력 모드 */}
|
||||
{authTokenMode === "direct" && (
|
||||
<Input
|
||||
id="fromApiKey"
|
||||
value={fromApiKey}
|
||||
onChange={(e) => setFromApiKey(e.target.value)}
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
{/* DB 선택 모드 */}
|
||||
{authTokenMode === "db" && (
|
||||
<Select
|
||||
value={authServiceName || "none"}
|
||||
onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="서비스명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{authServiceNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{authTokenMode === "direct"
|
||||
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
||||
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 엔드포인트 */}
|
||||
<div>
|
||||
<Label htmlFor="fromEndpoint">엔드포인트 *</Label>
|
||||
<Input
|
||||
id="fromEndpoint"
|
||||
value={fromEndpoint}
|
||||
onChange={(e) => setFromEndpoint(e.target.value)}
|
||||
placeholder="/api/users"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<Label htmlFor="registeredRestApi" className="flex items-center gap-1.5 text-xs">
|
||||
<span>🔗 등록된 연결</span>
|
||||
{rawResponseLoading && (
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRestApiId}
|
||||
onValueChange={applyRegisteredRestApi}
|
||||
>
|
||||
<SelectTrigger id="registeredRestApi" className="h-9 text-sm">
|
||||
<SelectValue placeholder="직접 입력 (등록된 연결 사용 안 함)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="manual">직접 입력 (등록된 연결 사용 안 함)</SelectItem>
|
||||
{registeredRestApis.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{c.connection_name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
[{c.auth_type || "none"}]
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rawResponseError && (
|
||||
<p className="mt-1 text-[11px] text-destructive">{rawResponseError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 데이터 배열 경로 */}
|
||||
{/* API 서버 URL + HTTP 메서드 — 한 행, URL 이 길어 7:3 비율 */}
|
||||
<div className="grid grid-cols-[3fr_1fr] gap-3">
|
||||
<div>
|
||||
<Label htmlFor="fromApiUrl" className="text-xs">API 서버 URL *</Label>
|
||||
<Input
|
||||
id="fromApiUrl"
|
||||
value={fromApiUrl}
|
||||
onChange={(e) => setFromApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">메서드</Label>
|
||||
<Select value={fromApiMethod} onValueChange={(value: any) => setFromApiMethod(value)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 엔드포인트 + 데이터 배열 경로 — 한 행, 50:50 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="fromEndpoint" className="text-xs">엔드포인트 *</Label>
|
||||
<Input
|
||||
id="fromEndpoint"
|
||||
value={fromEndpoint}
|
||||
onChange={(e) => setFromEndpoint(e.target.value)}
|
||||
placeholder="/api/users"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dataArrayPath" className="text-xs">데이터 배열 경로</Label>
|
||||
<Input
|
||||
id="dataArrayPath"
|
||||
value={dataArrayPath}
|
||||
onChange={(e) => setDataArrayPath(e.target.value)}
|
||||
placeholder="resultData (비우면 자동 탐색)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인증 토큰 — 라디오 + 입력을 한 행으로 압축 */}
|
||||
<div>
|
||||
<Label htmlFor="dataArrayPath">데이터 배열 경로</Label>
|
||||
<Input
|
||||
id="dataArrayPath"
|
||||
value={dataArrayPath}
|
||||
onChange={(e) => setDataArrayPath(e.target.value)}
|
||||
placeholder="response (예: data.items, results)"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
||||
<br />
|
||||
예시: response, data.items, result.list
|
||||
</p>
|
||||
<Label className="text-xs">인증 토큰</Label>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex shrink-0 overflow-hidden rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAuthTokenMode("direct"); setAuthServiceName(""); }}
|
||||
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "direct" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
|
||||
>직접 입력</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthTokenMode("db")}
|
||||
className={`px-2.5 py-1.5 text-xs ${authTokenMode === "db" ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
|
||||
>DB에서 선택</button>
|
||||
</div>
|
||||
{authTokenMode === "direct" ? (
|
||||
<Input
|
||||
id="fromApiKey"
|
||||
value={fromApiKey}
|
||||
onChange={(e) => setFromApiKey(e.target.value)}
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
className="h-9 flex-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Select value={authServiceName || "none"} onValueChange={(value) => setAuthServiceName(value === "none" ? "" : value)}>
|
||||
<SelectTrigger className="h-9 flex-1 text-sm">
|
||||
<SelectValue placeholder="서비스명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{authServiceNames.map((name) => (
|
||||
<SelectItem key={name} value={name}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Body (POST/PUT/DELETE용) */}
|
||||
@@ -796,17 +955,20 @@ export default function BatchManagementNewPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 파라미터 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
<Label className="text-base font-medium">API 파라미터 설정</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground">특정 사용자나 조건으로 데이터를 조회할 때 사용합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* API 파라미터 설정 — 기본 접힘. 필요할 때만 펼침 */}
|
||||
<details className="rounded-lg border bg-muted/20 [&[open]>summary>svg]:rotate-90">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 p-3 text-xs font-medium hover:bg-muted/30">
|
||||
<svg className="h-3 w-3 transition-transform" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M4 2l4 4-4 4z" />
|
||||
</svg>
|
||||
API 파라미터 설정
|
||||
<span className="text-[10px] font-normal text-muted-foreground">— 특정 사용자/조건 조회 시</span>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t p-3">
|
||||
<div>
|
||||
<Label>파라미터 타입</Label>
|
||||
<Label className="text-xs">파라미터 타입</Label>
|
||||
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -886,7 +1048,8 @@ export default function BatchManagementNewPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* API 호출 미리보기 정보 */}
|
||||
{fromApiUrl && fromEndpoint && (
|
||||
@@ -1133,7 +1296,10 @@ export default function BatchManagementNewPage() {
|
||||
{/* 1. 커넥션 선택 - 항상 활성화 */}
|
||||
<div>
|
||||
<Label>데이터베이스 커넥션 선택 *</Label>
|
||||
<Select onValueChange={handleToConnectionChange}>
|
||||
<Select
|
||||
value={toConnection ? (toConnection.type === "internal" ? "internal" : String(toConnection.id)) : ""}
|
||||
onValueChange={handleToConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
@@ -1153,7 +1319,7 @@ export default function BatchManagementNewPage() {
|
||||
{/* 2. 테이블 선택 - 커넥션 선택 후 활성화 */}
|
||||
<div className={toTables.length === 0 ? "pointer-events-none opacity-50" : ""}>
|
||||
<Label>테이블 선택 *</Label>
|
||||
<Select onValueChange={handleToTableChange} disabled={toTables.length === 0}>
|
||||
<Select value={toTable} onValueChange={handleToTableChange} disabled={toTables.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={toTables.length === 0 ? "먼저 커넥션을 선택하세요" : "테이블을 선택하세요"}
|
||||
@@ -1477,30 +1643,30 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컬럼 매핑 설정</CardTitle>
|
||||
<CardDescription>DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</CardDescription>
|
||||
<CardHeader className="px-4 pb-2 pt-3">
|
||||
<CardTitle className="text-sm">컬럼 매핑 설정</CardTitle>
|
||||
<CardDescription className="text-[11px]">DB 컬럼에 API 필드 또는 고정값을 매핑합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{/* 왼쪽: 샘플 데이터 */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 flex h-8 items-center">
|
||||
<h4 className="text-sm font-semibold">샘플 데이터 (최대 3개)</h4>
|
||||
<div className="mb-2 flex h-7 items-center">
|
||||
<h4 className="text-xs font-semibold">샘플 데이터 (최대 3개)</h4>
|
||||
</div>
|
||||
{sampleJsonList.length > 0 ? (
|
||||
<div className="bg-muted/30 h-[360px] overflow-y-auto rounded-lg border p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-[300px] overflow-y-auto rounded-lg border p-2">
|
||||
<div className="space-y-1.5">
|
||||
{sampleJsonList.map((json, index) => (
|
||||
<div key={index} className="bg-background rounded border p-2">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap">{json}</pre>
|
||||
<div key={index} className="bg-background rounded border p-1.5">
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap">{json}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[360px] items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
API 데이터 미리보기를 실행하면 샘플 데이터가 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1509,39 +1675,39 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
|
||||
{/* 오른쪽: 매핑 영역 (스크롤) */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 flex h-8 items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">매핑 설정</h4>
|
||||
<Button variant="outline" size="sm" onClick={addMapping} className="h-8 gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="mb-2 flex h-7 items-center justify-between">
|
||||
<h4 className="text-xs font-semibold">매핑 설정</h4>
|
||||
<Button variant="outline" size="sm" onClick={addMapping} className="h-7 gap-1 px-2 text-[11px]">
|
||||
<Plus className="h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappingList.length === 0 ? (
|
||||
<div className="flex h-[360px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
|
||||
<p className="text-muted-foreground text-sm">매핑이 없습니다.</p>
|
||||
<Button variant="link" onClick={addMapping} className="mt-2">
|
||||
<div className="flex h-[300px] flex-col items-center justify-center rounded-lg border border-dashed text-center">
|
||||
<p className="text-muted-foreground text-xs">매핑이 없습니다.</p>
|
||||
<Button variant="link" size="sm" onClick={addMapping} className="mt-1 h-auto text-xs">
|
||||
매핑 추가하기
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
|
||||
<div className="bg-muted/30 h-[300px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||
{mappingList.map((mapping, index) => (
|
||||
<div key={mapping.id} className="bg-background flex items-center gap-2 rounded-lg border p-3">
|
||||
<div key={mapping.id} className="bg-background flex items-center gap-1.5 rounded-lg border p-2">
|
||||
{/* 순서 표시 */}
|
||||
<div className="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
|
||||
<div className="bg-primary/10 text-primary flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* DB 컬럼 선택 (좌측 - TO) */}
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="w-32 shrink-0">
|
||||
<Select
|
||||
value={mapping.dbColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateMapping(mapping.id, { dbColumn: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="DB 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1563,40 +1729,49 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<ArrowLeft className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{/* 소스 타입 선택 */}
|
||||
<div className="w-24 shrink-0">
|
||||
<Select
|
||||
value={mapping.sourceType}
|
||||
onValueChange={(value: "api" | "fixed") =>
|
||||
onValueChange={(value: "api" | "fixed" | "conditional") =>
|
||||
updateMapping(mapping.id, {
|
||||
sourceType: value,
|
||||
apiField: value === "fixed" ? "" : mapping.apiField,
|
||||
fixedValue: value === "api" ? "" : mapping.fixedValue,
|
||||
// 모드 전환 시 입력값 정리
|
||||
apiField:
|
||||
value === "api" || value === "conditional"
|
||||
? mapping.apiField
|
||||
: "",
|
||||
fixedValue: value === "fixed" ? mapping.fixedValue : "",
|
||||
conditionalConfig:
|
||||
value === "conditional"
|
||||
? mapping.conditionalConfig || emptyConditionalConfig()
|
||||
: mapping.conditionalConfig,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="api">API 필드</SelectItem>
|
||||
<SelectItem value="fixed">고정값</SelectItem>
|
||||
<SelectItem value="conditional">조건 변환</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
|
||||
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{mapping.sourceType === "api" ? (
|
||||
{mapping.sourceType === "api" && (
|
||||
<Select
|
||||
value={mapping.apiField || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateMapping(mapping.id, { apiField: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="API 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1606,7 +1781,7 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{field}</span>
|
||||
{firstSample && firstSample[field] !== undefined && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
(예: {String(firstSample[field]).substring(0, 15)}
|
||||
{String(firstSample[field]).length > 15 ? "..." : ""})
|
||||
</span>
|
||||
@@ -1616,12 +1791,26 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
)}
|
||||
{mapping.sourceType === "fixed" && (
|
||||
<Input
|
||||
value={mapping.fixedValue}
|
||||
onChange={(e) => updateMapping(mapping.id, { fixedValue: e.target.value })}
|
||||
placeholder="고정값 입력"
|
||||
className="h-9"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
{mapping.sourceType === "conditional" && (
|
||||
<ConditionalEditor
|
||||
evaluateField={mapping.apiField}
|
||||
fieldOptions={fromApiFields}
|
||||
config={mapping.conditionalConfig || emptyConditionalConfig()}
|
||||
onEvaluateFieldChange={(v) =>
|
||||
updateMapping(mapping.id, { apiField: v })
|
||||
}
|
||||
onConfigChange={(cfg) =>
|
||||
updateMapping(mapping.id, { conditionalConfig: cfg })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1631,9 +1820,9 @@ const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMapping(mapping.id)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 shrink-0"
|
||||
className="text-muted-foreground hover:text-destructive h-6 w-6 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반)
|
||||
// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD
|
||||
// - URL ?id=<template_id> 로 바로 진입
|
||||
// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드
|
||||
// - InvyoneStudio 는 template_id 를 통해 templates API 로 저장/로드
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import InvyoneStudio from "@/components/screen/InvyoneStudio";
|
||||
import type { ScreenDefinition } from "@/types/screen";
|
||||
import { getTemplateList, deleteTemplate } from "@/lib/api/template";
|
||||
import { createTemplate } from "@/lib/utils/templateAdapter";
|
||||
@@ -442,7 +442,7 @@ function BuilderInner() {
|
||||
|
||||
return (
|
||||
<div className="ide-builder h-[calc(100vh-4rem)] w-full overflow-hidden bg-background">
|
||||
<ScreenDesigner
|
||||
<InvyoneStudio
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={handleBackToList}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react";
|
||||
|
||||
// 탭별 컴포넌트
|
||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||
import AutoFillTab from "./tabs/AutoFillTab";
|
||||
import HierarchyTab from "./tabs/HierarchyTab";
|
||||
import ConditionTab from "./tabs/ConditionTab";
|
||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||
import HierarchyColumnTab from "./tabs/HierarchyColumnTab";
|
||||
|
||||
export default function CascadingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("relations");
|
||||
|
||||
// URL 쿼리 파라미터에서 탭 설정
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 탭 변경 시 URL 업데이트
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
router.replace(url.pathname + url.search);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="relations" className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||
<span className="sm:hidden">연쇄</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hierarchy" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다단계 계층</span>
|
||||
<span className="sm:hidden">계층</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="condition" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">조건부 필터</span>
|
||||
<span className="sm:hidden">조건</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="autofill" className="gap-2">
|
||||
<FormInput className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">자동 입력</span>
|
||||
<span className="sm:hidden">자동</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="exclusion" className="gap-2">
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">상호 배제</span>
|
||||
<span className="sm:hidden">배제</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="category-value" className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">카테고리값</span>
|
||||
<span className="sm:hidden">카테고리</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="mt-6">
|
||||
<TabsContent value="relations">
|
||||
<CascadingRelationsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hierarchy">
|
||||
<HierarchyTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="condition">
|
||||
<ConditionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="autofill">
|
||||
<AutoFillTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="exclusion">
|
||||
<MutualExclusionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="category-value">
|
||||
<CategoryValueCascadingTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
X,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface TableColumn {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
export default function AutoFillTab() {
|
||||
// 목록 상태
|
||||
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||
|
||||
// 테이블/컬럼 목록
|
||||
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터
|
||||
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.getGroups();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTableList = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTableList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
const loadColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setMasterColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setMasterColumns(
|
||||
response.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||
dataType: col.dataType || col.data_type,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setMasterColumns([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTableList();
|
||||
}, [loadGroups, loadTableList]);
|
||||
|
||||
// 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (formData.masterTable) {
|
||||
loadColumns(formData.masterTable);
|
||||
}
|
||||
}, [formData.masterTable, loadColumns]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredGroups = groups.filter(
|
||||
(g) =>
|
||||
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setFormData({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
setMappings([]);
|
||||
setMasterColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||
setEditingGroup(group);
|
||||
|
||||
// 상세 정보 로드
|
||||
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
const detail = detailResponse.data;
|
||||
|
||||
// 컬럼 먼저 로드
|
||||
if (detail.masterTable) {
|
||||
await loadColumns(detail.masterTable);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
groupCode: detail.groupCode,
|
||||
groupName: detail.groupName,
|
||||
description: detail.description || "",
|
||||
masterTable: detail.masterTable,
|
||||
masterValueColumn: detail.masterValueColumn,
|
||||
masterLabelColumn: detail.masterLabelColumn || "",
|
||||
isActive: detail.isActive || "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||
sourceColumn: m.source_column || m.sourceColumn,
|
||||
targetField: m.target_field || m.targetField,
|
||||
targetLabel: m.target_label || m.targetLabel || "",
|
||||
isEditable: m.is_editable || m.isEditable || "Y",
|
||||
isRequired: m.is_required || m.isRequired || "N",
|
||||
defaultValue: m.default_value || m.defaultValue || "",
|
||||
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||
}));
|
||||
setMappings(convertedMappings);
|
||||
}
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (groupCode: string) => {
|
||||
setDeletingGroupCode(groupCode);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupCode) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||||
if (response.success) {
|
||||
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroupCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saveData = {
|
||||
...formData,
|
||||
mappings,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (editingGroup) {
|
||||
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||
} else {
|
||||
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 추가
|
||||
const handleAddMapping = () => {
|
||||
setMappings([
|
||||
...mappings,
|
||||
{
|
||||
sourceColumn: "",
|
||||
targetField: "",
|
||||
targetLabel: "",
|
||||
isEditable: "Y",
|
||||
isRequired: "N",
|
||||
defaultValue: "",
|
||||
sortOrder: mappings.length + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
setMappings(mappings.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 매핑 수정
|
||||
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||
const updated = [...mappings];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 검색 및 액션 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadGroups}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>자동 입력 그룹</CardTitle>
|
||||
<CardDescription>
|
||||
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">로딩 중...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>그룹 코드</TableHead>
|
||||
<TableHead>그룹명</TableHead>
|
||||
<TableHead>마스터 테이블</TableHead>
|
||||
<TableHead>매핑 수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredGroups.map((group) => (
|
||||
<TableRow key={group.groupCode}>
|
||||
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input
|
||||
value={formData.groupName}
|
||||
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||
placeholder="예: 고객사 정보 자동입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={formData.isActive === "Y"}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||
/>
|
||||
<Label>활성화</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 마스터 테이블 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>마스터 테이블 *</Label>
|
||||
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboOpen}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
{formData.masterTable
|
||||
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||
formData.masterTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
masterTable: table.tableName,
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
});
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && table.displayName !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.masterValueColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.masterLabelColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||
|
||||
{/* 소스 컬럼 */}
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={mapping.sourceColumn}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||
placeholder="타겟 필드명 (예: contact_name)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 라벨 */}
|
||||
<div className="w-28">
|
||||
<Input
|
||||
value={mapping.targetLabel || ""}
|
||||
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`editable-${index}`}
|
||||
checked={mapping.isEditable === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||
수정
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={mapping.isRequired === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user