feat(부서관리): 다중 결재/부서 관리자 + 조직장 (DEPT_MANAGERS 매핑 테이블)
- 마이그레이션 V022/RUN_088: DEPT_MANAGERS 신규 (role: approval/dept/org_leader, PK 3-tuple, FK CASCADE) - StartupSchemaMigrator 에 V022 idempotent CREATE 추가 → 테넌트 DB 자동 동기화 - mapper.xml: SELECT 에 3 json_agg ::TEXT 컬럼 추가, insertDeptManagers + deleteDeptManagersByDeptAndRole 신규 - service: parseManagersJson + syncManagers (delete-all + insert-all, 최대 10명, 역할 한글 메시지) - frontend: types 3 필드, DeptDetailDraft 확장, ManagerChipsField (chip+UserSearchModal 재사용), 조직장 Row 신규 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼은 호환을 위해 유지.
This commit is contained in:
@@ -68,6 +68,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 생성
|
||||
* POST /api/departments/companies/{companyCode}/departments
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
|
||||
@@ -96,6 +97,7 @@ public class DepartmentController {
|
||||
/**
|
||||
* 부서 수정
|
||||
* PUT /api/departments/{deptCode}
|
||||
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
|
||||
*/
|
||||
@PutMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
|
||||
|
||||
@@ -183,7 +183,27 @@ public class StartupSchemaMigrator {
|
||||
// conditional 매핑(when/then/default) 규칙 저장용.
|
||||
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB"
|
||||
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
|
||||
|
||||
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
|
||||
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
|
||||
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
|
||||
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
|
||||
DEPT_CODE VARCHAR(1024) NOT NULL,
|
||||
USER_ID VARCHAR(50) NOT NULL,
|
||||
ROLE VARCHAR(20) NOT NULL,
|
||||
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
|
||||
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
|
||||
CONSTRAINT chk_dept_managers_role
|
||||
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
|
||||
CONSTRAINT fk_dept_managers_dept
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
|
||||
@@ -43,6 +43,10 @@ public class DepartmentService extends BaseService {
|
||||
} else {
|
||||
dept.put("member_count", 0);
|
||||
}
|
||||
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return departments;
|
||||
}
|
||||
@@ -51,14 +55,26 @@ public class DepartmentService extends BaseService {
|
||||
public Map<String, Object> getDepartment(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
|
||||
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
if (dept != null) {
|
||||
parseManagersJson(dept, "approval_managers");
|
||||
parseManagersJson(dept, "dept_managers");
|
||||
parseManagersJson(dept, "org_leaders");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -134,11 +150,15 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||
sqlSession.insert("department.insertDepartment", insertParams);
|
||||
|
||||
syncManagers(deptCode, body, "approval");
|
||||
syncManagers(deptCode, body, "dept");
|
||||
syncManagers(deptCode, 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
|
||||
@@ -201,10 +221,12 @@ public class DepartmentService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncManagers(deptCode, body, "approval");
|
||||
syncManagers(deptCode, body, "dept");
|
||||
syncManagers(deptCode, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,6 +499,80 @@ public class DepartmentService extends BaseService {
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── 관리자 매핑 sync ────────────────────────────────
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private 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;
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(raw.toString(),
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||
dept.put(key, parsed);
|
||||
} catch (Exception e) {
|
||||
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, 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);
|
||||
};
|
||||
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명까지 등록 가능합니다.");
|
||||
}
|
||||
// delete-all
|
||||
Map<String, Object> delParams = new HashMap<>();
|
||||
delParams.put("dept_code", deptCode);
|
||||
delParams.put("role", role);
|
||||
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
|
||||
// insert-all
|
||||
if (!userIds.isEmpty()) {
|
||||
Map<String, Object> insParams = new HashMap<>();
|
||||
insParams.put("dept_code", deptCode);
|
||||
insParams.put("role", role);
|
||||
insParams.put("user_ids", userIds);
|
||||
sqlSession.insert("department.insertDeptManagers", insParams);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 중복 예외 클래스 ────────────────────────────────
|
||||
|
||||
public static class DuplicateDeptNameException extends RuntimeException {
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
@@ -23,7 +23,13 @@
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
D.DELETED_AT,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO D
|
||||
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
|
||||
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
|
||||
@@ -61,7 +67,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
@@ -86,7 +98,13 @@
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
DELETED_AT,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
|
||||
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
|
||||
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
@@ -306,4 +324,19 @@
|
||||
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>
|
||||
|
||||
</mapper>
|
||||
|
||||
Reference in New Issue
Block a user