Compare commits
176 Commits
f3c3087393
...
hjjeong
| Author | SHA1 | Date | |
|---|---|---|---|
| f8ef029e10 | |||
| 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 | |||
| aeddd7dc2a | |||
| 5fdd1c67b1 | |||
| 54a8f97f78 | |||
| 0199d1624b | |||
| b3f955d97d | |||
| ae899a3589 | |||
| 43b0455364 | |||
| b752de23a1 | |||
| 574319811c | |||
| 8f92fb2368 | |||
| 6fcb101f59 | |||
| 47eed68072 | |||
| d8f606ab00 | |||
| e8f517ed18 | |||
| d02bc38f6c | |||
| 0c9e22a679 | |||
| 570b3267ab | |||
| 0bba1836fb | |||
| f70719aecb | |||
| 3ab7deb196 | |||
| d592547242 | |||
| 6f8461a533 | |||
| 17172cf9b3 | |||
| f9a9c67891 | |||
| f31a7f852f | |||
| 2675c82904 | |||
| dce665caea | |||
| 5c0dca004b | |||
| acbd61e25a | |||
| dc77c07cc4 | |||
| 6d5ca2f23a | |||
| 1ba310236c | |||
| 0d5d1fe10d | |||
| c3e04adb23 | |||
| 7bd08dcf9d | |||
| 3dbc2107d8 | |||
| 3eeb0764bf | |||
| 4a8413000b | |||
| 7706403caa | |||
| 3c24956efd | |||
| a7683d4d0e | |||
| c3e5d7fc1b | |||
| 33a245e4e8 | |||
| c4a62b7e35 | |||
| 081feff51f | |||
| 90035dd5c6 | |||
| ffa6799053 | |||
| 7315603f0f | |||
| 8bdc9a958f | |||
| 6561aad7ef | |||
| af23fd0316 | |||
| 6a9fc06f0e | |||
| c0bd420c66 | |||
| 6ab7c3e780 | |||
| 4a83bfc8e8 | |||
| 6a7d261d23 | |||
| e4856dcae5 | |||
| baffd6affb | |||
| a5bbd1eb7c | |||
| 1bd0fd8b80 | |||
| 4b97448467 | |||
| 63279296f8 | |||
| 1b9604f66e | |||
| b16439098a | |||
| 36d93d91cf | |||
| 77bd8cab75 | |||
| f0781022de | |||
| 0a8be2df1e | |||
| 9c658ffd36 | |||
| 68c1cb5b14 | |||
| 74ddc4936f | |||
| 3d220373d8 | |||
| 3d5b2a4911 | |||
| d2b77d348b | |||
| 0e895a90fa | |||
| 59f5cf22f0 | |||
| c4631efbd2 | |||
| 84b9060e4e | |||
| 3280be8bd4 | |||
| b782bb298f | |||
| 798fdf18b3 | |||
| 8eb4e8c9a2 | |||
| 48d74170fc | |||
| e8ba13f52b | |||
| 5d2283cb47 | |||
| de0bfc1af4 | |||
| 205428533d | |||
| b5a60d1c8b | |||
| 42f7ae35db | |||
| 7d9ec39b5d | |||
| 4d19c31440 | |||
| a41f99c579 | |||
| cdc55dfd48 | |||
| 280e25a4df | |||
| 568eb14503 | |||
| a3c74f926c | |||
| 36d6ad508d | |||
| a6be4f2efe | |||
| 383b837a60 | |||
| e16fb16987 |
@@ -2,6 +2,10 @@
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
# direnv (per-developer JAVA_HOME / shell env)
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
|
||||
# planning, autopilot state, agent transcript, project memory 등 포함
|
||||
.omc/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
21
|
||||
@@ -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), "사용자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -295,7 +305,8 @@ public class AdminController {
|
||||
@PostMapping("/users/reset-password")
|
||||
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
|
||||
String userId = (String) body.get("user_id");
|
||||
adminService.resetUserPassword(userId);
|
||||
String newPassword = (String) body.get("new_password");
|
||||
adminService.resetUserPassword(userId, newPassword);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -190,9 +190,11 @@ public class ApprovalController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getRequests(
|
||||
@RequestParam Map<String, Object> params,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
params.put("effective_user_ids", effectiveUserIds);
|
||||
return ResponseEntity.ok(ApiResponse.success(approvalService.getRequests(params)));
|
||||
}
|
||||
|
||||
@@ -277,10 +279,12 @@ public class ApprovalController {
|
||||
@GetMapping("/my-pending")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyPendingLines(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_id", userId);
|
||||
params.put("effective_user_ids", effectiveUserIds);
|
||||
return ResponseEntity.ok(ApiResponse.success(approvalService.getMyPendingLines(params)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,42 +18,64 @@ 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}");
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (회사별)
|
||||
* GET /api/departments/companies/{companyCode}/departments
|
||||
* 부서 목록 조회 (회사별).
|
||||
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||
* ?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) {
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@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);
|
||||
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 상세 조회
|
||||
* GET /api/departments/{deptCode}
|
||||
* 부서 상세 조회.
|
||||
* - 기본: active 부서만 (DELETED_AT IS NULL)
|
||||
* - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용)
|
||||
* - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체
|
||||
* GET /api/departments/{deptCode}[?include_deleted=true]
|
||||
*/
|
||||
@GetMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getDepartment(
|
||||
@PathVariable String deptCode) {
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||
|
||||
Map<String, Object> department = departmentService.getDepartment(deptCode);
|
||||
Map<String, Object> department = includeDeleted
|
||||
? departmentService.getDepartmentIncludingDeleted(deptCode)
|
||||
: departmentService.getDepartment(deptCode);
|
||||
if (department == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(department, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 생성
|
||||
* 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(
|
||||
@@ -82,62 +104,283 @@ 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(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제
|
||||
* 일괄 미리보기 (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() 마킹으로 변경
|
||||
* - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가
|
||||
* - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남
|
||||
* DELETE /api/departments/{deptCode}
|
||||
*/
|
||||
@DeleteMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDepartment(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDepartment(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
int memberCount = departmentService.deleteDepartment(deptCode);
|
||||
if (memberCount == -1) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
int result = departmentService.deleteDepartment(deptCode);
|
||||
if (result == -1) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
String message = memberCount > 0
|
||||
? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)"
|
||||
: "부서가 삭제되었습니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(null, message));
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("soft_deleted", true);
|
||||
data.put("dept_code", deptCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)"));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 복구 (V1 slim scope).
|
||||
* - DELETED_AT = NULL 로 되돌림
|
||||
* - 부모도 deleted 상태면 차단
|
||||
* POST /api/departments/{deptCode}/restore
|
||||
*/
|
||||
@PostMapping("/{deptCode}/restore")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> restoreDepartment(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
DepartmentService.RestoreResult result;
|
||||
try {
|
||||
result = departmentService.restoreDepartment(deptCode);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
switch (result) {
|
||||
case OK:
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("dept_code", deptCode);
|
||||
data.put("restored", true);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다."));
|
||||
case NOT_FOUND:
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
case NOT_DELETED:
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다."));
|
||||
case PARENT_DELETED:
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요."));
|
||||
default:
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
* GET /api/departments/{deptCode}/members
|
||||
*/
|
||||
@GetMapping("/{deptCode}/members")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
|
||||
@PathVariable String deptCode) {
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
|
||||
@@ -150,8 +393,17 @@ public class DepartmentController {
|
||||
@GetMapping("/companies/{companyCode}/users/search")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
|
||||
}
|
||||
|
||||
if (search == null || search.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
|
||||
}
|
||||
@@ -168,15 +420,27 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Void>> addDeptMember(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
|
||||
Object userIdObj = body.get("user_id");
|
||||
if (userIdObj == null) userIdObj = body.get("user_id");
|
||||
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
|
||||
if (userIdObj == null || userIdObj.toString().isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
|
||||
}
|
||||
@@ -200,12 +464,25 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
|
||||
@PathVariable String deptCode,
|
||||
@PathVariable String userId,
|
||||
@RequestAttribute("role") String role) {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
boolean removed = departmentService.removeDeptMember(deptCode, userId);
|
||||
if (!removed) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다."));
|
||||
@@ -221,14 +498,31 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
|
||||
@PathVariable String deptCode,
|
||||
@PathVariable String userId,
|
||||
@RequestAttribute("role") String role) {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
departmentService.setPrimaryDept(deptCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
departmentService.setPrimaryDept(deptCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -242,4 +536,16 @@ public class DepartmentController {
|
||||
private boolean isSuperAdmin(String companyCodeOrRole) {
|
||||
return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능.
|
||||
* 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만.
|
||||
*/
|
||||
private boolean canAccessDept(Map<String, Object> dept, String userCompanyCode) {
|
||||
if (dept == null) return false;
|
||||
if (isSuperAdmin(userCompanyCode)) return true;
|
||||
String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null;
|
||||
if (deptCompanyCode == null) return false;
|
||||
return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("공통 코드 데이터 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.FavoritesService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/favorites")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FavoritesController {
|
||||
|
||||
private final FavoritesService favoritesService;
|
||||
|
||||
/**
|
||||
* GET /api/favorites/menus
|
||||
* 로그인 사용자의 즐겨찾기 메뉴 목록.
|
||||
*/
|
||||
@GetMapping("/menus")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyFavorites(
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
favoritesService.getFavoriteMenuList(params),
|
||||
"즐겨찾기 메뉴 조회 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites/menus
|
||||
* 즐겨찾기 추가. body: { menu_objid, sort_order? }
|
||||
*/
|
||||
@PostMapping("/menus")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addFavorite(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
Object menuObjid = body.get("menu_objid");
|
||||
if (menuObjid == null || String.valueOf(menuObjid).isBlank()) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error("menu_objid 필수입니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("menu_objid", String.valueOf(menuObjid));
|
||||
params.put("sort_order", body.get("sort_order"));
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
favoritesService.insertFavorite(params),
|
||||
"즐겨찾기 추가 성공"));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites/menus/{menuObjid}
|
||||
* 즐겨찾기 제거.
|
||||
*/
|
||||
@DeleteMapping("/menus/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> removeFavorite(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@PathVariable String menuObjid) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("menu_objid", menuObjid);
|
||||
int affected = favoritesService.deleteFavorite(params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("deleted", affected);
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "즐겨찾기 제거 성공"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.SubstituteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 관리 API.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T4)
|
||||
*
|
||||
* 정책:
|
||||
* - GET /mine 은 본인 read-only (누구나 가능)
|
||||
* - 나머지는 관리자(ADMIN/SUPER_ADMIN) 만 — Service 의 requireAdmin 이 2차 방어
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/substitutes")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SubstituteController {
|
||||
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 조회 — 관리자
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getList(
|
||||
@RequestParam Map<String, Object> params,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("role", role);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteList(params)));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getOne(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("관리자만 조회할 수 있습니다."));
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("substitute_id", substituteId);
|
||||
params.put("company_code", companyCode);
|
||||
try {
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteInfo(params)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 본인 조회 — ProfileModal read-only
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/mine")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getMine(
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params)));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 변경 — 관리자
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
body.put("company_code", companyCode);
|
||||
body.put("role", role);
|
||||
body.put("created_by", userId);
|
||||
try {
|
||||
Map<String, Object> created = substituteService.insertSubstitute(body);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(created, "대무자가 지정되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 등록 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 등록 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> update(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
body.put("substitute_id", substituteId);
|
||||
body.put("company_code", companyCode);
|
||||
body.put("role", role);
|
||||
body.put("updated_by", userId);
|
||||
try {
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(substituteService.updateSubstitute(body), "대무 설정이 수정되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 수정 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 수정 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> delete(
|
||||
@PathVariable("id") Long substituteId,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("substitute_id", substituteId);
|
||||
params.put("company_code", companyCode);
|
||||
params.put("role", role);
|
||||
try {
|
||||
substituteService.deleteSubstitute(params);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "대무 설정이 해지되었습니다."));
|
||||
} catch (AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("대무자 해지 오류", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("대무자 해지 중 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 사전 검증 — UI 가 등록 직전 호출
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/check-overlap")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkOverlap(
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
body.put("company_code", companyCode);
|
||||
int cnt = substituteService.checkOverlap(body);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("overlap", cnt > 0);
|
||||
result.put("count", cnt);
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import com.erp.tenant.TenantDataSourceFactory;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
import com.erp.tenant.TenantRoutingDataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 어드민 14개 메뉴 전사 합산용 fan-out 집계기.
|
||||
*
|
||||
* 호출자(컨트롤러)는 {@link #fanOut(String, Map)} 에 cross-tenant 전용 mapper id 와
|
||||
* 파라미터를 넘긴다. Aggregator 가 메타 DB 에서 활성 회사 목록을 가져온 뒤,
|
||||
* 회사마다 {@link DbContextHolder} 를 잠깐 그 회사 DB 로 바꿔 같은 SELECT 를 돌리고,
|
||||
* 모든 응답 행에 {@code company_code} 를 박아 머지한다.
|
||||
*
|
||||
* 핵심 원칙 (설계서 §3, §5.3):
|
||||
* - 한 회사 실패해도 전체는 진행 (fail-open). 실패 카운트만 누적.
|
||||
* - 모든 행에 {@code company_code} 추가 (응답 측에서 회사 필터/그룹 가능하도록).
|
||||
* - SELECT 만. UPDATE/DELETE 는 회사 도메인 컨텍스트로 위임.
|
||||
* - 풀 정책 무변경 — 회사 풀 {@code minIdle=0} 그대로 유지.
|
||||
*
|
||||
* 1차 구현은 직렬·캐시 OFF (설계서 §7.2 — N≤20 까진 충분).
|
||||
*
|
||||
* @see com.erp.crosstenant.CrossTenantContext
|
||||
* @see com.erp.tenant.TenantRoutingDataSource
|
||||
* @see com.erp.provisioning.CompanyStatsService // raw JDBC 1세대 패턴 (참고용)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantAggregator {
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
|
||||
/** Aggregator 결과 봉투. 컨트롤러는 이걸 그대로 ApiResponse.data 로 감싸 반환. */
|
||||
public static class Result {
|
||||
public final List<Map<String, Object>> rows;
|
||||
public final int companies_queried;
|
||||
public final int companies_failed;
|
||||
public final List<String> failed_company_codes;
|
||||
public final List<String> truncated_company_codes;
|
||||
|
||||
public Result(List<Map<String, Object>> rows,
|
||||
int companies_queried,
|
||||
int companies_failed,
|
||||
List<String> failed_company_codes,
|
||||
List<String> truncated_company_codes) {
|
||||
this.rows = rows;
|
||||
this.companies_queried = companies_queried;
|
||||
this.companies_failed = companies_failed;
|
||||
this.failed_company_codes = failed_company_codes;
|
||||
this.truncated_company_codes = truncated_company_codes;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getRows() { return rows; }
|
||||
public int getCompanies_queried() { return companies_queried; }
|
||||
public int getCompanies_failed() { return companies_failed; }
|
||||
public List<String> getFailed_company_codes() { return failed_company_codes; }
|
||||
public List<String> getTruncated_company_codes() { return truncated_company_codes; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out (cap 없음, 메타 포함 X).
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params) {
|
||||
return fanOut(mapperId, params, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out + 회사당 cap (메타 포함 X).
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit) {
|
||||
return fanOut(mapperId, params, perCompanyLimit, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out + 회사당 cap + 옵션으로 메타 DB 도 같이 조회.
|
||||
* 호출자는 미리 {@link CrossTenantContext#requireSuperAdmin}, {@link CrossTenantContext#requireMetaContext}
|
||||
* 통과를 확인해야 한다 (Aggregator 자체는 권한 가드 안 함 — 단일 책임).
|
||||
*
|
||||
* cap 동작:
|
||||
* - mapper 가 {@code LIMIT #{per_company_limit_plus_one}} 으로 cap+1 만큼 가져옴.
|
||||
* - Aggregator 가 회사별로 rows.size() > cap 인지 검사 → 초과면 잘라 cap 까지만 반환 + truncated 마킹.
|
||||
*
|
||||
* 메타 포함 (includeMeta=true):
|
||||
* - 회사 fan-out 전에 메타 DB 에서 같은 mapper 한 번 더 실행.
|
||||
* - 메타 행에는 {@code company_code='*'} 박힘 (시스템/공통 사용자 표시 — 기존 회사관리 화면 컨벤션과 동일).
|
||||
* - 사용자관리처럼 "SUPER_ADMIN 도 관리 대상"인 도메인에서만 사용. roles/batches 등은 메타에 의미있는 데이터 없으니 false.
|
||||
*
|
||||
* @param mapperId cross-tenant 전용 mapper id
|
||||
* @param params SQL 바인딩 파라미터
|
||||
* @param perCompanyLimit 회사당 cap. null 이면 cap 없음
|
||||
* @param includeMeta true 면 메타 DB 도 한 번 조회해서 결과 앞에 prepend (company_code='*')
|
||||
* @return fan-out 결과
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit, boolean includeMeta) {
|
||||
// 호출 직전 컨텍스트는 META 여야 한다 (CrossTenantContext.requireMetaContext 통과 후라고 가정).
|
||||
List<Map<String, Object>> companies = sqlSession.selectList("provisioning.listActiveCompanies");
|
||||
|
||||
List<Map<String, Object>> mergedRows = new ArrayList<>();
|
||||
List<String> failed = new ArrayList<>();
|
||||
List<String> truncated = new ArrayList<>();
|
||||
int queried = 0;
|
||||
|
||||
// includeMeta=true 면 메타 DB 결과를 먼저 추가 (company_code='*')
|
||||
// — 회사 fan-out 들어가기 전에 META 컨텍스트 그대로에서 실행.
|
||||
if (includeMeta) {
|
||||
try {
|
||||
List<Map<String, Object>> metaRows = params == null
|
||||
? sqlSession.selectList(mapperId)
|
||||
: sqlSession.selectList(mapperId, params);
|
||||
if (perCompanyLimit != null && metaRows.size() > perCompanyLimit) {
|
||||
metaRows = new ArrayList<>(metaRows.subList(0, perCompanyLimit));
|
||||
truncated.add("*");
|
||||
}
|
||||
for (Map<String, Object> r : metaRows) {
|
||||
r.put("company_code", "*");
|
||||
mergedRows.add(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[CrossTenant] meta query mapper={} failed: {}", mapperId, e.getMessage());
|
||||
failed.add("*");
|
||||
}
|
||||
}
|
||||
|
||||
for (Map<String, Object> c : companies) {
|
||||
String companyCode = (String) c.get("company_code");
|
||||
String dbName = (String) c.get("db_name");
|
||||
queried++;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> rows = queryOne(dbName, mapperId, params);
|
||||
|
||||
// cap 적용 — mapper 가 cap+1 만큼 가져왔으면 cap 으로 잘라 truncated 마킹
|
||||
if (perCompanyLimit != null && rows.size() > perCompanyLimit) {
|
||||
rows = new ArrayList<>(rows.subList(0, perCompanyLimit));
|
||||
truncated.add(companyCode);
|
||||
}
|
||||
|
||||
for (Map<String, Object> r : rows) {
|
||||
// company_code 강제 주입 — 응답 행이 어느 회사 것인지 명시.
|
||||
// mapper 내부에서 박은 값이 있더라도 메타 DB 라우팅 정보로 덮어씀.
|
||||
r.put("company_code", companyCode);
|
||||
mergedRows.add(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[CrossTenant] mapper={} failed for company={} db={} : {}",
|
||||
mapperId, companyCode, dbName, e.getMessage());
|
||||
failed.add(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
return new Result(mergedRows, queried, failed.size(), failed, truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 회사 DB 로 컨텍스트 잠깐 바꿔 SELECT 1번.
|
||||
* finally 에서 반드시 prev 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅됨.
|
||||
*
|
||||
* ★ 핵심: TenantRoutingDataSource 의 routing map 에 회사 DB 풀이 등록돼있지 않으면
|
||||
* META 로 fallback 됨. SubdomainResolverFilter 는 회사 도메인 진입 시에만 풀을 등록하므로,
|
||||
* SUPER_ADMIN 이 admin 도메인에서 호출하는 cross-tenant 경로에선 풀이 안 깔려 있다.
|
||||
* 따라서 매 호출마다 ensureTenantPool 로 lazy 생성 (이미 있으면 no-op).
|
||||
*/
|
||||
private List<Map<String, Object>> queryOne(String dbName, String mapperId, Map<String, Object> params) {
|
||||
ensureTenantPool(dbName);
|
||||
String prev = DbContextHolder.get();
|
||||
try {
|
||||
DbContextHolder.set(dbName);
|
||||
return params == null
|
||||
? sqlSession.selectList(mapperId)
|
||||
: sqlSession.selectList(mapperId, params);
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
DbContextHolder.clear();
|
||||
} else {
|
||||
DbContextHolder.set(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임.
|
||||
* SubdomainResolverFilter.ensureTenantPool 과 동일한 패턴.
|
||||
*/
|
||||
private void ensureTenantPool(String dbName) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
synchronized (routingDataSource) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
HikariDataSource ds = TenantDataSourceFactory.createTenant(
|
||||
tenantDbSettings.buildJdbcUrl(dbName),
|
||||
tenantDbSettings.username(),
|
||||
tenantDbSettings.password(),
|
||||
dbName);
|
||||
routingDataSource.addTenant(dbName, ds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase A 스모크 테스트 전용 — Aggregator 우회하고 메타 DB 활성 회사 목록만 반환.
|
||||
* 컨트롤러가 가드 통과 검증 + mapper 등록 확인용으로 호출.
|
||||
*/
|
||||
public List<Map<String, Object>> listActiveCompaniesForSmokeTest() {
|
||||
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listActiveCompanies");
|
||||
// 응답 키 순서 안정화 (Map 직렬화 시 순서 보장 위해 LinkedHashMap 으로 복사)
|
||||
List<Map<String, Object>> out = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> r : rows) {
|
||||
Map<String, Object> ordered = new LinkedHashMap<>();
|
||||
ordered.put("company_code", r.get("company_code"));
|
||||
ordered.put("company_name", r.get("company_name"));
|
||||
ordered.put("db_name", r.get("db_name"));
|
||||
out.add(ordered);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 어드민 엔드포인트 진입 가드.
|
||||
*
|
||||
* 정적 헬퍼 두 개. 컨트롤러는 이 둘을 호출해 boolean 으로 검사 후
|
||||
* 명시적으로 {@link org.springframework.http.ResponseEntity} 반환한다.
|
||||
* 예외 throw 방식을 안 쓰는 이유 — {@link com.erp.config.GlobalExceptionHandler} 의
|
||||
* catch-all 핸들러가 모든 예외를 500 으로 변환하므로, 가드 결과를 정확한 status code 로
|
||||
* 내려주려면 컨트롤러가 직접 결정해야 함.
|
||||
*
|
||||
* SecurityConfig 단계에서 매처로 가두지 않는 이유는 기존 95개 컨트롤러가
|
||||
* permitAll 로 동작 중이기 때문 ({@code SecurityConfig} L52~56 코멘트 참조).
|
||||
*
|
||||
* @see com.erp.crosstenant.CrossTenantAggregator
|
||||
* @see com.erp.tenant.DbContextHolder
|
||||
*/
|
||||
public final class CrossTenantContext {
|
||||
|
||||
public static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
|
||||
|
||||
private CrossTenantContext() {}
|
||||
|
||||
/**
|
||||
* JWT 가 세팅한 role attribute 가 SUPER_ADMIN 인지.
|
||||
* JwtAuthenticationFilter 가 토큰 검증 후 {@code request.setAttribute("role", userType)} 박음.
|
||||
* 토큰 없거나 role 미스매치면 false.
|
||||
*/
|
||||
public static boolean isSuperAdmin(HttpServletRequest request) {
|
||||
Object role = request.getAttribute("role");
|
||||
return ROLE_SUPER_ADMIN.equals(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 요청이 META DB 컨텍스트인지.
|
||||
* SubdomainResolverFilter 가 admin.invyone.com / 메인 도메인일 때 setMeta() 박음.
|
||||
* 회사 도메인 (qnc.invyone.com 등) 에서 호출되면 false.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant 어드민 합산 엔드포인트.
|
||||
*
|
||||
* 모든 엔드포인트는 진입 시 두 가드를 통과해야 한다:
|
||||
* 1. JWT role == "SUPER_ADMIN" → 미통과 시 403
|
||||
* 2. 현재 컨텍스트 == META DB → 미통과 시 400
|
||||
* 통과 후 {@link CrossTenantAggregator#fanOut} 으로 회사 N 개에 fan-out.
|
||||
*
|
||||
* Phase A (2026-04-27): 인프라만. 스모크 테스트용 {@code /_active-companies} 엔드포인트 1개.
|
||||
* Phase B 부터 {@code /users}, {@code /menus} ... 14개 메뉴 fan-out 엔드포인트 추가 예정.
|
||||
*
|
||||
* 라우팅 규약: admin.invyone.com 또는 메인 도메인(메타 컨텍스트) 에서만 호출 가능.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantController {
|
||||
|
||||
/**
|
||||
* 회사당 cap 디폴트. cross-tenant 는 "전사 둘러보기" 용이라 정확한 페이지네이션 불필요 —
|
||||
* 200건 넘으면 검색으로 좁히거나 회사 도메인 단일 모드로 전환하는 게 본 설계의 의도.
|
||||
* 호출자가 ?per_company_limit= 으로 override 가능.
|
||||
*/
|
||||
private static final int DEFAULT_PER_COMPANY_LIMIT = 200;
|
||||
private static final int MAX_PER_COMPANY_LIMIT = 2000; // 안전 가드
|
||||
|
||||
private final CrossTenantAggregator aggregator;
|
||||
|
||||
/**
|
||||
* Phase A 스모크 테스트 엔드포인트.
|
||||
*
|
||||
* 가드 두 개 통과 후 메타 DB 의 {@code COMPANY_MNG} 에서 활성 회사 목록 반환.
|
||||
* Aggregator 의 fan-out 자체는 호출하지 않음 — Phase B 에서 첫 mapper (listUsers) 추가 시 활성화.
|
||||
*
|
||||
* 검증 항목:
|
||||
* - 컨트롤러 라우팅 정상
|
||||
* - SUPER_ADMIN 가드 (다른 role 이면 403)
|
||||
* - META 컨텍스트 가드 (회사 도메인이면 400)
|
||||
* - {@code provisioning.listActiveCompanies} mapper 등록 확인
|
||||
*/
|
||||
@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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = aggregator.listActiveCompaniesForSmokeTest();
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rows", rows);
|
||||
data.put("total", rows.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "Phase A smoke test ok"));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/users
|
||||
*
|
||||
* 활성 회사 N 개에 fan-out 으로 사용자 목록 합산.
|
||||
* 응답 행마다 {@code company_code} 박혀있어 화면 측에서 회사 컬럼/필터 가능.
|
||||
*
|
||||
* 지원 파라미터: search, status, dept_code (단일 회사 화면과 동일).
|
||||
* 페이지네이션은 1차 구현에선 비지원 — 회사당 전체 반환 후 클라이언트에서 페이지네이션 (설계서 §9.3).
|
||||
*
|
||||
* 회사 한 곳이 실패해도 나머지는 반환 (실패 격리). 응답에 {@code companies_failed} +
|
||||
* 헤더 {@code X-CrossTenant-Failed} 로 어떤 회사가 빠졌는지 명시.
|
||||
*/
|
||||
@GetMapping("/users")
|
||||
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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
|
||||
// search 파라미터 ILIKE 패턴화 — admin.selectUserList 와 동일한 변환 규칙 (양쪽 % 감쌈)
|
||||
Map<String, Object> params = new HashMap<>(queryParams);
|
||||
Object rawSearch = params.get("search");
|
||||
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
|
||||
params.put("search", "%" + rawSearch + "%");
|
||||
}
|
||||
|
||||
int perCompanyLimit = resolvePerCompanyLimit(params);
|
||||
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
|
||||
|
||||
// ★ /users 는 includeMeta=true — 메타 DB 의 SUPER_ADMIN 들도 함께 반환 (company_code='*').
|
||||
// SUPER_ADMIN 자기 자신들도 어디선가 관리되어야 한다는 요구. roles/batches/lang-keys 는 메타에 의미있는 데이터 없으니 false.
|
||||
CrossTenantAggregator.Result result = aggregator.fanOut(
|
||||
"admin-cross-tenant.listUsers", params, perCompanyLimit, true);
|
||||
|
||||
return buildResponse(result, perCompanyLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/roles
|
||||
*
|
||||
* 활성 회사 N 개에 fan-out 으로 권한 그룹 목록 합산.
|
||||
* 단일 회사 GET /api/roles 와 동일한 컬럼 + 행마다 company_code 추가.
|
||||
*
|
||||
* 지원 파라미터: search (AUTH_NAME ILIKE 검색).
|
||||
*/
|
||||
@GetMapping("/roles")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listRoles(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
return runFanOut(request, queryParams, "admin-cross-tenant.listRoleGroups", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/batches
|
||||
*
|
||||
* 활성 회사 N 개에 fan-out 으로 배치 목록 합산. 행마다 company_code 박힘.
|
||||
* 지원 파라미터: search, is_active.
|
||||
*/
|
||||
@GetMapping("/batches")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listBatches(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
// 배치는 search 를 SQL 안에서 '%' || #{search} || '%' 로 감싸므로 컨트롤러 변환 불필요
|
||||
return runFanOut(request, queryParams, "admin-cross-tenant.listBatches", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/lang-keys
|
||||
*
|
||||
* 활성 회사 N 개에 fan-out 으로 다국어 키 목록 합산. 행마다 company_code 박힘.
|
||||
* 지원 파라미터: search, menu_code.
|
||||
*/
|
||||
@GetMapping("/lang-keys")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listLangKeys(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
return runFanOut(request, queryParams, "admin-cross-tenant.listLangKeys", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 fan-out 실행 헬퍼.
|
||||
*
|
||||
* @param wrapSearchWithPercent true 면 search 파라미터를 컨트롤러에서 % 로 감쌈
|
||||
* (mapper 가 그대로 ILIKE 에 넣는 방식 — listUsers, listRoleGroups, listLangKeys).
|
||||
* false 면 mapper 안에서 '%' || ? || '%' 로 직접 감싸는 케이스 (listBatches).
|
||||
*/
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> runFanOut(
|
||||
HttpServletRequest request,
|
||||
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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>(queryParams);
|
||||
if (wrapSearchWithPercent) {
|
||||
Object rawSearch = params.get("search");
|
||||
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
|
||||
params.put("search", "%" + rawSearch + "%");
|
||||
}
|
||||
}
|
||||
|
||||
int perCompanyLimit = resolvePerCompanyLimit(params);
|
||||
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
|
||||
|
||||
CrossTenantAggregator.Result result = aggregator.fanOut(mapperId, params, perCompanyLimit);
|
||||
|
||||
return buildResponse(result, perCompanyLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사당 cap 결정 — 쿼리 파라미터 {@code per_company_limit} 가 있으면 사용 (1~MAX 사이 클램프),
|
||||
* 없으면 디폴트 200.
|
||||
*/
|
||||
private int resolvePerCompanyLimit(Map<String, Object> params) {
|
||||
Object raw = params.get("per_company_limit");
|
||||
if (raw == null) return DEFAULT_PER_COMPANY_LIMIT;
|
||||
try {
|
||||
int v = Integer.parseInt(String.valueOf(raw));
|
||||
if (v < 1) return DEFAULT_PER_COMPANY_LIMIT;
|
||||
if (v > MAX_PER_COMPANY_LIMIT) return MAX_PER_COMPANY_LIMIT;
|
||||
return v;
|
||||
} catch (NumberFormatException e) {
|
||||
return DEFAULT_PER_COMPANY_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregator 결과 → 응답 봉투. truncated 정보 포함.
|
||||
* 응답 헤더에도 X-CrossTenant-Failed / X-CrossTenant-Truncated 박아 디버깅 편의.
|
||||
*/
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> buildResponse(
|
||||
CrossTenantAggregator.Result result, int perCompanyLimit) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rows", result.rows);
|
||||
data.put("total", result.rows.size());
|
||||
data.put("companies_queried", result.companies_queried);
|
||||
data.put("companies_failed", result.companies_failed);
|
||||
data.put("truncated", !result.truncated_company_codes.isEmpty());
|
||||
data.put("truncated_company_codes", result.truncated_company_codes);
|
||||
data.put("per_company_limit", perCompanyLimit);
|
||||
|
||||
ResponseEntity.BodyBuilder builder = ResponseEntity.ok();
|
||||
if (!result.failed_company_codes.isEmpty()) {
|
||||
builder.header("X-CrossTenant-Failed", String.join(",", result.failed_company_codes));
|
||||
}
|
||||
if (!result.truncated_company_codes.isEmpty()) {
|
||||
builder.header("X-CrossTenant-Truncated", String.join(",", result.truncated_company_codes));
|
||||
}
|
||||
return builder.body(ApiResponse.success(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant 부서 조회 — 사용자 등록/수정 폼의 "부서" dropdown 을
|
||||
* 선택된 회사 DB 기준으로 채우기 위한 보조 endpoint.
|
||||
*
|
||||
* 단일 회사 모드의 {@code GET /api/admin/departments} 와 응답 형태 동일.
|
||||
* 차이점: company_code 가 query param 으로 명시되고, 그 회사 DB 컨텍스트로 임시 전환.
|
||||
*
|
||||
* @see CrossTenantUserController
|
||||
* @see com.erp.controller.AdminController#getDepartmentList // 단일 모드 원본
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/departments")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantDeptController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/departments?company_code=TEST02
|
||||
* 응답 구조는 단일 모드와 동일: { success, data: { departments, flat_list }, total, total_count }
|
||||
*/
|
||||
@GetMapping
|
||||
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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(errorBody("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> serviceResult = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return adminService.getDepartmentList(params);
|
||||
});
|
||||
|
||||
int total = ((Number) serviceResult.get("total")).intValue();
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("departments", serviceResult.get("departments"));
|
||||
data.put("flat_list", serviceResult.get("flat_list"));
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", data);
|
||||
response.put("message", "부서 목록 조회 성공");
|
||||
response.put("total", total);
|
||||
response.put("total_count", total);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(errorBody(e.getMessage(), request.getRequestURI()));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> errorBody(String message, String path) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("success", false);
|
||||
body.put("message", message);
|
||||
body.put("path", path);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import com.erp.tenant.TenantDataSourceFactory;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
import com.erp.tenant.TenantRoutingDataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant WRITE 트랙 — 회사 컨텍스트 임시 전환 + 작업 실행 + 복원.
|
||||
*
|
||||
* READ 트랙({@link CrossTenantAggregator}) 와 달리 fan-out 안 함. 호출자가 명시한
|
||||
* 단일 회사(company_code) DB 컨텍스트로 잠깐 전환해서 INSERT/UPDATE/DELETE 실행.
|
||||
*
|
||||
* 사용 패턴 (컨트롤러):
|
||||
* <pre>
|
||||
* if (!CrossTenantContext.isSuperAdmin(request)) return forbidden();
|
||||
* if (!CrossTenantContext.isMetaContext()) return badRequest();
|
||||
*
|
||||
* String targetCompany = (String) body.get("company_code");
|
||||
* Map<String,Object> result = executor.runInCompany(targetCompany, () ->
|
||||
* adminService.saveUser(body)
|
||||
* );
|
||||
* </pre>
|
||||
*
|
||||
* 핵심 보장:
|
||||
* - 회사 풀 lazy 생성 (없으면 만들고, 이미 있으면 no-op). minIdle=0 정책 그대로.
|
||||
* - finally 에서 prev 컨텍스트 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅.
|
||||
* - 알 수 없는 company_code 면 IllegalArgumentException — 컨트롤러가 400 으로 변환.
|
||||
*
|
||||
* @see CrossTenantAggregator // READ 트랙 (fan-out)
|
||||
* @see CrossTenantContext // 가드
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantExecutor {
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
|
||||
/**
|
||||
* 지정 회사 DB 컨텍스트로 작업 실행. 결과 반환.
|
||||
*
|
||||
* @throws IllegalArgumentException company_code 가 비어있거나 active 회사가 아닐 때
|
||||
*/
|
||||
public <T> T runInCompany(String companyCode, Supplier<T> work) {
|
||||
String dbName = resolveDbName(companyCode);
|
||||
|
||||
ensureTenantPool(dbName);
|
||||
|
||||
String prev = DbContextHolder.get();
|
||||
try {
|
||||
DbContextHolder.set(dbName);
|
||||
log.info("[CrossTenant/Write] enter company={} db={}", companyCode, dbName);
|
||||
return work.get();
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
DbContextHolder.clear();
|
||||
} else {
|
||||
DbContextHolder.set(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 결과 없는 작업용 (Runnable 형태). */
|
||||
public void runInCompany(String companyCode, Runnable work) {
|
||||
runInCompany(companyCode, () -> {
|
||||
work.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* company_code → db_name 매핑. META DB 의 COMPANY_MNG 에서 active 행만 인정.
|
||||
* 컨텍스트 전환 전에 호출돼야 하므로 META 컨텍스트(현재 컨텍스트)에서 실행.
|
||||
*/
|
||||
private String resolveDbName(String companyCode) {
|
||||
if (companyCode == null || companyCode.isBlank()) {
|
||||
throw new IllegalArgumentException("company_code 가 비어있음");
|
||||
}
|
||||
if ("*".equals(companyCode)) {
|
||||
throw new IllegalArgumentException("'*' 는 cross-tenant write 대상이 아님 (메타 = SUPER_ADMIN 자신)");
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
String dbName = sqlSession.selectOne("provisioning.resolveDbNameByCompanyCode", params);
|
||||
if (dbName == null) {
|
||||
throw new IllegalArgumentException("등록되지 않았거나 비활성 회사: company_code=" + companyCode);
|
||||
}
|
||||
return dbName;
|
||||
}
|
||||
|
||||
/** 회사 풀이 없으면 최초 1회 생성. SubdomainResolverFilter / Aggregator 와 동일 패턴. */
|
||||
private void ensureTenantPool(String dbName) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
synchronized (routingDataSource) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
HikariDataSource ds = TenantDataSourceFactory.createTenant(
|
||||
tenantDbSettings.buildJdbcUrl(dbName),
|
||||
tenantDbSettings.username(),
|
||||
tenantDbSettings.password(),
|
||||
dbName);
|
||||
routingDataSource.addTenant(dbName, ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant ROLE WRITE/READ 엔드포인트 — Phase 2.
|
||||
*
|
||||
* 권한 그룹은 회사 DB 의 AUTHORITY_MASTER, 멤버/메뉴 권한도 회사 DB 내부 테이블.
|
||||
* 어느 회사의 권한 그룹인지 알아야 라우팅 가능 → 모든 endpoint 가 company_code 필수
|
||||
* (body 또는 query param).
|
||||
*
|
||||
* 단일 회사 모드 endpoint ({@link com.erp.controller.RoleController}) 는 무수정.
|
||||
*
|
||||
* @see CrossTenantExecutor
|
||||
* @see CrossTenantUserController // 같은 패턴, Phase 1
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/roles")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/roles
|
||||
* body: { company_code, auth_name, auth_code, ... }
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createRole(
|
||||
HttpServletRequest request,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
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);
|
||||
params.put("writer", writer);
|
||||
params.put("objid", "AM" + System.currentTimeMillis());
|
||||
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
|
||||
params.put("auth_name", params.get("role_name"));
|
||||
}
|
||||
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
|
||||
params.put("auth_code", params.get("role_code"));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cross-tenant/roles/{id} body: { company_code, ... }
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRole(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
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);
|
||||
params.put("objid", id);
|
||||
params.put("writer", writer);
|
||||
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
|
||||
params.put("auth_name", params.get("role_name"));
|
||||
}
|
||||
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
|
||||
params.put("auth_code", params.get("role_code"));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/roles/{id}?company_code=TEST02
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteRole(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 워크스페이스 / 메뉴 트리 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/roles/{id}/workspace?company_code=TEST02
|
||||
* 그룹 + 멤버 + non-members + 메뉴 + 메뉴 권한 한 번에.
|
||||
*/
|
||||
@GetMapping("/{id}/workspace")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getWorkspace(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> ws = executor.runInCompany(companyCode,
|
||||
() -> roleService.getRoleWorkspace(id));
|
||||
if (ws == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("권한 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(ws, "워크스페이스 조회 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/roles/menus/all?company_code=TEST02
|
||||
* 회사 메뉴 트리 (권한 설정용 원천).
|
||||
*/
|
||||
@GetMapping("/menus/all")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllMenus(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<List<Map<String, Object>>>> g = guardList(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> menus = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("company_code", companyCode);
|
||||
return roleService.getAllMenus(p);
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(menus, "메뉴 목록 조회 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 멤버 토글 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
|
||||
*/
|
||||
@PostMapping("/{id}/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addSingleMember(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String userId,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
boolean inserted = roleService.addSingleRoleMember(id, userId, writer);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("inserted", inserted);
|
||||
r.put("master_objid", id);
|
||||
r.put("user_id", userId);
|
||||
return r;
|
||||
});
|
||||
String msg = Boolean.TRUE.equals(result.get("inserted")) ? "멤버 추가 성공" : "이미 멤버입니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(result, msg));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
|
||||
*/
|
||||
@DeleteMapping("/{id}/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> removeSingleMember(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
boolean deleted = roleService.removeSingleRoleMember(id, userId);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("deleted", deleted);
|
||||
r.put("master_objid", id);
|
||||
r.put("user_id", userId);
|
||||
return r;
|
||||
});
|
||||
String msg = Boolean.TRUE.equals(result.get("deleted")) ? "멤버 제거 성공" : "멤버가 존재하지 않습니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(result, msg));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 메뉴 권한 토글 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/cross-tenant/roles/{id}/menu-permissions/{menuObjid}
|
||||
* body: { company_code, create_yn?, read_yn?, update_yn?, delete_yn? }
|
||||
*/
|
||||
@PatchMapping("/{id}/menu-permissions/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> toggleMenuPermission(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String menuObjid,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> roleService.toggleMenuPermission(
|
||||
id, menuObjid,
|
||||
asYn(body.get("create_yn")),
|
||||
asYn(body.get("read_yn")),
|
||||
asYn(body.get("update_yn")),
|
||||
asYn(body.get("delete_yn")),
|
||||
writer));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 권한 토글 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 가드 헬퍼 (응답 타입별로 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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
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;
|
||||
if (raw instanceof Boolean b) return b ? "Y" : "N";
|
||||
String s = String.valueOf(raw).trim();
|
||||
if (s.isEmpty()) return null;
|
||||
if ("Y".equalsIgnoreCase(s) || "true".equalsIgnoreCase(s) || "1".equals(s)) return "Y";
|
||||
if ("N".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s) || "0".equals(s)) return "N";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
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;
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant USER WRITE 엔드포인트.
|
||||
*
|
||||
* 기본 패턴:
|
||||
* 1. {@link CrossTenantContext#isSuperAdmin} + {@link CrossTenantContext#isMetaContext} 가드
|
||||
* 2. 요청 body/path/query 에서 target {@code company_code} 추출 (필수)
|
||||
* 3. {@link CrossTenantExecutor#runInCompany} 로 그 회사 DB 컨텍스트 임시 전환
|
||||
* 4. 기존 {@link AdminService} 의 user write 메서드 호출 (재사용)
|
||||
* 5. finally 에서 컨텍스트 복원
|
||||
*
|
||||
* 기존 {@code POST /api/admin/users} 등 단일 회사 모드 엔드포인트는 무수정 — 회사 도메인
|
||||
* 컨텍스트에서 그대로 동작.
|
||||
*
|
||||
* @see CrossTenantExecutor
|
||||
* @see com.erp.controller.AdminController // 단일 회사 모드 원본
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users
|
||||
* SUPER_ADMIN 이 특정 회사 사용자 등록/수정 (회사는 body.company_code 로 명시).
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/with-dept
|
||||
* 사원+부서 통합 저장 (cross-tenant).
|
||||
*/
|
||||
@PostMapping("/with-dept")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUserWithDept(body));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사원+부서 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cross-tenant/users/{userId}
|
||||
* 사용자 수정 (REST). target company_code 는 body 에 명시.
|
||||
*/
|
||||
@PutMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/users/{userId}?company_code=TEST01
|
||||
* 사용자 삭제 (비활성화). target company_code 는 query param.
|
||||
*/
|
||||
@DeleteMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteUser(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
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);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId);
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/cross-tenant/users/{userId}/status
|
||||
* 사용자 상태 변경. body: { "status": "active|inactive", "company_code": "TEST01" }
|
||||
*/
|
||||
@PatchMapping("/{userId}/status")
|
||||
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String status = (String) body.get("status");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.changeUserStatus(userId, status));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/reset-password
|
||||
* body: { "user_id": "...", "company_code": "TEST01" }
|
||||
*/
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<ApiResponse<Void>> resetUserPassword(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── READ 보강 (단건 조회 / 중복확인 / 이력) ───────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/users/{userId}?company_code=TEST01
|
||||
* 단건 조회 — 회사 컨텍스트로 가서 USER_INFO 단건.
|
||||
*/
|
||||
@GetMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
try {
|
||||
Map<String, Object> info = executor.runInCompany(companyCode,
|
||||
() -> adminService.getUserInfo(userId));
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/users/{userId}/with-dept?company_code=TEST01
|
||||
*/
|
||||
@GetMapping("/{userId}/with-dept")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("company_code", companyCode);
|
||||
p.put("user_id", userId);
|
||||
return adminService.getUserWithDept(p);
|
||||
});
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/check-duplicate
|
||||
* body: { "user_id": "...", "company_code": "TEST01" }
|
||||
*/
|
||||
@PostMapping("/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("is_duplicate", existing != null);
|
||||
return out;
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 가드 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
/** 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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 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()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -48,15 +48,17 @@ public class StartupSchemaMigrator {
|
||||
// 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는
|
||||
// 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화.
|
||||
// SEQ 만 갱신 → 멱등.
|
||||
// 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것
|
||||
// (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생).
|
||||
"""
|
||||
UPDATE MENU_INFO
|
||||
SET SEQ = CASE MENU_NAME_KOR
|
||||
WHEN '회사관리' THEN 100
|
||||
WHEN '부서관리' THEN 200
|
||||
WHEN '사용자관리' THEN 300
|
||||
WHEN '메뉴관리' THEN 400
|
||||
WHEN '권한관리' THEN 500
|
||||
WHEN '권한 그룹관리' THEN 600
|
||||
WHEN '회사관리' THEN '100'
|
||||
WHEN '부서관리' THEN '200'
|
||||
WHEN '사용자관리' THEN '300'
|
||||
WHEN '메뉴관리' THEN '400'
|
||||
WHEN '권한관리' THEN '500'
|
||||
WHEN '권한 그룹관리' THEN '600'
|
||||
ELSE SEQ
|
||||
END
|
||||
WHERE MENU_TYPE = '0'
|
||||
@@ -67,6 +69,243 @@ public class StartupSchemaMigrator {
|
||||
'회사관리', '부서관리', '사용자관리',
|
||||
'메뉴관리', '권한관리', '권한 그룹관리'
|
||||
)
|
||||
""",
|
||||
|
||||
// V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼.
|
||||
// DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨.
|
||||
// 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL",
|
||||
|
||||
// V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
|
||||
"CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL",
|
||||
|
||||
// V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리.
|
||||
// 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
// DROP IF EXISTS 로 멱등성 보장.
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
|
||||
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE",
|
||||
|
||||
// V020: 사용자별 메뉴 즐겨찾기 테이블.
|
||||
// 메타 DB 는 Flyway V020 으로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
// CREATE IF NOT EXISTS 로 멱등성 보장.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
|
||||
OBJID BIGSERIAL PRIMARY KEY,
|
||||
USER_ID VARCHAR(100) NOT NULL,
|
||||
MENU_OBJID VARCHAR(50) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)",
|
||||
|
||||
// RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성
|
||||
"CREATE EXTENSION IF NOT EXISTS btree_gist",
|
||||
|
||||
// RUN_086 (2) 대무자(代務者) 관리 테이블
|
||||
// self-위임 차단 (CHECK), 같은 쌍 활성 기간 겹침 차단 (EXCLUDE).
|
||||
// 재실행 시 IF NOT EXISTS 로 안전. EXCLUDE/CHECK 제약은 첫 생성 때만 적용.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES (
|
||||
SUBSTITUTE_ID BIGSERIAL PRIMARY KEY,
|
||||
COMPANY_CODE VARCHAR(50) NOT NULL,
|
||||
ORIGINAL_USER_ID VARCHAR(50) NOT NULL,
|
||||
PROXY_USER_ID VARCHAR(50) NOT NULL,
|
||||
START_DATE DATE NULL,
|
||||
END_DATE DATE NOT NULL,
|
||||
REASON VARCHAR(500),
|
||||
IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CREATED_BY VARCHAR(50),
|
||||
CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UPDATED_BY VARCHAR(50),
|
||||
UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_user_substitutes_self
|
||||
CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID),
|
||||
CONSTRAINT chk_user_substitutes_date
|
||||
CHECK (START_DATE IS NULL OR START_DATE <= END_DATE),
|
||||
CONSTRAINT excl_user_substitutes_overlap
|
||||
EXCLUDE USING gist (
|
||||
COMPANY_CODE WITH =,
|
||||
ORIGINAL_USER_ID WITH =,
|
||||
PROXY_USER_ID WITH =,
|
||||
daterange(START_DATE, END_DATE, '[]') WITH &&
|
||||
) WHERE (IS_ACTIVE = TRUE)
|
||||
)
|
||||
""",
|
||||
|
||||
// RUN_086 (3) USER_SUBSTITUTES 인덱스 — Filter 핫패스 + 조회 가속
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_original ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)",
|
||||
|
||||
// RUN_086 (4) SYSTEM_AUDIT_LOG — 처리자(actual processor) 분리 기록 컬럼
|
||||
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50)",
|
||||
"ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100)",
|
||||
|
||||
// RUN_086 (5) APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 데이터 복사 (idempotent)
|
||||
// 기존 운영 데이터 보존 + 어댑터 read 경로가 즉시 동작하도록.
|
||||
// IS_ACTIVE 매핑: APPROVAL_PROXY_SETTINGS 의 CHAR('Y'/'N') → BOOLEAN.
|
||||
// 메타데이터(created/updated) 는 원본 컬럼 의존 없이 'migration_086' + NOW() 로 고정
|
||||
// (APPROVAL_PROXY_SETTINGS 의 timestamp 컬럼명이 환경별로 다를 수 있어 안전한 default 채택).
|
||||
"""
|
||||
INSERT INTO USER_SUBSTITUTES (
|
||||
COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID,
|
||||
START_DATE, END_DATE, REASON, IS_ACTIVE,
|
||||
CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
|
||||
)
|
||||
SELECT
|
||||
p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID,
|
||||
CAST(NULLIF(p.START_DATE, '') AS DATE),
|
||||
CAST(NULLIF(p.END_DATE, '') AS DATE),
|
||||
p.REASON,
|
||||
CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END,
|
||||
'migration_086', NOW(),
|
||||
'migration_086', NOW()
|
||||
FROM APPROVAL_PROXY_SETTINGS p
|
||||
WHERE NULLIF(p.END_DATE, '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM USER_SUBSTITUTES s
|
||||
WHERE s.COMPANY_CODE = p.COMPANY_CODE
|
||||
AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID
|
||||
AND s.PROXY_USER_ID = p.PROXY_USER_ID
|
||||
AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE)
|
||||
AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE)
|
||||
)
|
||||
""",
|
||||
|
||||
// V021 / RUN_087: BATCH_MAPPINGS 에 MAPPING_CONFIG JSONB 컬럼 추가.
|
||||
// conditional 매핑(when/then/default) 규칙 저장용.
|
||||
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
|
||||
|
||||
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
|
||||
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
|
||||
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
|
||||
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
|
||||
|
||||
// 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')
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -90,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.erp.security;
|
||||
|
||||
import com.erp.ai.security.AiApiKeyAuthFilter;
|
||||
import com.erp.ai.service.AiAgentApiKeyService;
|
||||
import com.erp.service.SubstituteService;
|
||||
import com.erp.tenant.CompanyResolver;
|
||||
import com.erp.tenant.SubdomainResolverFilter;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
@@ -37,6 +38,7 @@ public class SecurityConfig {
|
||||
private final TenantRoutingDataSource tenantRoutingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
private final AiAgentApiKeyService aiAgentApiKeyService;
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
/**
|
||||
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com").
|
||||
@@ -76,9 +78,12 @@ public class SecurityConfig {
|
||||
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
|
||||
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
|
||||
JwtAuthenticationFilter.class)
|
||||
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
|
||||
// TenantConsistencyGuardFilter 뒤 — 대무자(代務者) 컨텍스트 effective_user_ids 주입.
|
||||
.addFilterAfter(new SubstituteContextFilter(substituteService),
|
||||
TenantConsistencyGuardFilter.class)
|
||||
// SubstituteContextFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
|
||||
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
|
||||
TenantConsistencyGuardFilter.class);
|
||||
SubstituteContextFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.erp.security;
|
||||
|
||||
import com.erp.service.SubstituteService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 컨텍스트 주입 필터.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T5)
|
||||
*
|
||||
* 동작:
|
||||
* 1. /api/** 가 아니면 통과
|
||||
* 2. JWT user_id / company_code attribute 없으면 통과 (비로그인)
|
||||
* 3. company_code == "*" (SUPER_ADMIN pre-switch) 이면 통과 — 대무 의미 없음
|
||||
* 4. substituteService.getActiveOriginalUserIds(userId, companyCode) 조회
|
||||
* 5. effective_user_ids = [userId, ...originalIds] → request attribute
|
||||
* 6. actual_processor_id = userId → request attribute (의미 명시 alias)
|
||||
*
|
||||
* 예외 처리:
|
||||
* DB 조회 실패 시 effective_user_ids 를 [userId] 만 담아 통과시킨다 — 대무 컨텍스트
|
||||
* 실패가 본 요청을 깨면 안 되기 때문 (가용성 우선). warn 로그 남김.
|
||||
*
|
||||
* 성능:
|
||||
* - request 당 SELECT 1회 (인덱스 (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE) 매치, 보통 <1ms)
|
||||
* - request-scope 자연 캐시 — 한 요청 내에서 attribute 만 참조하면 추가 조회 없음
|
||||
*
|
||||
* 필터 순서:
|
||||
* SubdomainResolver → AiApiKey → Jwt → TenantConsistencyGuard → **여기** → ForcePasswordChangeGuard
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String ATTR_EFFECTIVE_USER_IDS = "effective_user_ids";
|
||||
public static final String ATTR_ACTUAL_PROCESSOR_ID = "actual_processor_id";
|
||||
|
||||
private final SubstituteService substituteService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
if (path == null || !path.startsWith("/api/")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 대무자 컨텍스트가 의미 없는 경로 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");
|
||||
|
||||
// 비로그인 또는 SUPER_ADMIN pre-switch → 대무 컨텍스트 의미 없음, 통과
|
||||
if (userId == null || companyCode == null || "*".equals(companyCode)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> effectiveIds = new ArrayList<>();
|
||||
effectiveIds.add(userId);
|
||||
|
||||
try {
|
||||
List<String> originalIds = substituteService.getActiveOriginalUserIds(userId, companyCode);
|
||||
if (originalIds != null && !originalIds.isEmpty()) {
|
||||
effectiveIds.addAll(originalIds);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 대무 컨텍스트 조회 실패는 본 요청을 막지 않음 — 본인 권한만으로 동작
|
||||
log.warn("[SubstituteContext] failed to resolve proxy context for user={}: {}",
|
||||
userId, e.getMessage());
|
||||
}
|
||||
|
||||
request.setAttribute(ATTR_EFFECTIVE_USER_IDS, effectiveIds);
|
||||
request.setAttribute(ATTR_ACTUAL_PROCESSOR_ID, userId);
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -208,10 +208,17 @@ public class AdminService extends BaseService {
|
||||
}
|
||||
|
||||
public void resetUserPassword(String userId) {
|
||||
String defaultPw = passwordEncoder.encode("Welcome1!");
|
||||
resetUserPassword(userId, null);
|
||||
}
|
||||
|
||||
public void resetUserPassword(String userId, String newPassword) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("user_id 는 필수입니다");
|
||||
}
|
||||
String rawPw = (newPassword != null && !newPassword.isBlank()) ? newPassword : "Welcome1!";
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("user_password", defaultPw);
|
||||
params.put("user_password", passwordEncoder.encode(rawPw));
|
||||
sqlSession.update("admin.updateUserPassword", params);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,26 @@ public class ApprovalService extends BaseService {
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* IN (:effective_user_ids) 쿼리용 fallback.
|
||||
* SubstituteContextFilter 가 attribute 를 못 채운 경로(통합 테스트/배치 등) 에서도
|
||||
* 빈 IN () SQL 에러를 막기 위해 항상 최소 [user_id] 가 들어가도록 한다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void ensureEffectiveUserIds(Map<String, Object> params) {
|
||||
Object v = params.get("effective_user_ids");
|
||||
boolean empty = v == null || (v instanceof Collection<?> && ((Collection<?>) v).isEmpty());
|
||||
if (empty) {
|
||||
Object userId = params.get("user_id");
|
||||
if (userId != null) {
|
||||
params.put("effective_user_ids", List.of(userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// approval_definitions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -149,6 +169,7 @@ public class ApprovalService extends BaseService {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
public Map<String, Object> getRequests(Map<String, Object> params) {
|
||||
ensureEffectiveUserIds(params);
|
||||
int page = toInt(params.getOrDefault("page", "1"));
|
||||
int limit = toInt(params.getOrDefault("limit", "20"));
|
||||
params.put("page_limit", limit);
|
||||
@@ -359,6 +380,7 @@ public class ApprovalService extends BaseService {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
public List<Map<String, Object>> getMyPendingLines(Map<String, Object> params) {
|
||||
ensureEffectiveUserIds(params);
|
||||
return sqlSession.selectList("approval.selectMyPendingLines", params);
|
||||
}
|
||||
|
||||
@@ -456,6 +478,24 @@ public class ApprovalService extends BaseService {
|
||||
activateNextStep(requestId, stepOrder, totalSteps, lineCC, userId, comment);
|
||||
}
|
||||
}
|
||||
|
||||
// 결재 처리 audit log — 대무 시 user_id(A)와 processor_id(B) 분리 기록.
|
||||
// 실패는 본 처리를 막지 않음 (가용성 우선).
|
||||
try {
|
||||
Map<String, Object> auditP = new HashMap<>();
|
||||
auditP.put("company_code", lineCC);
|
||||
auditP.put("user_id", approverId); // 위임자 A
|
||||
auditP.put("user_name", line.get("approver_name"));
|
||||
auditP.put("processor_id", userId); // 실제 처리자 B
|
||||
// processor_name 은 AuditLogService 가 USER_INFO 에서 lookup (T14)
|
||||
auditP.put("action", "approval." + action);
|
||||
auditP.put("resource_type", "approval_line");
|
||||
auditP.put("resource_id", String.valueOf(lineId));
|
||||
auditP.put("summary", "결재 " + action + (proxyFor != null ? " (대무)" : ""));
|
||||
auditLogService.insertAuditLog(auditP);
|
||||
} catch (Exception e) {
|
||||
log.warn("결재 audit log 기록 실패 (line={}): {}", lineId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -73,7 +73,12 @@ public class AuditLogService extends BaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 로그 1건 기록
|
||||
* 감사 로그 1건 기록.
|
||||
*
|
||||
* PROCESSOR 처리 (대무 추적):
|
||||
* - processor_id 미지정 → user_id 로 채움 (평시 = 동일 = 본인 처리)
|
||||
* - processor_id 가 user_id 와 다르고 processor_name 미지정 → USER_INFO 에서 lookup
|
||||
* (대무 이벤트만 USER_INFO 단건 조회 1회 — 평시는 추가 DB 호출 없음)
|
||||
*/
|
||||
public void insertAuditLog(Map<String, Object> params) {
|
||||
// changes가 Map이면 JSON 문자열로 직렬화
|
||||
@@ -86,6 +91,26 @@ public class AuditLogService extends BaseService {
|
||||
params.put("changes", null);
|
||||
}
|
||||
}
|
||||
|
||||
Object processorId = params.get("processor_id");
|
||||
Object userId = params.get("user_id");
|
||||
if (processorId == null) {
|
||||
params.put("processor_id", userId);
|
||||
if (params.get("processor_name") == null) {
|
||||
params.put("processor_name", params.get("user_name"));
|
||||
}
|
||||
} else if (!processorId.equals(userId) && params.get("processor_name") == null) {
|
||||
try {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", processorId);
|
||||
p.put("company_code", params.get("company_code"));
|
||||
String name = sqlSession.selectOne("auditLog.selectUserNameById", p);
|
||||
params.put("processor_name", name);
|
||||
} catch (Exception e) {
|
||||
log.warn("processor_name lookup 실패 (processor_id={}): {}", processorId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
sqlSession.insert("auditLog.insertAuditLog", params);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.batch.BatchExecutor;
|
||||
import com.erp.common.BaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -24,8 +25,11 @@ public class BatchManagementService extends BaseService {
|
||||
private CommonService commonService;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private BatchExecutor batchExecutor;
|
||||
|
||||
private static final String NS = "batchManagement.";
|
||||
private static final String EXEC_LOG_NS = "batchExecutionLog.";
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -113,24 +117,102 @@ public class BatchManagementService extends BaseService {
|
||||
Map<String, Object> batchConfig = batchService.getBatchInfo(params);
|
||||
if (batchConfig == null) throw new RuntimeException("배치 설정을 찾을 수 없습니다.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
long startMs = System.currentTimeMillis();
|
||||
String batchName = str(batchConfig.get("batch_name"));
|
||||
String companyCode = str(batchConfig.get("company_code"));
|
||||
log.info("배치 수동 실행: id={}, name={}", id, batchName);
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
// 1. 실행 로그 INSERT — RUNNING 상태로 먼저 박아두면 도중 비정상 종료해도 추적 가능
|
||||
Map<String, Object> logRow = new LinkedHashMap<>();
|
||||
logRow.put("batch_config_id", id);
|
||||
logRow.put("company_code", companyCode);
|
||||
logRow.put("execution_status", "RUNNING");
|
||||
logRow.put("server_name", safeHostName());
|
||||
logRow.put("process_id", String.valueOf(ProcessHandle.current().pid()));
|
||||
try {
|
||||
sqlSession.insert(EXEC_LOG_NS + "insertBatchExecutionLog", logRow);
|
||||
} catch (Exception e) {
|
||||
log.warn("실행 로그 INSERT 실패 (실행은 계속 진행): {}", e.getMessage());
|
||||
}
|
||||
Object logId = logRow.get("id");
|
||||
|
||||
// 2. 실제 ETL 실행 — 예외는 로그에 기록 후 다시 throw (controller 의 에러 응답 위해)
|
||||
BatchExecutor.ExecutionResult execResult = null;
|
||||
String status = "SUCCESS";
|
||||
String errorMessage = null;
|
||||
try {
|
||||
execResult = batchExecutor.execute(batchConfig);
|
||||
if (execResult.failedRecords > 0) {
|
||||
status = execResult.successRecords > 0 ? "PARTIAL" : "FAILED";
|
||||
}
|
||||
if (!execResult.errorMessages.isEmpty()) {
|
||||
errorMessage = String.join("\n", execResult.errorMessages);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
status = "FAILED";
|
||||
errorMessage = e.getMessage();
|
||||
log.error("배치 실행 중 예외: id={} — {}", id, e.getMessage(), e);
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startMs;
|
||||
|
||||
// 3. 실행 로그 UPDATE — 최종 상태/카운트/duration 마무리
|
||||
// 주의: batch_execution_logs 의 duration_ms / *_records 컬럼은 운영 DB 에서 VARCHAR
|
||||
// (V001 legacy 마이그레이션 흔적). PgJDBC 가 Long/Integer 를 VARCHAR 로 자동 변환하지 못할 수 있어
|
||||
// 명시적으로 String 으로 보낸다. mapper 의 COALESCE default 도 '0' (문자열) 이라 일관됨.
|
||||
if (logId != null) {
|
||||
Map<String, Object> updateLog = new LinkedHashMap<>();
|
||||
updateLog.put("id", logId);
|
||||
updateLog.put("execution_status", status);
|
||||
updateLog.put("end_time", new java.sql.Timestamp(System.currentTimeMillis()));
|
||||
updateLog.put("duration_ms", String.valueOf(duration));
|
||||
updateLog.put("total_records", String.valueOf(execResult != null ? execResult.totalRecords : 0));
|
||||
updateLog.put("success_records", String.valueOf(execResult != null ? execResult.successRecords : 0));
|
||||
updateLog.put("failed_records", String.valueOf(execResult != null ? execResult.failedRecords : 0));
|
||||
if (errorMessage != null) updateLog.put("error_message", errorMessage);
|
||||
try {
|
||||
sqlSession.update(EXEC_LOG_NS + "updateBatchExecutionLog", updateLog);
|
||||
} catch (Exception e) {
|
||||
log.warn("실행 로그 UPDATE 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("batch_name", batchName);
|
||||
result.put("total_records", 0);
|
||||
result.put("success_records", 0);
|
||||
result.put("failed_records", 0);
|
||||
result.put("execution_status", status);
|
||||
result.put("total_records", execResult != null ? execResult.totalRecords : 0);
|
||||
result.put("success_records", execResult != null ? execResult.successRecords : 0);
|
||||
result.put("failed_records", execResult != null ? execResult.failedRecords : 0);
|
||||
result.put("execution_time", duration);
|
||||
if (errorMessage != null) result.put("error_message", errorMessage);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 실행 로그 server_name 컬럼용 — hostname resolve 실패 시 "unknown". */
|
||||
private static String safeHostName() {
|
||||
try {
|
||||
return java.net.InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// ── REST API Preview / Save ───────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> previewRestApiData(Map<String, Object> body) {
|
||||
// 프론트(batchManagement.ts)는 camelCase 로 키를 보내고 백엔드는 snake_case 로 읽음.
|
||||
// 기존 convertCamelToSnake() 는 batch_configs 전용 remap 이라 여기엔 효과 없음.
|
||||
// → previewRestApiData 전용으로 사용하는 키만 직접 remap.
|
||||
remap(body, "apiUrl", "api_url");
|
||||
remap(body, "apiKey", "api_key");
|
||||
remap(body, "requestBody", "request_body");
|
||||
remap(body, "dataArrayPath", "data_array_path");
|
||||
remap(body, "paramType", "param_type");
|
||||
remap(body, "paramName", "param_name");
|
||||
remap(body, "paramValue", "param_value");
|
||||
remap(body, "paramSource", "param_source");
|
||||
remap(body, "authServiceName", "auth_service_name");
|
||||
|
||||
String apiUrl = str(body.get("api_url"));
|
||||
String endpoint = str(body.get("endpoint"));
|
||||
String method = body.get("method") != null ? str(body.get("method")) : "GET";
|
||||
@@ -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;
|
||||
@@ -140,6 +141,12 @@ public class DdlService extends BaseService {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
String inputType = convertToInputType(column);
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
String detailSettings = column.containsKey("detail_settings")
|
||||
? column.get("detail_settings").toString() : "{}";
|
||||
Integer maxOrder = jdbcTemplate.queryForObject(
|
||||
@@ -219,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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -408,10 +488,17 @@ public class DdlService extends BaseService {
|
||||
// 사용자 정의 컬럼
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
Map<String, Object> col = columns.get(i);
|
||||
String inputType = convertToInputType(col);
|
||||
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + inputType + ")"
|
||||
);
|
||||
}
|
||||
String detailSettings = col.containsKey("detail_settings")
|
||||
? col.get("detail_settings").toString() : "{}";
|
||||
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
|
||||
convertToInputType(col), detailSettings, i);
|
||||
inputType, detailSettings, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +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,9 +5,12 @@ 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;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -18,11 +21,22 @@ public class DepartmentService extends BaseService {
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> getDepartments(String companyCode) {
|
||||
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) {
|
||||
@@ -30,14 +44,38 @@ 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;
|
||||
}
|
||||
|
||||
/** active 부서만 반환. deleted 면 null. 복구 흐름은 getDepartmentIncludingDeleted 사용 */
|
||||
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);
|
||||
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
|
||||
@@ -65,10 +103,30 @@ public class DepartmentService extends BaseService {
|
||||
? (String) company.get("company_name")
|
||||
: companyCode;
|
||||
|
||||
// 부서 코드 생성
|
||||
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
|
||||
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
|
||||
String deptCode = "DEPT_" + nextNumber;
|
||||
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||
String parentCode = parentObj != null ? parentObj.toString() : null;
|
||||
validateParent(parentCode, companyCode);
|
||||
|
||||
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
|
||||
// 재시도 로직 (race condition 대비, 최대 3회)
|
||||
String deptCode = null;
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
|
||||
long nextNumber = codeResult != null && codeResult.get("next_number") != null
|
||||
? ((Number) codeResult.get("next_number")).longValue()
|
||||
: 1L;
|
||||
String candidate = "DEPT_" + nextNumber;
|
||||
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", candidate));
|
||||
if (existing == null) {
|
||||
deptCode = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (deptCode == null) {
|
||||
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
|
||||
}
|
||||
|
||||
// 부서 생성 (전체 필드)
|
||||
Map<String, Object> insertParams = new HashMap<>();
|
||||
@@ -76,36 +134,32 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("dept_name", deptName);
|
||||
insertParams.put("company_code", companyCode);
|
||||
insertParams.put("company_name", companyName);
|
||||
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
|
||||
insertParams.put("parent_dept_code", parentCode);
|
||||
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
|
||||
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
|
||||
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
|
||||
insertParams.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
|
||||
insertParams.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
|
||||
insertParams.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
|
||||
insertParams.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
|
||||
insertParams.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
|
||||
insertParams.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
|
||||
insertParams.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
|
||||
insertParams.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
|
||||
insertParams.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
|
||||
insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
|
||||
insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
|
||||
insertParams.put("status", bodyParam(body, "status", "status"));
|
||||
// dept_info 추가 필드 (master_*, location_*, data_type, sales_yn)
|
||||
insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
|
||||
insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
|
||||
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
|
||||
insertParams.put("data_type", bodyParam(body, "data_type", "data_type"));
|
||||
insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
|
||||
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
|
||||
@@ -115,70 +169,525 @@ public class DepartmentService extends BaseService {
|
||||
throw new IllegalArgumentException("부서명을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용)
|
||||
Map<String, Object> existingDept = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", deptCode)
|
||||
);
|
||||
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
|
||||
? existingDept.get("company_code").toString()
|
||||
: null;
|
||||
|
||||
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
|
||||
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||
String newParentCode = newParent != null ? newParent.toString() : null;
|
||||
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||
if (deptCompanyCode != null) {
|
||||
validateParent(newParentCode, deptCompanyCode);
|
||||
}
|
||||
verifyParentCycle(deptCode, newParentCode);
|
||||
|
||||
// 부서명 중복 검증 — 본인 dept_code 는 제외
|
||||
if (deptCompanyCode != null) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", deptCompanyCode);
|
||||
dupParams.put("dept_name", deptName);
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
params.put("dept_name", deptName);
|
||||
params.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
|
||||
params.put("parent_dept_code", newParent);
|
||||
params.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
|
||||
params.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
|
||||
params.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
|
||||
params.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
|
||||
params.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
|
||||
params.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
|
||||
params.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
|
||||
params.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
|
||||
params.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
|
||||
params.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
|
||||
params.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
|
||||
params.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
|
||||
params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
|
||||
params.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
|
||||
params.put("status", bodyParam(body, "status", "status"));
|
||||
// dept_info 추가 필드
|
||||
params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
|
||||
params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
|
||||
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
|
||||
params.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
|
||||
params.put("data_type", bodyParam(body, "data_type", "data_type"));
|
||||
params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
|
||||
|
||||
int updated = sqlSession.update("department.updateDepartment", params);
|
||||
if (updated == 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 soft-delete (V1 slim scope).
|
||||
* - hard delete 가 아니라 DELETED_AT = NOW() 로 마킹
|
||||
* - USER_DEPT 행은 보존 → 복구 시 멤버 그대로 살아남
|
||||
* - 활성 자식 부서가 있으면 차단 (deleted 자식은 무시)
|
||||
* - 반환: 0 = soft-delete 성공 (보존된 부서원 수는 복구 시점에 재조회)
|
||||
* -1 = not found / already deleted
|
||||
*/
|
||||
@Transactional
|
||||
public int deleteDepartment(String deptCode) {
|
||||
// 하위 부서 확인
|
||||
// 활성 하위 부서 확인 (deleted 자식은 자식 카운트에서 제외)
|
||||
Map<String, Object> childParams = new HashMap<>();
|
||||
childParams.put("dept_code", deptCode);
|
||||
childParams.put("include_deleted", false);
|
||||
Number childCountNum = sqlSession.selectOne("department.selectChildDeptCount", childParams);
|
||||
int childCount = childCountNum != null ? childCountNum.intValue() : 0;
|
||||
if (childCount > 0) {
|
||||
throw new IllegalStateException("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.");
|
||||
}
|
||||
|
||||
// 부서원 삭제
|
||||
Map<String, Object> memberParams = new HashMap<>();
|
||||
memberParams.put("dept_code", deptCode);
|
||||
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
|
||||
|
||||
// 부서 삭제
|
||||
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
|
||||
Map<String, Object> deptParams = new HashMap<>();
|
||||
deptParams.put("dept_code", deptCode);
|
||||
int deleted = sqlSession.delete("department.deleteDepartment", deptParams);
|
||||
if (deleted == 0) {
|
||||
return -1; // not found
|
||||
int updated = sqlSession.update("department.deleteDepartment", deptParams);
|
||||
if (updated == 0) {
|
||||
return -1; // not found 또는 이미 deleted
|
||||
}
|
||||
|
||||
log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount);
|
||||
return memberCount;
|
||||
log.info("부서 soft-delete 성공: deptCode={} (USER_DEPT 행 보존)", deptCode);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 복구 (V1 slim scope).
|
||||
* - DELETED_AT = NULL 로 되돌림
|
||||
* - 부모가 있고 부모도 deleted 상태면 차단 (orphan 방지)
|
||||
* - USER_DEPT 행은 soft-delete 시점부터 보존되어왔으므로 자동 복원됨
|
||||
*/
|
||||
@Transactional
|
||||
public RestoreResult restoreDepartment(String deptCode) {
|
||||
Map<String, Object> dept = getDepartmentIncludingDeleted(deptCode);
|
||||
if (dept == null) {
|
||||
return RestoreResult.NOT_FOUND;
|
||||
}
|
||||
if (dept.get("deleted_at") == null) {
|
||||
return RestoreResult.NOT_DELETED;
|
||||
}
|
||||
|
||||
// 부모 deleted 검증
|
||||
Object parentObj = dept.get("parent_dept_code");
|
||||
if (parentObj != null && !parentObj.toString().isBlank()) {
|
||||
String parentCode = parentObj.toString();
|
||||
Map<String, Object> parent = getDepartmentIncludingDeleted(parentCode);
|
||||
if (parent != null && parent.get("deleted_at") != null) {
|
||||
return RestoreResult.PARENT_DELETED;
|
||||
}
|
||||
}
|
||||
|
||||
// 동일 이름의 active 부서 중복 검증 (복구 시점)
|
||||
Object companyCodeObj = dept.get("company_code");
|
||||
Object deptNameObj = dept.get("dept_name");
|
||||
if (companyCodeObj != null && deptNameObj != null) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCodeObj.toString());
|
||||
dupParams.put("dept_name", deptNameObj.toString());
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
int restored = sqlSession.update("department.restoreDepartment", params);
|
||||
if (restored == 0) {
|
||||
return RestoreResult.NOT_DELETED; // race: 동시 복구
|
||||
}
|
||||
|
||||
log.info("부서 복구 성공: deptCode={}", deptCode);
|
||||
return RestoreResult.OK;
|
||||
}
|
||||
|
||||
public enum RestoreResult {
|
||||
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
|
||||
}
|
||||
|
||||
/**
|
||||
* parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증.
|
||||
* null/blank 면 검증 스킵 (최상위 부서).
|
||||
*/
|
||||
private void validateParent(String parentCode, String companyCode) {
|
||||
if (parentCode == null || parentCode.isBlank()) return;
|
||||
Map<String, Object> parent = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", parentCode)
|
||||
);
|
||||
if (parent == null) {
|
||||
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
|
||||
}
|
||||
if (parent.get("deleted_at") != null) {
|
||||
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
|
||||
}
|
||||
Object parentCompany = parent.get("company_code");
|
||||
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
|
||||
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* parent_dept_code 변경 시 사이클 검증.
|
||||
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
|
||||
* 체인에 deptCode 자체가 들어있다면 사이클이 생기므로 차단.
|
||||
* (newParent == null 은 최상위로 만들기 — 항상 안전)
|
||||
*/
|
||||
private void verifyParentCycle(String deptCode, String newParent) {
|
||||
if (newParent == null) return;
|
||||
if (newParent.equals(deptCode)) {
|
||||
throw new IllegalArgumentException("자기 자신을 상위 부서로 지정할 수 없습니다.");
|
||||
}
|
||||
Set<String> visited = new HashSet<>();
|
||||
String cur = newParent;
|
||||
while (cur != null && !visited.contains(cur)) {
|
||||
if (deptCode.equals(cur)) {
|
||||
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
|
||||
}
|
||||
visited.add(cur);
|
||||
Map<String, Object> p = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", cur)
|
||||
);
|
||||
if (p == null) break;
|
||||
Object parent = p.get("parent_dept_code");
|
||||
cur = parent != null ? parent.toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 (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;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -234,19 +743,47 @@ public class DepartmentService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public boolean removeDeptMember(String deptCode, String userId) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("dept_code", deptCode);
|
||||
int deleted = sqlSession.delete("department.deleteDeptMember", params);
|
||||
// 1. 제거 전 — 이 row 가 primary 였는지 확인
|
||||
Map<String, Object> existParams = new HashMap<>();
|
||||
existParams.put("user_id", userId);
|
||||
existParams.put("dept_code", deptCode);
|
||||
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
|
||||
boolean wasPrimary = existing != null && Boolean.TRUE.equals(existing.get("is_primary"));
|
||||
|
||||
// 2. 제거
|
||||
int deleted = sqlSession.delete("department.deleteDeptMember", existParams);
|
||||
if (deleted == 0) {
|
||||
return false;
|
||||
}
|
||||
log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode);
|
||||
|
||||
// 3. primary 였으면 다른 USER_DEPT row 중 하나 promote
|
||||
if (wasPrimary) {
|
||||
Map<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
|
||||
Map.of("user_id", userId));
|
||||
if (remaining != null && remaining.get("dept_code") != null) {
|
||||
Map<String, Object> promote = new HashMap<>();
|
||||
promote.put("user_id", userId);
|
||||
promote.put("dept_code", remaining.get("dept_code").toString());
|
||||
sqlSession.update("department.setUserPrimaryDept", promote);
|
||||
log.info("주 부서 자동 승격: userId={}, newPrimaryDept={}", userId, remaining.get("dept_code"));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("부서원 제거 성공: userId={}, deptCode={}, wasPrimary={}", userId, deptCode, wasPrimary);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setPrimaryDept(String deptCode, String userId) {
|
||||
// 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지
|
||||
Map<String, Object> existParams = new HashMap<>();
|
||||
existParams.put("user_id", userId);
|
||||
existParams.put("dept_code", deptCode);
|
||||
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
|
||||
}
|
||||
|
||||
// 다른 부서의 주 부서 해제
|
||||
Map<String, Object> clearParams = new HashMap<>();
|
||||
clearParams.put("user_id", userId);
|
||||
@@ -277,13 +814,118 @@ public class DepartmentService extends BaseService {
|
||||
return val != null ? val : body.get(camelCase);
|
||||
}
|
||||
|
||||
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
|
||||
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
|
||||
private Object nullIfBlank(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof String s && s.trim().isEmpty()) return null;
|
||||
if (value instanceof String s) {
|
||||
String trimmed = s.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class FavoritesService extends BaseService {
|
||||
|
||||
public List<Map<String, Object>> getFavoriteMenuList(Map<String, Object> params) {
|
||||
return sqlSession.selectList("favorites.selectFavoriteMenuList", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertFavorite(Map<String, Object> params) {
|
||||
sqlSession.insert("favorites.insertFavorite", params);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("user_id", params.get("user_id"));
|
||||
result.put("menu_objid", params.get("menu_objid"));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteFavorite(Map<String, Object> params) {
|
||||
return sqlSession.delete("favorites.deleteFavorite", params);
|
||||
}
|
||||
|
||||
public boolean exists(Map<String, Object> params) {
|
||||
Integer cnt = sqlSession.selectOne("favorites.selectFavoriteExists", params);
|
||||
return cnt != null && cnt > 0;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 대무자(代務者) 관리 서비스.
|
||||
*
|
||||
* Spec: .omc/specs/deep-dive-user-substitute-management.md
|
||||
* Plan: .omc/plans/autopilot-impl.md (T3)
|
||||
*
|
||||
* 핵심 규칙:
|
||||
* - 관리자만 위임 지정/수정/해지. 본인 self-위임 불가.
|
||||
* - 종료일 필수, 시작일 옵션 (비우면 즉시).
|
||||
* - 같은 (COMPANY, ORIGINAL, PROXY) 쌍의 활성 기간 겹침 금지 (DB EXCLUDE + 사전 검증).
|
||||
* - 같은 회사 사용자끼리만. SUPER_ADMIN 은 대무자로 지정 불가.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SubstituteService extends BaseService {
|
||||
|
||||
private static final String NS = "substitute.";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 조회
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> getSubstituteList(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
|
||||
List<Map<String, Object>> list = sqlSession.selectList(NS + "selectSubstituteList", params);
|
||||
Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", list);
|
||||
result.put("total", total == null ? 0 : total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileModal read-only: 내가 위임한(proxying_for_me) + 나를 대무 중인(my_proxies) 두 방향 한 번에.
|
||||
* 결과를 Java 단에서 partition.
|
||||
*/
|
||||
public Map<String, Object> getMySubstitutes(Map<String, Object> params) {
|
||||
if (params.get("user_id") == null) {
|
||||
throw new IllegalArgumentException("user_id 가 필요합니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectMySubstitutes", params);
|
||||
|
||||
List<Map<String, Object>> proxyingForMe = new ArrayList<>();
|
||||
List<Map<String, Object>> myProxies = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object relation = row.get("relation");
|
||||
if ("proxying_for_me".equals(relation)) {
|
||||
proxyingForMe.add(row);
|
||||
} else if ("my_proxies".equals(relation)) {
|
||||
myProxies.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("proxying_for_me", proxyingForMe);
|
||||
result.put("my_proxies", myProxies);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubstituteContextFilter 핫 패스. 트랜잭션 없이 가볍게.
|
||||
* 반환: B 가 현재 대무 중인 A 의 ID 목록 (없으면 빈 리스트).
|
||||
*/
|
||||
public List<String> getActiveOriginalUserIds(String proxyUserId, String companyCode) {
|
||||
if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("proxy_user_id", proxyUserId);
|
||||
p.put("company_code", companyCode);
|
||||
List<String> ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p);
|
||||
return ids == null ? List.of() : ids;
|
||||
}
|
||||
|
||||
public Map<String, Object> getSubstituteInfo(Map<String, Object> params) {
|
||||
Map<String, Object> row = sqlSession.selectOne(NS + "selectSubstituteInfo", params);
|
||||
if (row == null) {
|
||||
throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다.");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* ApprovalService 어댑터: B 가 A 의 대무자로 활성 상태인지 검증.
|
||||
* 결재 처리 중 호출.
|
||||
*/
|
||||
public Map<String, Object> getActiveProxyForLine(Map<String, Object> params) {
|
||||
return sqlSession.selectOne(NS + "selectActiveProxyForLine", params);
|
||||
}
|
||||
|
||||
public int checkOverlap(Map<String, Object> params) {
|
||||
Integer cnt = sqlSession.selectOne(NS + "countOverlap", params);
|
||||
return cnt == null ? 0 : cnt;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 변경
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> insertSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
validateInsertParams(params);
|
||||
|
||||
sqlSession.insert(NS + "insertSubstitute", params);
|
||||
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
info.put("substitute_id", params.get("substitute_id"));
|
||||
info.put("company_code", params.get("company_code"));
|
||||
return getSubstituteInfo(info);
|
||||
}
|
||||
|
||||
public Map<String, Object> updateSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
|
||||
Map<String, Object> existing = getSubstituteInfo(params);
|
||||
|
||||
// 변경되는 사용자 ID 가 있으면 회사 소속 + SUPER_ADMIN 검증
|
||||
Object newProxy = params.get("proxy_user_id");
|
||||
if (newProxy != null && !newProxy.equals(existing.get("proxy_user_id"))) {
|
||||
validateUserInCompany((String) newProxy, (String) params.get("company_code"), "proxy");
|
||||
rejectSuperAdminAsProxy((String) newProxy);
|
||||
}
|
||||
|
||||
// 기간/대무자 변경 시 겹침 재검증
|
||||
if (params.get("start_date") != null || params.get("end_date") != null
|
||||
|| params.get("clear_start_date") != null || newProxy != null) {
|
||||
Map<String, Object> overlapParams = new HashMap<>();
|
||||
overlapParams.put("company_code", params.get("company_code"));
|
||||
overlapParams.put("original_user_id", existing.get("original_user_id"));
|
||||
overlapParams.put("proxy_user_id",
|
||||
newProxy != null ? newProxy : existing.get("proxy_user_id"));
|
||||
overlapParams.put("start_date",
|
||||
Boolean.TRUE.equals(params.get("clear_start_date")) ? null
|
||||
: (params.get("start_date") != null ? params.get("start_date")
|
||||
: existing.get("start_date")));
|
||||
overlapParams.put("end_date",
|
||||
params.get("end_date") != null ? params.get("end_date")
|
||||
: existing.get("end_date"));
|
||||
overlapParams.put("exclude_substitute_id", params.get("substitute_id"));
|
||||
if (checkOverlap(overlapParams) > 0) {
|
||||
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
int updated = sqlSession.update(NS + "updateSubstitute", params);
|
||||
if (updated == 0) {
|
||||
throw new IllegalArgumentException("대무 설정 수정에 실패했습니다.");
|
||||
}
|
||||
return getSubstituteInfo(params);
|
||||
}
|
||||
|
||||
public void deleteSubstitute(Map<String, Object> params) {
|
||||
requireAdmin(params);
|
||||
getSubstituteInfo(params); // 존재 확인
|
||||
sqlSession.delete(NS + "deleteSubstitute", params);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 검증
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void validateInsertParams(Map<String, Object> params) {
|
||||
String companyCode = (String) params.get("company_code");
|
||||
String original = (String) params.get("original_user_id");
|
||||
String proxy = (String) params.get("proxy_user_id");
|
||||
Object endDate = params.get("end_date");
|
||||
|
||||
if (companyCode == null || companyCode.isBlank()) {
|
||||
throw new IllegalArgumentException("회사 코드가 필요합니다.");
|
||||
}
|
||||
if (original == null || original.isBlank()) {
|
||||
throw new IllegalArgumentException("위임자(대상 사용자) 가 필요합니다.");
|
||||
}
|
||||
if (proxy == null || proxy.isBlank()) {
|
||||
throw new IllegalArgumentException("대무자가 필요합니다.");
|
||||
}
|
||||
if (original.equals(proxy)) {
|
||||
throw new IllegalArgumentException("본인을 자기 대무자로 지정할 수 없습니다.");
|
||||
}
|
||||
if (endDate == null || (endDate instanceof String && ((String) endDate).isBlank())) {
|
||||
throw new IllegalArgumentException("종료일은 필수입니다 (무기한 대무 금지).");
|
||||
}
|
||||
|
||||
// B3: 같은 회사 소속 검증
|
||||
validateUserInCompany(original, companyCode, "original");
|
||||
validateUserInCompany(proxy, companyCode, "proxy");
|
||||
|
||||
// SUPER_ADMIN 을 대무자로 지정 금지
|
||||
rejectSuperAdminAsProxy(proxy);
|
||||
|
||||
// 사전 겹침 검증
|
||||
Map<String, Object> overlapParams = new HashMap<>();
|
||||
overlapParams.put("company_code", companyCode);
|
||||
overlapParams.put("original_user_id", original);
|
||||
overlapParams.put("proxy_user_id", proxy);
|
||||
overlapParams.put("start_date", params.get("start_date"));
|
||||
overlapParams.put("end_date", endDate);
|
||||
if (checkOverlap(overlapParams) > 0) {
|
||||
throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUserInCompany(String userId, String companyCode, String which) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", userId);
|
||||
p.put("company_code", companyCode);
|
||||
Integer cnt = sqlSession.selectOne(NS + "countUserInCompany", p);
|
||||
if (cnt == null || cnt == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"original".equals(which)
|
||||
? "대상 사용자가 회사에 존재하지 않습니다."
|
||||
: "대무자가 회사에 존재하지 않습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void rejectSuperAdminAsProxy(String userId) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", userId);
|
||||
Integer cnt = sqlSession.selectOne(NS + "countSuperAdmin", p);
|
||||
if (cnt != null && cnt > 0) {
|
||||
throw new IllegalArgumentException("SUPER_ADMIN 은 대무자로 지정할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdmin(Map<String, Object> params) {
|
||||
String role = (String) params.get("role");
|
||||
if (!"ADMIN".equals(role) && !"COMPANY_ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
|
||||
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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,6 +28,16 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
private static final String NS = "tableManagement.";
|
||||
|
||||
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
|
||||
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
|
||||
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
|
||||
"varchar", "text", "char", "character", "character varying",
|
||||
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
|
||||
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
|
||||
"time", "time without time zone", "time with time zone",
|
||||
"uuid", "json", "jsonb", "bytea"
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 테이블 목록
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -145,20 +157,40 @@ public class TableManagementService extends BaseService {
|
||||
Map<String, Object> settings, String companyCode) {
|
||||
ensureTableInLabels(tableName);
|
||||
|
||||
String inputType = normalizeInputType((String) settings.get("input_type"));
|
||||
Object rawInputType = settings.get("input_type");
|
||||
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
|
||||
InputTypeContext ctx = inputTypeChanged
|
||||
? InputTypeContext.USER_UPDATE_TYPE
|
||||
: InputTypeContext.USER_UPDATE_OTHER;
|
||||
String inputType = normalizeInputType((String) rawInputType, ctx);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
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);
|
||||
@@ -183,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);
|
||||
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
@@ -366,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<>();
|
||||
|
||||
@@ -455,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);
|
||||
@@ -603,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);
|
||||
@@ -617,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(
|
||||
@@ -634,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);
|
||||
@@ -848,12 +1271,43 @@ 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" 변환 */
|
||||
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||
private String normalizeInputType(String inputType) {
|
||||
if ("direct".equals(inputType) || "auto".equals(inputType)) {
|
||||
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
|
||||
@@ -862,6 +1316,23 @@ public class TableManagementService extends BaseService {
|
||||
return inputType != null ? inputType : "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
||||
*/
|
||||
private String normalizeInputType(String value, InputTypeContext context) {
|
||||
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
|
||||
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
||||
throw new IllegalArgumentException(
|
||||
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
||||
+ " (받은 값: " + value + ")"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
|
||||
return normalizeInputType(value);
|
||||
}
|
||||
|
||||
private String toJsonString(Object obj) {
|
||||
if (obj == null) return "{}";
|
||||
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
|
||||
|
||||
@@ -85,7 +85,11 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
|
||||
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/bare localhost/예약어 제외.
|
||||
*
|
||||
* 운영 (3파트, e.g. qnc.invyone.com) → 첫 파트
|
||||
* dev (2파트, {sub}.localhost) → 첫 파트 (RFC 6761, 별도 DNS 불필요)
|
||||
* 그 외 (invyone.com 같은 베이스 / bare localhost / IP) → null (META)
|
||||
*/
|
||||
static String extractSubdomain(String host) {
|
||||
if (host == null || host.isBlank()) return null;
|
||||
@@ -99,8 +103,18 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||
if (IPV4.matcher(host).matches()) return null;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length < 3) return null; // invyone.com (2파트) → null
|
||||
|
||||
// 2파트 — "{sub}.localhost" 만 허용 (dev 전용). invyone.com 같은 베이스 도메인은 null.
|
||||
if (parts.length == 2) {
|
||||
if (!"localhost".equals(parts[1])) return null;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return null;
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
// 3파트 이상 (운영) — 첫 번째 파트가 서브도메인
|
||||
if (parts.length < 3) return null;
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ cors:
|
||||
# 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨.
|
||||
# Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지
|
||||
# 않도록 반드시 따옴표로 감싸기.
|
||||
# dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴.
|
||||
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
|
||||
# dev 디폴트: localhost + 사무실 Tailscale IP + *.localhost 테넌트 (RFC 6761) + 테넌트 서브도메인 (모든 포트) 패턴.
|
||||
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.localhost:[*],http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
|
||||
|
||||
file:
|
||||
upload-dir: ./uploads
|
||||
|
||||
+10
-6
@@ -14,15 +14,19 @@
|
||||
--
|
||||
-- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE
|
||||
-- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음.
|
||||
--
|
||||
-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로
|
||||
-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다.
|
||||
-- → THEN 값은 반드시 문자열 리터럴로 줄 것.
|
||||
|
||||
UPDATE MENU_INFO
|
||||
SET SEQ = CASE MENU_NAME_KOR
|
||||
WHEN '회사관리' THEN 100
|
||||
WHEN '부서관리' THEN 200
|
||||
WHEN '사용자관리' THEN 300
|
||||
WHEN '메뉴관리' THEN 400
|
||||
WHEN '권한관리' THEN 500
|
||||
WHEN '권한 그룹관리' THEN 600
|
||||
WHEN '회사관리' THEN '100'
|
||||
WHEN '부서관리' THEN '200'
|
||||
WHEN '사용자관리' THEN '300'
|
||||
WHEN '메뉴관리' THEN '400'
|
||||
WHEN '권한관리' THEN '500'
|
||||
WHEN '권한 그룹관리' THEN '600'
|
||||
ELSE SEQ
|
||||
END
|
||||
WHERE MENU_TYPE = '0'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- V018: invyone 부서관리 V1 - soft-delete
|
||||
-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경.
|
||||
-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경.
|
||||
-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전.
|
||||
--
|
||||
-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가
|
||||
-- 동일 statement 를 부팅 시점에 적용.
|
||||
--
|
||||
-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체,
|
||||
-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리.
|
||||
|
||||
-- (1) DEPT_INFO 소프트삭제 컬럼
|
||||
ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL;
|
||||
|
||||
-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
|
||||
CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE
|
||||
ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE)
|
||||
WHERE DELETED_AT IS NULL;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- V019: 부서관리 미사용/중복 컬럼 정리
|
||||
-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복.
|
||||
-- DROP IF EXISTS 로 멱등성 보장.
|
||||
--
|
||||
-- 대상 컬럼 (8개):
|
||||
-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복)
|
||||
-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출)
|
||||
-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명)
|
||||
-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지)
|
||||
-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복)
|
||||
-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직)
|
||||
-- ERP_MANAGED - ERP 관리 (분기 로직 없음)
|
||||
-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌)
|
||||
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED;
|
||||
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- V020: 사용자별 메뉴 즐겨찾기 테이블
|
||||
-- 로그인 사용자가 사이드바 메뉴 항목을 즐겨찾기에 등록/해제하면 한 행씩 쌓이고,
|
||||
-- 사이드바 최상단 '즐겨찾기' 섹션이 이 행들을 읽어 표시한다.
|
||||
-- 테넌트 DB 별로 격리 (회사마다 메뉴가 달라 cross-tenant 공용으로 묶지 않음).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
|
||||
OBJID BIGSERIAL PRIMARY KEY,
|
||||
USER_ID VARCHAR(100) NOT NULL,
|
||||
MENU_OBJID VARCHAR(50) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER
|
||||
ON USER_MENU_FAVORITES (USER_ID);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가
|
||||
-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다.
|
||||
-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도
|
||||
-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다.
|
||||
|
||||
ALTER TABLE BATCH_MAPPINGS
|
||||
ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- =================================================================
|
||||
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
|
||||
-- =================================================================
|
||||
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
|
||||
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
|
||||
-- 멱등: IF NOT EXISTS 로 재실행 안전.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
|
||||
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
|
||||
@@ -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'
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Cross-tenant 어드민 합산 전용 mapper.
|
||||
|
||||
핵심 규칙 (notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §6.2):
|
||||
- SELECT 만. UPDATE/DELETE/INSERT 금지 (수정은 회사 도메인 컨텍스트로 위임).
|
||||
- WHERE 에 COMPANY_CODE 필터 박지 말 것 — fan-out 시 각 회사 DB 안에서만 실행되므로
|
||||
그 회사 데이터로 정의상 한정됨.
|
||||
- SELECT 절에도 COMPANY_CODE 박지 말 것 — Aggregator 가 메타 DB 라우팅 정보 기준으로
|
||||
응답 행에 박아준다 (회사 DB 안에 저장된 stale COMPANY_CODE 값 우선시 금지).
|
||||
- JOIN 은 회사 DB 내부 테이블끼리만. 메타 DB 조인 금지.
|
||||
|
||||
namespace 단일: "admin-cross-tenant"
|
||||
-->
|
||||
<mapper namespace="admin-cross-tenant">
|
||||
|
||||
<!--
|
||||
사용자 목록 — 단일 회사 admin.selectUserList 와 컬럼 동일 (COMPANY_CODE 제외).
|
||||
company_code 는 Aggregator 가 응답에 박는다.
|
||||
cross-tenant 1차 구현은 페이지네이션 없이 회사당 전체 반환 (설계서 §9.3).
|
||||
필터는 search / status / dept_code 만 지원 (단일 회사 화면과 동일).
|
||||
-->
|
||||
<select id="listUsers" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
SABUN AS sabun
|
||||
, USER_ID AS user_id
|
||||
, USER_NAME AS user_name
|
||||
, COALESCE(USER_NAME_ENG, '') AS user_name_eng
|
||||
, COALESCE(DEPT_CODE, '') AS dept_code
|
||||
, COALESCE(DEPT_NAME, '') AS dept_name
|
||||
, COALESCE(POSITION_CODE, '') AS position_code
|
||||
, COALESCE(POSITION_NAME, '') AS position_name
|
||||
, COALESCE(EMAIL, '') AS email
|
||||
, COALESCE(TEL, '') AS tel
|
||||
, COALESCE(CELL_PHONE, '') AS cell_phone
|
||||
, COALESCE(USER_TYPE, '') AS user_type
|
||||
, COALESCE(USER_TYPE_NAME, '') AS user_type_name
|
||||
, COALESCE(TO_CHAR(CREATED_DATE, 'YYYY-MM-DD'), '') AS reg_date
|
||||
, STATUS AS status
|
||||
, COALESCE(LOCALE, '') AS locale
|
||||
FROM USER_INFO
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND (USER_ID ILIKE #{search}
|
||||
OR USER_NAME ILIKE #{search}
|
||||
OR DEPT_NAME ILIKE #{search}
|
||||
OR POSITION_NAME ILIKE #{search}
|
||||
OR USER_TYPE_NAME ILIKE #{search}
|
||||
OR SABUN ILIKE #{search}
|
||||
OR EMAIL ILIKE #{search}
|
||||
OR TEL ILIKE #{search}
|
||||
OR CELL_PHONE ILIKE #{search})
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
AND STATUS = #{status}
|
||||
</if>
|
||||
<if test="dept_code != null and dept_code != ''">
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC, USER_NAME ASC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
권한 그룹 목록 — 단일 회사 role.getRoleGroupList 와 컬럼 동일 (COMPANY_CODE 제외).
|
||||
member_count / menu_count 서브쿼리 그대로 유지. 회사 DB 안에서 동작하므로
|
||||
그 회사의 AUTHORITY_SUB_USER / AUTHORITY_SUB_MENU 만 카운트됨.
|
||||
-->
|
||||
<select id="listRoleGroups" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
AM.OBJID AS objid
|
||||
, AM.AUTH_NAME AS auth_name
|
||||
, AM.AUTH_CODE AS auth_code
|
||||
, AM.STATUS AS status
|
||||
, AM.WRITER AS writer
|
||||
, AM.CREATED_DATE AS created_date
|
||||
, (SELECT COUNT(*) FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = AM.OBJID) AS member_count
|
||||
, (SELECT COUNT(*) FROM AUTHORITY_SUB_MENU WHERE MASTER_OBJID = AM.OBJID) AS menu_count
|
||||
FROM AUTHORITY_MASTER AM
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND AM.AUTH_NAME ILIKE #{search}
|
||||
</if>
|
||||
ORDER BY AM.CREATED_DATE DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
배치 목록 — 단일 회사 batch.getBatchList 와 동일하게 SELECT *.
|
||||
PostgreSQL 컬럼명이 그대로 lowercase Map key 로 떨어짐.
|
||||
페이지네이션은 cross-tenant 1차 구현엔 비지원 (회사당 전체 반환).
|
||||
-->
|
||||
<select id="listBatches" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM BATCH_CONFIGS
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND (BATCH_NAME ILIKE '%' || #{search} || '%'
|
||||
OR DESCRIPTION ILIKE '%' || #{search} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
다국어 키 목록 — 단일 회사 multilang.getMultilangKeyList 와 컬럼 동일.
|
||||
회사 DB 안에서 동작하므로 filter_company_code 분기 불필요 (그 회사 데이터로 정의상 한정).
|
||||
1차 구현은 category_id 재귀 필터 비지원 — 필요해지면 후속 추가.
|
||||
-->
|
||||
<select id="listLangKeys" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
KEY_ID AS key_id
|
||||
, USAGE_NOTE AS usage_note
|
||||
, LANG_KEY AS lang_key
|
||||
, DESCRIPTION AS description
|
||||
, IS_ACTIVE AS is_active
|
||||
, CATEGORY_ID AS category_id
|
||||
, CREATED_DATE AS created_date
|
||||
, CREATED_BY AS created_by
|
||||
, UPDATED_DATE AS updated_date
|
||||
, UPDATED_BY AS updated_by
|
||||
FROM MULTI_LANG_KEY_MASTER
|
||||
WHERE 1=1
|
||||
<if test="menu_code != null and menu_code != ''">
|
||||
AND USAGE_NOTE = #{menu_code}
|
||||
</if>
|
||||
<if test="search != null and search != ''">
|
||||
AND (LANG_KEY ILIKE #{search}
|
||||
OR DESCRIPTION ILIKE #{search})
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC, KEY_ID DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -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>
|
||||
|
||||
@@ -728,14 +716,9 @@
|
||||
DEPT_CODE
|
||||
, PARENT_DEPT_CODE
|
||||
, DEPT_NAME
|
||||
, MASTER_SABUN
|
||||
, MASTER_USER_ID
|
||||
, LOCATION
|
||||
, LOCATION_NAME
|
||||
, CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE
|
||||
, DATA_TYPE
|
||||
, STATUS
|
||||
, SALES_YN
|
||||
, COMPANY_CODE
|
||||
, COMPANY_NAME
|
||||
FROM DEPT_INFO
|
||||
@@ -746,8 +729,7 @@
|
||||
</if>
|
||||
<if test="search != null and search != ''">
|
||||
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
|
||||
OR DEPT_CODE ILIKE '%' || #{search} || '%'
|
||||
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
|
||||
OR DEPT_CODE ILIKE '%' || #{search} || '%')
|
||||
</if>
|
||||
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
|
||||
</select>
|
||||
|
||||
@@ -214,12 +214,15 @@
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM APPROVAL_LINES L
|
||||
WHERE L.REQUEST_ID = R.REQUEST_ID
|
||||
AND L.APPROVER_ID = #{user_id}
|
||||
AND L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
)
|
||||
</if>
|
||||
ORDER BY R.CREATED_DATE DESC
|
||||
ORDER BY R.CREATED_AT DESC
|
||||
<if test="page_limit != null">
|
||||
LIMIT #{page_limit} OFFSET #{page_offset}
|
||||
</if>
|
||||
@@ -248,7 +251,10 @@
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM APPROVAL_LINES L
|
||||
WHERE L.REQUEST_ID = R.REQUEST_ID
|
||||
AND L.APPROVER_ID = #{user_id}
|
||||
AND L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
)
|
||||
@@ -459,14 +465,17 @@
|
||||
SELECT L.*,
|
||||
R.TITLE, R.TARGET_TABLE, R.TARGET_RECORD_ID,
|
||||
R.REQUESTER_NAME, R.REQUESTER_DEPT,
|
||||
R.CREATED_DATE AS REQUEST_CREATED_DATE
|
||||
R.CREATED_AT AS REQUEST_CREATED_DATE
|
||||
FROM APPROVAL_LINES L
|
||||
JOIN APPROVAL_REQUESTS R
|
||||
ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE
|
||||
WHERE L.APPROVER_ID = #{user_id}
|
||||
WHERE L.APPROVER_ID IN
|
||||
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
|
||||
#{uid}
|
||||
</foreach>
|
||||
AND L.STATUS = 'pending'
|
||||
AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*')
|
||||
ORDER BY R.CREATED_DATE ASC
|
||||
ORDER BY R.CREATED_AT ASC
|
||||
</select>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -536,12 +545,14 @@
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</delete>
|
||||
|
||||
<!-- 어댑터: USER_SUBSTITUTES 참조 (T7, 086 마이그레이션 이후). -->
|
||||
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
|
||||
SELECT * FROM APPROVAL_PROXY_SETTINGS
|
||||
SELECT *
|
||||
FROM USER_SUBSTITUTES
|
||||
WHERE ORIGINAL_USER_ID = #{original_user_id}
|
||||
AND PROXY_USER_ID = #{proxy_user_id}
|
||||
AND IS_ACTIVE = 'Y'
|
||||
AND START_DATE <= CURRENT_DATE
|
||||
AND IS_ACTIVE = TRUE
|
||||
AND (START_DATE IS NULL OR START_DATE <= CURRENT_DATE)
|
||||
AND END_DATE >= CURRENT_DATE
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
LIMIT 1
|
||||
|
||||
@@ -143,18 +143,31 @@
|
||||
ORDER BY COUNT DESC, U.USER_NAME ASC
|
||||
</select>
|
||||
|
||||
<!-- 감사 로그 INSERT -->
|
||||
<!-- 감사 로그 INSERT.
|
||||
PROCESSOR_ID/PROCESSOR_NAME 은 대무(代務) 처리 추적용 (086 마이그레이션 이후).
|
||||
평시는 USER_ID == PROCESSOR_ID. -->
|
||||
<insert id="insertAuditLog" parameterType="map">
|
||||
INSERT INTO SYSTEM_AUDIT_LOG (
|
||||
COMPANY_CODE, USER_ID, USER_NAME, ACTION, RESOURCE_TYPE,
|
||||
RESOURCE_ID, RESOURCE_NAME, TABLE_NAME, SUMMARY, CHANGES,
|
||||
IP_ADDRESS, REQUEST_PATH
|
||||
IP_ADDRESS, REQUEST_PATH,
|
||||
PROCESSOR_ID, PROCESSOR_NAME
|
||||
) VALUES (
|
||||
#{company_code}, #{user_id}, #{user_name}, #{action}, #{resource_type},
|
||||
#{resource_id}, #{resource_name}, #{table_name}, #{summary},
|
||||
CAST(#{changes} AS JSONB),
|
||||
#{ip_address}, #{request_path}
|
||||
#{ip_address}, #{request_path},
|
||||
#{processor_id}, #{processor_name}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 처리자 이름 lookup (대무 시 USER_INFO 에서 1회 조회). -->
|
||||
<select id="selectUserNameById" parameterType="map" resultType="string">
|
||||
SELECT USER_NAME
|
||||
FROM USER_INFO
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -102,6 +102,117 @@
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
</delete>
|
||||
|
||||
<!-- batch_mappings: 특정 batch_config_id 의 매핑 행들 조회 -->
|
||||
<select id="getBatchMappingsByConfigId" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
ID
|
||||
, BATCH_CONFIG_ID
|
||||
, COMPANY_CODE
|
||||
, FROM_CONNECTION_TYPE
|
||||
, FROM_CONNECTION_ID
|
||||
, FROM_TABLE_NAME
|
||||
, FROM_COLUMN_NAME
|
||||
, FROM_COLUMN_TYPE
|
||||
, FROM_API_URL
|
||||
, FROM_API_KEY
|
||||
, FROM_API_METHOD
|
||||
, FROM_API_PARAM_TYPE
|
||||
, FROM_API_PARAM_NAME
|
||||
, FROM_API_PARAM_VALUE
|
||||
, FROM_API_PARAM_SOURCE
|
||||
, FROM_API_BODY
|
||||
, TO_CONNECTION_TYPE
|
||||
, TO_CONNECTION_ID
|
||||
, TO_TABLE_NAME
|
||||
, TO_COLUMN_NAME
|
||||
, TO_COLUMN_TYPE
|
||||
, TO_API_URL
|
||||
, TO_API_KEY
|
||||
, TO_API_METHOD
|
||||
, TO_API_BODY
|
||||
, MAPPING_ORDER
|
||||
, MAPPING_TYPE
|
||||
, MAPPING_CONFIG::TEXT AS MAPPING_CONFIG
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
FROM BATCH_MAPPINGS
|
||||
WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
|
||||
ORDER BY MAPPING_ORDER, ID
|
||||
</select>
|
||||
|
||||
<!-- batch_mappings: 단건 INSERT (replace-all 패턴이라 INSERT/DELETE 만 사용) -->
|
||||
<insert id="insertBatchMapping" parameterType="map" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
|
||||
INSERT INTO BATCH_MAPPINGS (
|
||||
BATCH_CONFIG_ID
|
||||
, COMPANY_CODE
|
||||
, FROM_CONNECTION_TYPE
|
||||
, FROM_CONNECTION_ID
|
||||
, FROM_TABLE_NAME
|
||||
, FROM_COLUMN_NAME
|
||||
, FROM_COLUMN_TYPE
|
||||
, FROM_API_URL
|
||||
, FROM_API_KEY
|
||||
, FROM_API_METHOD
|
||||
, FROM_API_PARAM_TYPE
|
||||
, FROM_API_PARAM_NAME
|
||||
, FROM_API_PARAM_VALUE
|
||||
, FROM_API_PARAM_SOURCE
|
||||
, FROM_API_BODY
|
||||
, TO_CONNECTION_TYPE
|
||||
, TO_CONNECTION_ID
|
||||
, TO_TABLE_NAME
|
||||
, TO_COLUMN_NAME
|
||||
, TO_COLUMN_TYPE
|
||||
, TO_API_URL
|
||||
, TO_API_KEY
|
||||
, TO_API_METHOD
|
||||
, TO_API_BODY
|
||||
, MAPPING_ORDER
|
||||
, MAPPING_TYPE
|
||||
, MAPPING_CONFIG
|
||||
, CREATED_BY
|
||||
, CREATED_DATE
|
||||
) VALUES (
|
||||
#{batch_config_id}::varchar
|
||||
, #{company_code}
|
||||
, #{from_connection_type}
|
||||
, #{from_connection_id}
|
||||
, #{from_table_name}
|
||||
, #{from_column_name}
|
||||
, #{from_column_type}
|
||||
, #{from_api_url}
|
||||
, #{from_api_key}
|
||||
, #{from_api_method}
|
||||
, #{from_api_param_type}
|
||||
, #{from_api_param_name}
|
||||
, #{from_api_param_value}
|
||||
, #{from_api_param_source}
|
||||
, #{from_api_body}
|
||||
, #{to_connection_type}
|
||||
, #{to_connection_id}
|
||||
, #{to_table_name}
|
||||
, #{to_column_name}
|
||||
, #{to_column_type}
|
||||
, #{to_api_url}
|
||||
, #{to_api_key}
|
||||
, #{to_api_method}
|
||||
, #{to_api_body}
|
||||
, #{mapping_order}
|
||||
, <choose>
|
||||
<when test="mapping_type != null and mapping_type != ''">#{mapping_type}</when>
|
||||
<otherwise>'direct'</otherwise>
|
||||
</choose>
|
||||
, #{mapping_config,jdbcType=OTHER}::jsonb
|
||||
, #{created_by}
|
||||
, NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- batch_mappings: 특정 batch_config_id 의 매핑 전부 삭제 (replace-all 의 앞단계) -->
|
||||
<delete id="deleteBatchMappingsByConfigId" parameterType="map">
|
||||
DELETE FROM BATCH_MAPPINGS WHERE BATCH_CONFIG_ID = #{batch_config_id}::varchar
|
||||
</delete>
|
||||
|
||||
<!-- 내부 DB 테이블 목록 조회 -->
|
||||
<select id="getInternalTables" resultType="map">
|
||||
SELECT
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<sql id="batchExecutionLogSearchCondition">
|
||||
<if test="batch_config_id != null">
|
||||
AND bel.batch_config_id = #{batch_config_id}
|
||||
AND bel.batch_config_id = #{batch_config_id}::varchar
|
||||
</if>
|
||||
<if test="execution_status != null and execution_status != ''">
|
||||
AND bel.execution_status = #{execution_status}
|
||||
@@ -84,7 +84,7 @@
|
||||
<select id="getBatchExecutionLogLatest" parameterType="map" resultType="map">
|
||||
SELECT * FROM batch_execution_logs
|
||||
|
||||
WHERE batch_config_id = #{batch_config_id}
|
||||
WHERE batch_config_id = #{batch_config_id}::varchar
|
||||
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 1
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
WHERE 1=1
|
||||
<if test="batch_config_id != null">
|
||||
AND batch_config_id = #{batch_config_id}
|
||||
AND batch_config_id = #{batch_config_id}::varchar
|
||||
</if>
|
||||
<if test="start_date != null and start_date != ''">
|
||||
AND start_time >= #{start_date}::timestamp
|
||||
@@ -123,7 +123,7 @@
|
||||
total_records, success_records, failed_records,
|
||||
error_message, error_details, server_name, process_id
|
||||
) VALUES (
|
||||
#{batch_config_id}, #{company_code}, #{execution_status},
|
||||
#{batch_config_id}::varchar, #{company_code}, #{execution_status},
|
||||
COALESCE(#{start_time}::timestamp, NOW()),
|
||||
#{end_time}::timestamp,
|
||||
#{duration_ms},
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
execution_today AS (
|
||||
SELECT COUNT(*) AS today_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS today_failed
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
WHERE DATE(start_time) = CURRENT_DATE
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
),
|
||||
execution_yesterday AS (
|
||||
SELECT COUNT(*) AS yesterday_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS yesterday_failed
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
WHERE DATE(start_time) = CURRENT_DATE - INTERVAL '1 day'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
)
|
||||
@@ -77,9 +77,9 @@
|
||||
SUM(CASE WHEN execution_status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN execution_status = 'FAILED' THEN 1 ELSE 0 END) AS failed_count
|
||||
|
||||
FROM batch_execution_log
|
||||
FROM batch_execution_logs
|
||||
|
||||
WHERE batch_config_id = #{batch_config_id}
|
||||
WHERE batch_config_id = #{batch_config_id}::varchar
|
||||
AND start_time >= NOW() - INTERVAL '24 hours'
|
||||
|
||||
GROUP BY DATE_TRUNC('hour', start_time)
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="department">
|
||||
|
||||
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
|
||||
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
|
||||
<select id="selectDepartments" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
D.DEPT_CODE,
|
||||
@@ -15,29 +15,40 @@
|
||||
D.ORG_SYSTEM,
|
||||
D.APPROVAL_MANAGER,
|
||||
D.DEPT_MANAGER,
|
||||
D.ORG_HEAD,
|
||||
D.ZIPCODE,
|
||||
D.ADDRESS1,
|
||||
D.ADDRESS2,
|
||||
D.START_DATE,
|
||||
D.END_DATE,
|
||||
D.ERP_MANAGED,
|
||||
D.SHOW_IN_CHART,
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
D.DELETED_AT,
|
||||
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, D.ORG_HEAD,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
|
||||
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
|
||||
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
|
||||
D.SORT_ORDER, D.STATUS, D.DELETED_AT
|
||||
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
|
||||
</select>
|
||||
|
||||
<!-- 부서 단건 조회 -->
|
||||
<!-- 부서 단건 조회 (active 만) -->
|
||||
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
DEPT_CODE,
|
||||
@@ -49,33 +60,70 @@
|
||||
ORG_SYSTEM,
|
||||
APPROVAL_MANAGER,
|
||||
DEPT_MANAGER,
|
||||
ORG_HEAD,
|
||||
ZIPCODE,
|
||||
ADDRESS1,
|
||||
ADDRESS2,
|
||||
START_DATE,
|
||||
END_DATE,
|
||||
ERP_MANAGED,
|
||||
SHOW_IN_CHART,
|
||||
SORT_ORDER,
|
||||
STATUS
|
||||
STATUS,
|
||||
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
|
||||
</select>
|
||||
|
||||
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
|
||||
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
DEPT_CODE,
|
||||
DEPT_NAME,
|
||||
COMPANY_CODE,
|
||||
PARENT_DEPT_CODE,
|
||||
SHORT_NAME,
|
||||
DEPT_TYPE,
|
||||
ORG_SYSTEM,
|
||||
APPROVAL_MANAGER,
|
||||
DEPT_MANAGER,
|
||||
ZIPCODE,
|
||||
ADDRESS1,
|
||||
ADDRESS2,
|
||||
START_DATE,
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
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>
|
||||
|
||||
<!-- 중복 부서명 확인 -->
|
||||
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
|
||||
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
|
||||
SELECT DEPT_CODE, DEPT_NAME
|
||||
FROM DEPT_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND DEPT_NAME = #{dept_name}
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
AND DELETED_AT IS NULL
|
||||
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
|
||||
</select>
|
||||
|
||||
<!-- 회사명 조회 -->
|
||||
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
|
||||
<select id="selectCompanyName" parameterType="map" resultType="map">
|
||||
SELECT COMPANY_NAME
|
||||
FROM COMPANY_MNG
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
|
||||
@@ -98,22 +146,14 @@
|
||||
ORG_SYSTEM,
|
||||
APPROVAL_MANAGER,
|
||||
DEPT_MANAGER,
|
||||
ORG_HEAD,
|
||||
ZIPCODE,
|
||||
ADDRESS1,
|
||||
ADDRESS2,
|
||||
START_DATE,
|
||||
END_DATE,
|
||||
ERP_MANAGED,
|
||||
SHOW_IN_CHART,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
MASTER_SABUN,
|
||||
MASTER_USER_ID,
|
||||
LOCATION,
|
||||
LOCATION_NAME,
|
||||
DATA_TYPE,
|
||||
SALES_YN,
|
||||
CREATED_DATE
|
||||
) VALUES (
|
||||
#{dept_code},
|
||||
@@ -126,22 +166,14 @@
|
||||
#{org_system},
|
||||
#{approval_manager},
|
||||
#{dept_manager},
|
||||
#{org_head},
|
||||
#{zipcode},
|
||||
#{address1},
|
||||
#{address2},
|
||||
#{start_date}::date,
|
||||
#{end_date}::date,
|
||||
COALESCE(#{erp_managed}, 'Y'),
|
||||
COALESCE(#{show_in_chart}, 'Y'),
|
||||
COALESCE(#{sort_order}, 10),
|
||||
COALESCE(#{status}, 'active'),
|
||||
#{master_sabun},
|
||||
#{master_user_id},
|
||||
#{location},
|
||||
#{location_name},
|
||||
COALESCE(#{data_type}, 'real'),
|
||||
COALESCE(#{sales_yn}, 'N'),
|
||||
NOW()
|
||||
)
|
||||
</insert>
|
||||
@@ -157,43 +189,43 @@
|
||||
ORG_SYSTEM = #{org_system},
|
||||
APPROVAL_MANAGER = #{approval_manager},
|
||||
DEPT_MANAGER = #{dept_manager},
|
||||
ORG_HEAD = #{org_head},
|
||||
ZIPCODE = #{zipcode},
|
||||
ADDRESS1 = #{address1},
|
||||
ADDRESS2 = #{address2},
|
||||
START_DATE = #{start_date}::date,
|
||||
END_DATE = #{end_date}::date,
|
||||
ERP_MANAGED = #{erp_managed},
|
||||
SHOW_IN_CHART = #{show_in_chart},
|
||||
SORT_ORDER = #{sort_order},
|
||||
STATUS = #{status},
|
||||
MASTER_SABUN = #{master_sabun},
|
||||
MASTER_USER_ID = #{master_user_id},
|
||||
LOCATION = #{location},
|
||||
LOCATION_NAME = #{location_name},
|
||||
DATA_TYPE = #{data_type},
|
||||
SALES_YN = #{sales_yn}
|
||||
LOCATION = #{location}
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
</update>
|
||||
|
||||
<!-- 하위 부서 수 조회 -->
|
||||
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
|
||||
<select id="selectChildDeptCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM DEPT_INFO
|
||||
WHERE PARENT_DEPT_CODE = #{dept_code}
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND DELETED_AT IS NULL
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 부서 삭제 전 user_dept 삭제 -->
|
||||
<delete id="deleteUserDeptByDeptCode" parameterType="map">
|
||||
DELETE FROM USER_DEPT
|
||||
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
|
||||
<update id="deleteDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO
|
||||
SET DELETED_AT = NOW()
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</delete>
|
||||
AND DELETED_AT IS NULL
|
||||
</update>
|
||||
|
||||
<!-- 부서 삭제 -->
|
||||
<delete id="deleteDepartment" parameterType="map">
|
||||
DELETE FROM DEPT_INFO
|
||||
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
|
||||
<update id="restoreDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO
|
||||
SET DELETED_AT = NULL
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</delete>
|
||||
AND DELETED_AT IS NOT NULL
|
||||
</update>
|
||||
|
||||
<!-- 부서원 목록 조회 -->
|
||||
<select id="selectDeptMembers" parameterType="map" resultType="map">
|
||||
@@ -208,7 +240,7 @@
|
||||
D.DEPT_NAME,
|
||||
UD.IS_PRIMARY
|
||||
FROM USER_DEPT UD
|
||||
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
|
||||
WHERE UD.DEPT_CODE = #{dept_code}
|
||||
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
|
||||
@@ -225,8 +257,8 @@
|
||||
FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND (
|
||||
USER_ID ILIKE #{search}
|
||||
OR USER_NAME ILIKE #{search}
|
||||
USER_ID ILIKE #{search} ESCAPE '\'
|
||||
OR USER_NAME ILIKE #{search} ESCAPE '\'
|
||||
)
|
||||
ORDER BY USER_NAME
|
||||
LIMIT 20
|
||||
@@ -239,14 +271,23 @@
|
||||
WHERE USER_ID = #{user_id}
|
||||
</select>
|
||||
|
||||
<!-- 기존 부서원 확인 -->
|
||||
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
|
||||
<select id="selectExistingMember" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
|
||||
FROM USER_DEPT
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
|
||||
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
|
||||
<select id="selectFirstUserDept" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
FROM USER_DEPT
|
||||
WHERE USER_ID = #{user_id}
|
||||
ORDER BY CREATED_DATE ASC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 사용자의 주 부서 확인 -->
|
||||
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
@@ -283,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) -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user