Files
invyone/backend-spring/src/main/java/com/erp/controller/DepartmentController.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

412 lines
20 KiB
Java

package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.DepartmentService;
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;
@RestController
@RequestMapping("/api/departments")
@RequiredArgsConstructor
@Slf4j
public class DepartmentController {
private final DepartmentService departmentService;
/**
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
/**
* 부서 상세 조회.
* - 기본: 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,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
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
*/
@PostMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
@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("해당 회사의 부서를 생성할 권한이 없습니다."));
}
try {
Map<String, Object> created = departmentService.createDepartment(companyCode, body);
return ResponseEntity.status(201).body(ApiResponse.success(created, "부서가 생성되었습니다."));
} 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()));
}
}
/**
* 부서 수정
* PUT /api/departments/{deptCode}
*/
@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()));
}
}
/**
* 부서 삭제 (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<Map<String, Object>>> deleteDepartment(
@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 만 수정/삭제할 수 있습니다."));
}
try {
int result = departmentService.deleteDepartment(deptCode);
if (result == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
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,
@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, "부서원 목록 조회 성공"));
}
/**
* 사용자 검색 (부서원 추가용)
* GET /api/departments/companies/{companyCode}/users/search
*/
@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("검색어를 입력해주세요."));
}
List<Map<String, Object>> users = departmentService.searchUsers(companyCode, search);
return ResponseEntity.ok(ApiResponse.success(users, "사용자 검색 성공"));
}
/**
* 부서원 추가
* POST /api/departments/{deptCode}/members
*/
@PostMapping("/{deptCode}/members")
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") != null ? body.get("user_id") : body.get("userId");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
String userId = userIdObj.toString();
try {
departmentService.addDeptMember(deptCode, userId);
return ResponseEntity.status(201).body(ApiResponse.success(null, "부서원이 추가되었습니다."));
} catch (DepartmentService.DuplicateMemberException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(404).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서원 제거
* DELETE /api/departments/{deptCode}/members/{userId}
*/
@DeleteMapping("/{deptCode}/members/{userId}")
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
@PathVariable String deptCode,
@PathVariable String userId,
@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("해당 부서원을 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(null, "부서원이 제거되었습니다."));
}
/**
* 주 부서 설정
* PUT /api/departments/{deptCode}/members/{userId}/primary
*/
@PutMapping("/{deptCode}/members/{userId}/primary")
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
@PathVariable String deptCode,
@PathVariable String userId,
@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 {
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
// ──────────────────────────────────────────────────
// 내부 유틸
// ──────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
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);
}
}