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> getDepartments(String companyCode) { return getDepartments(companyCode, false); } /** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */ public List> getDepartments(String companyCode, boolean includeDeleted) { Map params = new HashMap<>(); params.put("company_code", companyCode); params.put("include_deleted", includeDeleted); List> departments = sqlSession.selectList("department.selectDepartments", params); // member_count를 int로 변환 for (Map 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 getDepartment(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); return sqlSession.selectOne("department.selectDepartmentByCode", params); } /** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */ public Map getDepartmentIncludingDeleted(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params); } @Transactional public Map createDepartment(String companyCode, Map body) { // 프론트엔드는 snake_case로 전송 (Node.js 호환) String deptName = trimString(bodyParam(body, "dept_name", "dept_name")); if (deptName == null || deptName.isEmpty()) { throw new IllegalArgumentException("부서명을 입력해주세요."); } // 중복 부서명 확인 Map dupParams = new HashMap<>(); dupParams.put("company_code", companyCode); dupParams.put("dept_name", deptName); Map duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); if (duplicate != null) { throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다."); } // 회사명 조회 Map companyParams = new HashMap<>(); companyParams.put("company_code", companyCode); Map 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 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 existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", candidate)); if (existing == null) { deptCode = candidate; break; } } if (deptCode == null) { throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요."); } // 부서 생성 (전체 필드) Map 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 findParams = new HashMap<>(); findParams.put("dept_code", deptCode); return sqlSession.selectOne("department.selectDepartmentByCode", findParams); } @Transactional public Map updateDepartment(String deptCode, Map body) { String deptName = trimString(bodyParam(body, "dept_name", "dept_name")); if (deptName == null || deptName.isEmpty()) { throw new IllegalArgumentException("부서명을 입력해주세요."); } // 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용) Map 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 dupParams = new HashMap<>(); dupParams.put("company_code", deptCompanyCode); dupParams.put("dept_name", deptName); Map duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) { throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다."); } } Map 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 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 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 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 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 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 dupParams = new HashMap<>(); dupParams.put("company_code", companyCodeObj.toString()); dupParams.put("dept_name", deptNameObj.toString()); Map duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) { throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다."); } } Map 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 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 visited = new HashSet<>(); String cur = newParent; while (cur != null && !visited.contains(cur)) { if (deptCode.equals(cur)) { throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다."); } visited.add(cur); Map 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> getDeptMembers(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); return sqlSession.selectList("department.selectDeptMembers", params); } public List> searchUsers(String companyCode, String search) { Map 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 userParams = new HashMap<>(); userParams.put("user_id", userId); Map user = sqlSession.selectOne("department.selectUserById", userParams); if (user == null) { throw new IllegalArgumentException("사용자를 찾을 수 없습니다."); } // 이미 부서원인지 확인 Map existParams = new HashMap<>(); existParams.put("user_id", userId); existParams.put("dept_code", deptCode); Map existing = sqlSession.selectOne("department.selectExistingMember", existParams); if (existing != null) { throw new DuplicateMemberException("이미 해당 부서의 부서원입니다."); } // 주 부서가 있는지 확인 Map primaryParams = new HashMap<>(); primaryParams.put("user_id", userId); Map hasPrimary = sqlSession.selectOne("department.selectUserPrimaryDept", primaryParams); // 부서원 추가 Map 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 existParams = new HashMap<>(); existParams.put("user_id", userId); existParams.put("dept_code", deptCode); Map 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 remaining = sqlSession.selectOne("department.selectFirstUserDept", Map.of("user_id", userId)); if (remaining != null && remaining.get("dept_code") != null) { Map 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 existParams = new HashMap<>(); existParams.put("user_id", userId); existParams.put("dept_code", deptCode); Map existing = sqlSession.selectOne("department.selectExistingMember", existParams); if (existing == null) { throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요."); } // 다른 부서의 주 부서 해제 Map clearParams = new HashMap<>(); clearParams.put("user_id", userId); sqlSession.update("department.clearUserPrimaryDept", clearParams); // 해당 부서를 주 부서로 설정 Map 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 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); } } }