Files
invyone/backend-spring/src/main/java/com/erp/service/DepartmentService.java
T
johngreen 68c1cb5b14 fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화
CRITICAL:
- searchUsers 회사/role 격리 가드 추가 (멀티테넌시 침해 차단)
- setPrimaryDept 멤버십 검증 추가 (주부서 데이터 손상 방지)
- parent_dept_code cross-tenant 검증 (validateParent 헬퍼)

HIGH:
- updateDepartment SQL WHERE 에 DELETED_AT IS NULL 추가 (silent corruption 방지)
- update/restore 부서명 중복 검증 추가
- 글로벌 부서 (*) write 작업 SUPER_ADMIN 전용 가드
- 부서코드 자동 생성으로 강제 (사용자 입력 받지 않음)
- 회사 변경 시 상세 패널 초기화
- handleMove 부분 실패 시 화면 동기화
- 검색 시 부모 체인 자동 포함 (broken tree 수정)
- start_date 기본값 today 강제 제거

MEDIUM:
- 멤버 fetch cancellation flag
- 삭제 다이얼로그 dept_code 클로저 캡처
- isDirty 시 X 버튼 폼 초기화 경고
- 변경이력 버튼 disabled (백엔드 API 미구현)
- 일괄등록 실패 상세 모달 (라인 + 사유)
- LIKE 와일드카드 ESCAPE 적용
- nullIfBlank 에 trim 통합

LOW + 새 기능:
- 부서원 추가/제거 UI 신규 구현 (UserSearchModal)
- selectDeptMembers LEFT JOIN 으로 변경
- DepartmentPicker allowRoot 옵션 (최상위로 이동)
- expandAll 전체 departments 사용
- dead code 정리

DB:
- RUN_085 마이그레이션: DEPT_INFO partial UNIQUE + USER_DEPT UNIQUE
- 모든 active 테넌트 DB (siflex/test01/test02_invyone) 적용 완료

Breaking changes:
- 일괄등록 CSV 4컬럼 → 3컬럼 (부서명,상위부서,유형)
- 부서코드 입력란 제거 (자동 부여 DEPT_n)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:03 +09:00

489 lines
24 KiB
Java

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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
public class DepartmentService extends BaseService {
// ──────────────────────────────────────────────────
// 부서 CRUD
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
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) {
dept.put("member_count", ((Number) cnt).intValue());
} else {
dept.put("member_count", 0);
}
}
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);
}
/** 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);
}
@Transactional
public Map<String, Object> createDepartment(String companyCode, Map<String, Object> body) {
// 프론트엔드는 snake_case로 전송 (Node.js 호환)
String deptName = trimString(bodyParam(body, "dept_name", "dept_name"));
if (deptName == null || deptName.isEmpty()) {
throw new IllegalArgumentException("부서명을 입력해주세요.");
}
// 중복 부서명 확인
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCode);
dupParams.put("dept_name", deptName);
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null) {
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
}
// 회사명 조회
Map<String, Object> companyParams = new HashMap<>();
companyParams.put("company_code", companyCode);
Map<String, Object> company = sqlSession.selectOne("department.selectCompanyName", companyParams);
String companyName = (company != null && company.get("company_name") != null)
? (String) company.get("company_name")
: companyCode;
// 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<>();
insertParams.put("dept_code", deptCode);
insertParams.put("dept_name", deptName);
insertParams.put("company_code", companyCode);
insertParams.put("company_name", companyName);
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("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("sort_order", bodyParam(body, "sort_order", "sort_order"));
insertParams.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
sqlSession.insert("department.insertDepartment", insertParams);
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
}
@Transactional
public Map<String, Object> updateDepartment(String deptCode, Map<String, Object> body) {
String deptName = trimString(bodyParam(body, "dept_name", "dept_name"));
if (deptName == null || deptName.isEmpty()) {
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", 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("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("sort_order", bodyParam(body, "sort_order", "sort_order"));
params.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
params.put("location", nullIfBlank(bodyParam(body, "location", "location")));
int updated = sqlSession.update("department.updateDepartment", params);
if (updated == 0) {
return null;
}
log.info("부서 수정 성공: deptCode={}", deptCode);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
}
/**
* 부서 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("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.");
}
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
Map<String, Object> deptParams = new HashMap<>();
deptParams.put("dept_code", deptCode);
int updated = sqlSession.update("department.deleteDepartment", deptParams);
if (updated == 0) {
return -1; // not found 또는 이미 deleted
}
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;
}
}
// ──────────────────────────────────────────────────
// 부서원 관리
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDeptMembers(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectList("department.selectDeptMembers", params);
}
public List<Map<String, Object>> searchUsers(String companyCode, String search) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("search", "%" + search + "%");
return sqlSession.selectList("department.searchUsers", params);
}
@Transactional
public void addDeptMember(String deptCode, String userId) {
// 사용자 존재 확인
Map<String, Object> userParams = new HashMap<>();
userParams.put("user_id", userId);
Map<String, Object> user = sqlSession.selectOne("department.selectUserById", userParams);
if (user == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
}
// 이미 부서원인지 확인
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 DuplicateMemberException("이미 해당 부서의 부서원입니다.");
}
// 주 부서가 있는지 확인
Map<String, Object> primaryParams = new HashMap<>();
primaryParams.put("user_id", userId);
Map<String, Object> hasPrimary = sqlSession.selectOne("department.selectUserPrimaryDept", primaryParams);
// 부서원 추가
Map<String, Object> insertParams = new HashMap<>();
insertParams.put("user_id", userId);
insertParams.put("dept_code", deptCode);
insertParams.put("is_primary", hasPrimary == null);
sqlSession.insert("department.insertDeptMember", insertParams);
log.info("부서원 추가 성공: userId={}, deptCode={}", userId, deptCode);
}
@Transactional
public boolean removeDeptMember(String deptCode, String userId) {
// 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;
}
// 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);
sqlSession.update("department.clearUserPrimaryDept", clearParams);
// 해당 부서를 주 부서로 설정
Map<String, Object> setParams = new HashMap<>();
setParams.put("user_id", userId);
setParams.put("dept_code", deptCode);
sqlSession.update("department.setUserPrimaryDept", setParams);
log.info("주 부서 설정 성공: userId={}, deptCode={}", userId, deptCode);
}
// ──────────────────────────────────────────────────
// 내부 유틸
// ──────────────────────────────────────────────────
private String trimString(Object value) {
if (value == null) return null;
String str = value.toString().trim();
return str.isEmpty() ? null : str;
}
/** snake_case 우선, camelCase fallback으로 request body 파라미터 추출 */
private Object bodyParam(Map<String, Object> body, String snakeCase, String camelCase) {
Object val = body.get(snakeCase);
return val != null ? val : body.get(camelCase);
}
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
private Object nullIfBlank(Object value) {
if (value == null) return null;
if (value instanceof String s) {
String trimmed = s.trim();
return trimmed.isEmpty() ? null : trimmed;
}
return value;
}
// ── 중복 예외 클래스 ────────────────────────────────
public static class DuplicateDeptNameException extends RuntimeException {
public DuplicateDeptNameException(String message) {
super(message);
}
}
public static class DuplicateMemberException extends RuntimeException {
public DuplicateMemberException(String message) {
super(message);
}
}
}