diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index 55d1fa9c..7ee10c98 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -19,35 +19,47 @@ public class DepartmentController { private final DepartmentService departmentService; /** - * 부서 목록 조회 (회사별) - * GET /api/departments/companies/{companyCode}/departments + * 부서 목록 조회 (회사별). + * 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함. + * GET /api/departments/companies/{companyCode}/departments[?include_deleted=true] */ @GetMapping("/companies/{companyCode}/departments") public ResponseEntity>>> getDepartments( @PathVariable String companyCode, - @RequestAttribute("company_code") String userCompanyCode) { + @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> departments = departmentService.getDepartments(companyCode); + List> departments = departmentService.getDepartments(companyCode, includeDeleted); return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공")); } /** - * 부서 상세 조회 - * GET /api/departments/{deptCode} + * 부서 상세 조회. + * - 기본: active 부서만 (DELETED_AT IS NULL) + * - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용) + * - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체 + * GET /api/departments/{deptCode}[?include_deleted=true] */ @GetMapping("/{deptCode}") public ResponseEntity>> getDepartment( - @PathVariable String deptCode) { + @PathVariable String deptCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) { - Map department = departmentService.getDepartment(deptCode); + Map 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, "부서 조회 성공")); } @@ -87,57 +99,148 @@ public class DepartmentController { public ResponseEntity>> updateDepartment( @PathVariable String deptCode, @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode, @RequestBody Map body) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map 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 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> deleteDepartment( + public ResponseEntity>> deleteDepartment( @PathVariable String deptCode, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map 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 memberCount = departmentService.deleteDepartment(deptCode); - if (memberCount == -1) { - return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); + int result = departmentService.deleteDepartment(deptCode); + if (result == -1) { + return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); } - String message = memberCount > 0 - ? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)" - : "부서가 삭제되었습니다."; - return ResponseEntity.ok(ApiResponse.success(null, message)); + Map 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>> restoreDepartment( + @PathVariable String deptCode, + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { + + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + + Map 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 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>>> getDeptMembers( - @PathVariable String deptCode) { + @PathVariable String deptCode, + @RequestAttribute("company_code") String userCompanyCode) { + + Map 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> members = departmentService.getDeptMembers(deptCode); return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공")); @@ -150,8 +253,17 @@ public class DepartmentController { @GetMapping("/companies/{companyCode}/users/search") public ResponseEntity>>> 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("검색어를 입력해주세요.")); } @@ -168,15 +280,27 @@ public class DepartmentController { public ResponseEntity> addDeptMember( @PathVariable String deptCode, @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode, @RequestBody Map body) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map 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"); - if (userIdObj == null) userIdObj = body.get("user_id"); + 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를 입력해주세요.")); } @@ -200,12 +324,25 @@ public class DepartmentController { public ResponseEntity> removeDeptMember( @PathVariable String deptCode, @PathVariable String userId, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } + Map 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("해당 부서원을 찾을 수 없습니다.")); @@ -221,14 +358,31 @@ public class DepartmentController { public ResponseEntity> setPrimaryDept( @PathVariable String deptCode, @PathVariable String userId, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String userCompanyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } - departmentService.setPrimaryDept(deptCode, userId); - return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다.")); + Map 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())); + } } // ────────────────────────────────────────────────── @@ -242,4 +396,16 @@ public class DepartmentController { private boolean isSuperAdmin(String companyCodeOrRole) { return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole); } + + /** + * 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능. + * 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만. + */ + private boolean canAccessDept(Map 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); + } } diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index 1d3318cd..44ec7040 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -41,7 +41,55 @@ public class StartupSchemaMigrator { private static final List MIGRATIONS = List.of( // RUN_082: 첫 로그인 비밀번호 강제 변경 플래그 - "ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE" + "ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE", + + // V017: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열 + // 조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹) + // 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는 + // 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화. + // SEQ 만 갱신 → 멱등. + // 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것 + // (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생). + """ + UPDATE MENU_INFO + SET SEQ = CASE MENU_NAME_KOR + WHEN '회사관리' THEN '100' + WHEN '부서관리' THEN '200' + WHEN '사용자관리' THEN '300' + WHEN '메뉴관리' THEN '400' + WHEN '권한관리' THEN '500' + WHEN '권한 그룹관리' THEN '600' + ELSE SEQ + END + WHERE MENU_TYPE = '0' + AND COMPANY_CODE = '*' + AND PARENT_OBJ_ID IS NOT NULL + AND PARENT_OBJ_ID <> '0' + AND MENU_NAME_KOR IN ( + '회사관리', '부서관리', '사용자관리', + '메뉴관리', '권한관리', '권한 그룹관리' + ) + """, + + // V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼. + // DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨. + // 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + "ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL", + + // V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속) + "CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL", + + // V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리. + // 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + // DROP IF EXISTS 로 멱등성 보장. + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED", + "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE" ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index a2a75e79..e774d84c 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -6,8 +6,10 @@ 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 @@ -18,8 +20,14 @@ public class DepartmentService extends BaseService { // ────────────────────────────────────────────────── 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로 변환 @@ -34,12 +42,20 @@ public class DepartmentService extends BaseService { 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 호환) @@ -65,10 +81,30 @@ public class DepartmentService extends BaseService { ? (String) company.get("company_name") : companyCode; - // 부서 코드 생성 - Map codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null); - long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L; - String deptCode = "DEPT_" + nextNumber; + // 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<>(); @@ -76,29 +112,21 @@ public class DepartmentService extends BaseService { insertParams.put("dept_name", deptName); insertParams.put("company_code", companyCode); insertParams.put("company_name", companyName); - insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"))); + 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("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head"))); 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("erp_managed", bodyParam(body, "erp_managed", "erp_managed")); - insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart")); insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order")); insertParams.put("status", bodyParam(body, "status", "status")); - // dept_info 추가 필드 (master_*, location_*, data_type, sales_yn) - insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun"))); - insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id"))); + // dept_info 추가 필드 (location 코드만 유지 — V019 정리 후) insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location"))); - insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name"))); - insertParams.put("data_type", bodyParam(body, "data_type", "data_type")); - insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn")); sqlSession.insert("department.insertDepartment", insertParams); log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName); @@ -115,32 +143,53 @@ public class DepartmentService extends BaseService { 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", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"))); + 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("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head"))); 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("erp_managed", bodyParam(body, "erp_managed", "erp_managed")); - params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart")); params.put("sort_order", bodyParam(body, "sort_order", "sort_order")); params.put("status", bodyParam(body, "status", "status")); - // dept_info 추가 필드 - params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun"))); - params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id"))); + // dept_info 추가 필드 (location 코드만 유지 — V019 정리 후) params.put("location", nullIfBlank(bodyParam(body, "location", "location"))); - params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name"))); - params.put("data_type", bodyParam(body, "data_type", "data_type")); - params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn")); int updated = sqlSession.update("department.updateDepartment", params); if (updated == 0) { @@ -153,32 +202,140 @@ public class DepartmentService extends BaseService { 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("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요."); } - // 부서원 삭제 - Map memberParams = new HashMap<>(); - memberParams.put("dept_code", deptCode); - int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams); - - // 부서 삭제 + // soft-delete: DELETED_AT = NOW(). USER_DEPT 보존 Map deptParams = new HashMap<>(); deptParams.put("dept_code", deptCode); - int deleted = sqlSession.delete("department.deleteDepartment", deptParams); - if (deleted == 0) { - return -1; // not found + int updated = sqlSession.update("department.deleteDepartment", deptParams); + if (updated == 0) { + return -1; // not found 또는 이미 deleted } - log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount); - return memberCount; + 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; + } } // ────────────────────────────────────────────────── @@ -234,19 +391,47 @@ public class DepartmentService extends BaseService { @Transactional public boolean removeDeptMember(String deptCode, String userId) { - Map params = new HashMap<>(); - params.put("user_id", userId); - params.put("dept_code", deptCode); - int deleted = sqlSession.delete("department.deleteDeptMember", params); + // 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; } - log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode); + + // 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); @@ -277,10 +462,13 @@ public class DepartmentService extends BaseService { return val != null ? val : body.get(camelCase); } - /** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */ + /** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */ private Object nullIfBlank(Object value) { if (value == null) return null; - if (value instanceof String s && s.trim().isEmpty()) return null; + if (value instanceof String s) { + String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; + } return value; } diff --git a/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql b/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql new file mode 100644 index 00000000..05951e74 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V017__reorder_company_mgmt_admin_menus.sql @@ -0,0 +1,39 @@ +-- V017: 회사 관리 그룹 하위 관리자 메뉴(MENU_TYPE='0', COMPANY_CODE='*') 순서 재배열 +-- +-- 변경 후 순서: +-- 1) 회사관리 (조직 계층: 회사) +-- 2) 부서관리 (조직 계층: 부서) +-- 3) 사용자관리 (조직 계층: 사용자) +-- 4) 메뉴관리 (권한 체계: 메뉴) +-- 5) 권한관리 (권한 체계: 권한) +-- 6) 권한 그룹관리 (권한 체계: 권한 그룹) +-- +-- 사유: 기존 순서(회사 → 사용자 → 메뉴 → 권한 → 권한그룹 → 부서)는 부서가 맨 끝으로 +-- 빠져 조직 계층이 끊기고, 권한 그룹이 권한관리 뒤에 오는 등 그룹 일관성이 없었다. +-- 조직(회사→부서→사용자) + 권한(메뉴→권한→권한그룹) 두 묶음으로 정렬한다. +-- +-- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE +-- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음. +-- +-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로 +-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다. +-- → THEN 값은 반드시 문자열 리터럴로 줄 것. + +UPDATE MENU_INFO + SET SEQ = CASE MENU_NAME_KOR + WHEN '회사관리' THEN '100' + WHEN '부서관리' THEN '200' + WHEN '사용자관리' THEN '300' + WHEN '메뉴관리' THEN '400' + WHEN '권한관리' THEN '500' + WHEN '권한 그룹관리' THEN '600' + ELSE SEQ + END + WHERE MENU_TYPE = '0' + AND COMPANY_CODE = '*' + AND PARENT_OBJ_ID IS NOT NULL + AND PARENT_OBJ_ID <> '0' + AND MENU_NAME_KOR IN ( + '회사관리', '부서관리', '사용자관리', + '메뉴관리', '권한관리', '권한 그룹관리' + ); diff --git a/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql b/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql new file mode 100644 index 00000000..3c1212cf --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V018__dept_mgmt_v1_softdelete.sql @@ -0,0 +1,18 @@ +-- V018: invyone 부서관리 V1 - soft-delete +-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경. +-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경. +-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전. +-- +-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가 +-- 동일 statement 를 부팅 시점에 적용. +-- +-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체, +-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리. + +-- (1) DEPT_INFO 소프트삭제 컬럼 +ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL; + +-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속) +CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE + ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) + WHERE DELETED_AT IS NULL; diff --git a/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql b/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql new file mode 100644 index 00000000..d5c72f18 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V019__dept_mgmt_v1_cleanup_unused_columns.sql @@ -0,0 +1,22 @@ +-- V019: 부서관리 미사용/중복 컬럼 정리 +-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복. +-- DROP IF EXISTS 로 멱등성 보장. +-- +-- 대상 컬럼 (8개): +-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복) +-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출) +-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명) +-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지) +-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복) +-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직) +-- ERP_MANAGED - ERP 관리 (분기 로직 없음) +-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌) + +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED; +ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE; diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index d4fbdd8a..1211310b 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -728,14 +728,9 @@ DEPT_CODE , PARENT_DEPT_CODE , DEPT_NAME - , MASTER_SABUN - , MASTER_USER_ID , LOCATION - , LOCATION_NAME , CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE - , DATA_TYPE , STATUS - , SALES_YN , COMPANY_CODE , COMPANY_NAME FROM DEPT_INFO @@ -746,8 +741,7 @@ AND (DEPT_NAME ILIKE '%' || #{search} || '%' - OR DEPT_CODE ILIKE '%' || #{search} || '%' - OR LOCATION_NAME ILIKE '%' || #{search} || '%') + OR DEPT_CODE ILIKE '%' || #{search} || '%') ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index 5f00b785..be469617 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -3,7 +3,7 @@ "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + - + + + + - + - + @@ -98,22 +124,14 @@ ORG_SYSTEM, APPROVAL_MANAGER, DEPT_MANAGER, - ORG_HEAD, ZIPCODE, ADDRESS1, ADDRESS2, START_DATE, END_DATE, - ERP_MANAGED, - SHOW_IN_CHART, SORT_ORDER, STATUS, - MASTER_SABUN, - MASTER_USER_ID, LOCATION, - LOCATION_NAME, - DATA_TYPE, - SALES_YN, CREATED_DATE ) VALUES ( #{dept_code}, @@ -126,22 +144,14 @@ #{org_system}, #{approval_manager}, #{dept_manager}, - #{org_head}, #{zipcode}, #{address1}, #{address2}, #{start_date}::date, #{end_date}::date, - COALESCE(#{erp_managed}, 'Y'), - COALESCE(#{show_in_chart}, 'Y'), COALESCE(#{sort_order}, 10), COALESCE(#{status}, 'active'), - #{master_sabun}, - #{master_user_id}, #{location}, - #{location_name}, - COALESCE(#{data_type}, 'real'), - COALESCE(#{sales_yn}, 'N'), NOW() ) @@ -157,43 +167,43 @@ ORG_SYSTEM = #{org_system}, APPROVAL_MANAGER = #{approval_manager}, DEPT_MANAGER = #{dept_manager}, - ORG_HEAD = #{org_head}, ZIPCODE = #{zipcode}, ADDRESS1 = #{address1}, ADDRESS2 = #{address2}, START_DATE = #{start_date}::date, END_DATE = #{end_date}::date, - ERP_MANAGED = #{erp_managed}, - SHOW_IN_CHART = #{show_in_chart}, SORT_ORDER = #{sort_order}, STATUS = #{status}, - MASTER_SABUN = #{master_sabun}, - MASTER_USER_ID = #{master_user_id}, - LOCATION = #{location}, - LOCATION_NAME = #{location_name}, - DATA_TYPE = #{data_type}, - SALES_YN = #{sales_yn} + LOCATION = #{location} WHERE DEPT_CODE = #{dept_code} + AND DELETED_AT IS NULL - + - - - DELETE FROM USER_DEPT + + + UPDATE DEPT_INFO + SET DELETED_AT = NOW() WHERE DEPT_CODE = #{dept_code} - + AND DELETED_AT IS NULL + - - - DELETE FROM DEPT_INFO + + + UPDATE DEPT_INFO + SET DELETED_AT = NULL WHERE DEPT_CODE = #{dept_code} - + AND DELETED_AT IS NOT NULL + - + + + + setBaseDate(e.target.value)} - disabled={periodMode !== "date"} - className="h-7 flex-1 text-xs" - /> - + {/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */} + {false && ( +
+ + setPeriodMode(v as "all" | "date")} + className="flex items-center gap-3" + > +
+ + +
+
+ + +
+
+ setBaseDate(e.target.value)} + disabled={periodMode !== "date"} + className="h-7 flex-1 text-xs" + /> +
+ )} - { + setSelectedCompanyCode(v); + setSelectedCode(null); + setIsNewMode(false); + setDraft(emptyDraft(v)); + setOriginalDraft(null); + }} + > @@ -498,12 +705,31 @@ export default function DeptMngListPage() { /> -
- -
@@ -516,35 +742,42 @@ export default function DeptMngListPage() {
회사를 선택하세요
) : ( <> - {/* 회사 루트 노드 */} -
- + {/* 회사/사업장 루트 (1:1 가정으로 단일 표시) — 토글 가능 */} +
- {/* 사업장 (현재는 회사=사업장 1:1 가정) */} -
-
- - - {selectedCompany.company_code}. {selectedCompany.company_name} -
+ {selectedCompany.company_code}. {selectedCompany.company_name} + - {/* 부서 트리 */} + {siteOpen && (
handleAddNew(parent.dept_code), + onMoveTo: (d) => setMoveTargetDept(d), + onMoveTop: (d) => handleMove(d, "top"), + onMoveUp: (d) => handleMove(d, "up"), + onMoveDown: (d) => handleMove(d, "down"), + onMoveBottom: (d) => handleMove(d, "bottom"), + onContextDelete: (d) => setContextDeleteDept(d), + }} /> {childrenOf(null).length === 0 && (
등록된 부서가 없습니다.
)}
-
+ )} )} @@ -554,98 +787,197 @@ export default function DeptMngListPage() {
{/* 상세 헤더 */}
-
- - 상세정보 -
-
- - - -
-
- - {/* 탭 */} -
- - -
- - {/* 탭 바디 */} -
- {activeTab === "info" ? ( - d.dept_code === draft.parent_dept_code)?.dept_name ?? draft.parent_dept_code) - : "-" - } - /> - ) : ( - +
+ {(selectedCode || isNewMode) && ( +
+ + + +
)} + + {(selectedCode || isNewMode) ? ( + <> + {/* 탭 */} +
+ + +
+ + {/* 탭 바디 */} +
+ {activeTab === "info" ? ( + + ) : ( + { + if (selectedCode) { + const res = await departmentAPI.getDepartmentMembers(selectedCode); + if (res.success && (res as any).data) setMembers((res as any).data); + } + await loadDepartments(); + }} + /> + )} +
+ + ) : ( +
+
+ +
+ 좌측 트리에서 부서를 선택하거나 +
+ 상단의 + 추가 버튼으로 새 부서를 만드세요 +
+
+
+ )}
{/* 삭제 확인 */} - + { + setDeleteConfirmOpen(o); + if (!o) setPendingDeleteDept(null); + }} + > 부서 삭제

