68c1cb5b14
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>
412 lines
20 KiB
Java
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);
|
|
}
|
|
}
|