- {draft.dept_name} 부서를 삭제하시겠습니까? + {pendingDeleteDept?.name ?? draft.dept_name} 부서를 삭제하시겠습니까?

-

부서원은 자동으로 제외됩니다. 하위 부서가 있으면 삭제할 수 없습니다.

+

부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.

- +
+ {/* 트리 ⋮ 메뉴 — 컨텍스트 삭제 확인 */} + !o && setContextDeleteDept(null)}> + + + 부서 삭제 + +

+ {contextDeleteDept?.dept_name} 부서를 삭제하시겠습니까? +

+

부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.

+ + + + +
+
+ + {/* 트리 ⋮ 메뉴 — 다른 부서 아래로 이동 picker */} + + handleConfirmMoveTo(typeof code === "string" && code ? code : null) + } + onClose={() => setMoveTargetDept(null)} + excludeCodes={moveTargetDept ? collectAllDescendants(moveTargetDept.dept_code) : []} + title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"} + /> + {/* 일괄등록 */} @@ -656,14 +988,15 @@ export default function DeptMngListPage() {

CSV 형식으로 한 줄에 하나씩 입력하세요

- 형식: 부서코드,부서명,상위부서코드,부서유형(dept|team|temp) + 형식: 부서명,상위부서코드,부서유형(dept|team|temp)

-

예시: D001,경영지원본부,,dept

+

부서코드는 저장 시 자동 부여됩니다 (DEPT_n).

+

예시: 경영지원본부,,dept