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..fe1acefb 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,12 +99,21 @@ 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("부서를 찾을 수 없습니다.")); + } + try { Map updated = departmentService.updateDepartment(deptCode, body); if (updated == null) { @@ -105,39 +126,102 @@ public class DepartmentController { } /** - * 부서 삭제 + * 부서 삭제 (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("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); + } + 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("부서를 찾을 수 없습니다.")); + } + + DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode); + 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, "부서원 목록 조회 성공")); @@ -168,12 +252,21 @@ 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("부서를 찾을 수 없습니다.")); + } + // 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환) Object userIdObj = body.get("user_id"); if (userIdObj == null) userIdObj = body.get("user_id"); @@ -200,12 +293,21 @@ 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("부서를 찾을 수 없습니다.")); + } + boolean removed = departmentService.removeDeptMember(deptCode, userId); if (!removed) { return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다.")); @@ -221,12 +323,21 @@ 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("관리자 권한이 필요합니다.")); } + 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("부서를 찾을 수 없습니다.")); + } + departmentService.setPrimaryDept(deptCode, userId); return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다.")); } @@ -242,4 +353,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 dd522eb4..44ec7040 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -48,15 +48,17 @@ public class StartupSchemaMigrator { // 메타 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 + 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' @@ -67,7 +69,27 @@ public class StartupSchemaMigrator { '회사관리', '부서관리', '사용자관리', '메뉴관리', '권한관리', '권한 그룹관리' ) - """ + """, + + // 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..f30dfb82 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,22 @@ 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; + // 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성 + String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code")); + String deptCode; + if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) { + // 중복 체크 + Map existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", requestedCode)); + if (existing != null) { + throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다."); + } + deptCode = requestedCode; + } else { + Map codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null); + long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L; + deptCode = "DEPT_" + nextNumber; + } // 부서 생성 (전체 필드) Map insertParams = new HashMap<>(); @@ -82,23 +110,15 @@ public class DepartmentService extends BaseService { 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 +135,28 @@ public class DepartmentService extends BaseService { throw new IllegalArgumentException("부서명을 입력해주세요."); } + // 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단 + Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")); + verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null); + 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 +169,105 @@ 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; + } + } + + 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 변경 시 사이클 검증. + * 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,14 +323,33 @@ 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; } 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 index 2b6eb216..05951e74 100644 --- 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 @@ -14,15 +14,19 @@ -- -- 멱등성: 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 + 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' 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..f84c2ba4 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,48 @@ 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} - + - + DELETE FROM USER_DEPT WHERE DEPT_CODE = #{dept_code} - - - DELETE FROM DEPT_INFO + + + UPDATE DEPT_INFO + SET DELETED_AT = NOW() WHERE DEPT_CODE = #{dept_code} - + AND DELETED_AT IS NULL + + + + + 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" + /> +
+ )} - - - - -
+
update("zipcode", e.target.value)} + className="h-8 w-[100px] text-sm" + /> + +
update("zipcode", e.target.value)} - className="h-8 w-[100px] text-sm" + value={draft.address1} + onChange={(e) => update("address1", e.target.value)} + className="h-8 text-sm" + /> + update("address2", e.target.value)} + className="h-8 text-sm" /> -
- update("address1", e.target.value)} - className="h-8 text-sm" - /> - update("address2", e.target.value)} - className="h-8 text-sm" - /> - -
+
+ )} - -
- update("start_date", e.target.value)} - className="h-8 text-sm" - /> -
- + {/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */} + {false && ( + +
update("end_date", e.target.value)} - className="h-8 flex-1 text-sm" + value={draft.start_date} + onChange={(e) => update("start_date", e.target.value)} + className="h-8 text-sm" /> -
-
- - - -
- update("erp_managed", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - +
+ + update("end_date", e.target.value)} + className="h-8 flex-1 text-sm" + />
-
- - -
- -
- - update("show_in_chart", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
- + + )} - - update("master_sabun", e.target.value)} - className="h-8 text-sm" - placeholder="부서장 사번" - /> - - - - update("master_user_id", e.target.value)} - className="h-8 text-sm" - placeholder="user_id" - /> - - - -
+ {/* TODO V2: 위치 — 향후 매핑 가능성. 컬럼은 DEPT_INFO.LOCATION 유지 */} + {false && ( + update("location", e.target.value)} className="h-8 text-sm" placeholder="위치코드" /> - update("location_name", e.target.value)} - className="h-8 text-sm" - placeholder="위치명" - /> -
-
- - - update("data_type", v)} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
- - - update("sales_yn", v as "Y" | "N")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
+ + )}
); diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 86049d4b..17257247 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -17,9 +17,9 @@ interface CompanyTableProps { export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) { const router = useRouter(); - // 부서 관리 페이지로 이동 - const handleManageDepartments = (company: Company) => { - router.push(`/admin/userMng/companyList/${company.company_code}/departments`); + // 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지) + const handleManageDepartments = (_company: Company) => { + router.push(`/admin/userMng/deptMngList`); }; // 디스크 사용량 포맷팅 함수 diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index 6a43aa29..083d334e 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; +import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -31,6 +31,9 @@ export function DepartmentStructure({ const [expandedDepts, setExpandedDepts] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); + // V1: soft-delete 된 부서 표시 토글 + const [showDeleted, setShowDeleted] = useState(false); + // 부서 추가 모달 const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [parentDeptForAdd, setParentDeptForAdd] = useState(null); @@ -42,15 +45,15 @@ export function DepartmentStructure({ const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null); const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); - // 부서 목록 로드 + // 부서 목록 로드 — showDeleted 도 의존성에 포함 useEffect(() => { loadDepartments(); - }, [companyCode, refreshTrigger]); + }, [companyCode, refreshTrigger, showDeleted]); const loadDepartments = async () => { setIsLoading(true); try { - const response = await departmentAPI.getDepartments(companyCode); + const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted }); if (response.success && (response as any).data) { setDepartments((response as any).data); } else { @@ -65,6 +68,34 @@ export function DepartmentStructure({ } }; + // V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기) + const handleRestoreDepartment = async (deptCode: string, deptName: string) => { + try { + const response = await departmentAPI.restoreDepartment(deptCode); + if (response.success) { + loadDepartments(); + toast({ + title: "부서 복구 완료", + description: `"${deptName}" 부서가 복구되었습니다.`, + variant: "default", + }); + } else { + toast({ + title: "복구 불가", + description: (response as any).error || "부서 복구에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("부서 복구 실패:", error); + toast({ + title: "부서 복구 실패", + description: "복구 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + // 부서 트리 구조 생성 const buildTree = (parentCode: string | null): Department[] => { return departments @@ -145,10 +176,13 @@ export function DepartmentStructure({ setDeptToDelete(null); loadDepartments(); - // 성공 메시지 Toast로 표시 (부서원 수 포함) + // V1 soft-delete: 복구 가능 안내 추가 + const isSoft = (response as any)?.data?.soft_deleted === true; toast({ - title: "부서 삭제 완료", - description: (response as any).message || "부서가 삭제되었습니다.", + title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료", + description: isSoft + ? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.` + : (response as any).message || "부서가 삭제되었습니다.", variant: "default", }); } else { @@ -184,17 +218,18 @@ export function DepartmentStructure({ const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code); const isExpanded = expandedDepts.has(dept.dept_code); const isSelected = selectedDepartment?.dept_code === dept.dept_code; + const isDeleted = !!(dept as any).deleted_at; return (
- {/* 부서 항목 */} + {/* 부서 항목 — soft-delete 시 회색+취소선 */}
-
onSelectDepartment(dept)}> +
!isDeleted && onSelectDepartment(dept)}> {/* 확장/축소 아이콘 */} {hasChildren ? (
+ + {/* deleted 배지 */} + {isDeleted && 삭제됨}
- {/* 액션 버튼 */} + {/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
- - + {isDeleted ? ( + + ) : ( + <> + + + + )}
@@ -259,10 +316,23 @@ export function DepartmentStructure({ {/* 헤더 */}

부서 구조

- +
+ {/* V1: soft-delete 부서 표시 토글 */} + + +
{/* 부서 트리 */} @@ -338,7 +408,9 @@ export function DepartmentStructure({

{deptToDelete?.name} 부서를 삭제하시겠습니까?

-

이 작업은 되돌릴 수 없습니다.

+

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

+ ) : ( +
+ )} + + {/* multi: 체크 표시 */} + {mode === "multi" && ( +
+ {isSel && } +
+ )} + + {/* 부서명 + 코드 */} +
+ {d.dept_name} + {d.dept_code} +
+ + {deleted && ( + 삭제됨 + )} +
+ + {hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)} + + ); + }); + }; + + // 루트 — parent_dept_code 가 null/빈문자열인 부서들 + const rootList = useMemo(() => childrenOf(null), [departments]); + const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code); + const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0; + + return ( + !o && onClose()}> + + + {title} + + +
+ {/* 검색 */} +
+ + setSearchInput(e.target.value)} + placeholder="부서명 또는 코드로 검색" + className="pl-8 pr-8" + autoFocus + /> + {searchInput && ( + + )} +
+ + {/* 트리 */} +
+ {isLoading ? ( +
로딩 중...
+ ) : !hasAny ? ( +
부서가 없습니다.
+ ) : noResults ? ( +
검색 결과 없음
+ ) : ( + renderTree(null, 0, new Set()) + )} +
+
+ + + + {mode === "multi" && ( + + )} + +
+
+ ); +} + +export default DepartmentPicker; diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index d859fddd..1cd02ebd 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -195,11 +195,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{ getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), extractParams: (m) => ({ diagramId: m[1] }), }, - { - pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, - getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), - extractParams: (m) => ({ companyCode: m[1] }), - }, { pattern: /^\/admin\/standards\/([^/]+)\/edit$/, getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts index 2486d11f..34be751a 100644 --- a/frontend/lib/api/department.ts +++ b/frontend/lib/api/department.ts @@ -6,12 +6,18 @@ import { apiClient } from "./client"; import { Department, DepartmentMember, DepartmentFormData } from "@/types/department"; /** - * 부서 목록 조회 (회사별) + * 부서 목록 조회 (회사별). + * options.includeDeleted=true 시 soft-delete 된 부서도 포함. */ -export async function getDepartments(companyCode: string) { +export async function getDepartments( + companyCode: string, + options?: { includeDeleted?: boolean }, +) { try { const url = `/departments/companies/${companyCode}/departments`; - const response = await apiClient.get<{ success: boolean; data: Department[] }>(url); + const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, { + params: options?.includeDeleted ? { include_deleted: true } : undefined, + }); return response.data; } catch (error: any) { console.error("부서 목록 조회 실패:", error); @@ -67,11 +73,16 @@ export async function updateDepartment(deptCode: string, data: DepartmentFormDat } /** - * 부서 삭제 + * 부서 삭제 (V1: soft-delete). + * 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가. */ export async function deleteDepartment(deptCode: string) { try { - const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`); + const response = await apiClient.delete<{ + success: boolean; + message?: string; + data?: { soft_deleted?: boolean; dept_code?: string }; + }>(`/departments/${deptCode}`); return response.data; } catch (error: any) { console.error("부서 삭제 실패:", error); @@ -79,6 +90,27 @@ export async function deleteDepartment(deptCode: string) { } } +/** + * 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기). + * 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지. + */ +export async function restoreDepartment(deptCode: string) { + try { + const response = await apiClient.post<{ + success: boolean; + message?: string; + data?: { dept_code?: string; restored?: boolean }; + }>(`/departments/${deptCode}/restore`); + return response.data; + } catch (error: any) { + console.error("부서 복구 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +} + /** * 부서원 목록 조회 */ diff --git a/frontend/package.json b/frontend/package.json index 8c09b0bd..ead03b6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771", "dev:docker": "next dev --turbopack -p 3000", + "dev:docker:nopack": "next dev -p 3000", "build": "next build", "build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build", "start": "next start", diff --git a/frontend/types/department.ts b/frontend/types/department.ts index df373623..5a75eadf 100644 --- a/frontend/types/department.ts +++ b/frontend/types/department.ts @@ -2,19 +2,14 @@ * 부서 관리 관련 타입 정의 */ -// 부서 정보 (dept_info 테이블 1:1 매핑) +// 부서 정보 (dept_info 테이블 1:1 매핑) — V019 정리 후 export interface Department { dept_code: string; // 부서 코드 (PK) parent_dept_code?: string | null; // 상위 부서 코드 dept_name: string; // 부서명 - master_sabun?: string | null; // 부서장 사번 - master_user_id?: string | null; // 부서장 사용자ID - location?: string | null; // 위치코드 - location_name?: string | null; // 위치명 + location?: string | null; // 위치코드 (UI hide, V2 매핑용 컬럼만 유지) created_date?: string | null; // 생성일시 - data_type?: string | null; // 데이터 구분 (real/temp) status?: "active" | "inactive" | null; // 사용여부 - sales_yn?: "Y" | "N" | null; // 영업조직 여부 company_name?: string | null; // 회사명 company_code: string; // 회사 코드 short_name?: string | null; // 부서약칭 @@ -22,17 +17,15 @@ export interface Department { org_system?: string | null; // 조직체계 approval_manager?: string | null; // 결재관리자 user_id dept_manager?: string | null; // 부서관리자 user_id - org_head?: string | null; // 조직장 user_id zipcode?: string | null; address1?: string | null; address2?: string | null; start_date?: string | null; // YYYY-MM-DD end_date?: string | null; // YYYY-MM-DD - erp_managed?: "Y" | "N" | null; - show_in_chart?: "Y" | "N" | null; sort_order?: number | null; created_at?: string; updated_at?: string; + deleted_at?: string | null; // V1: soft-delete 시각. NULL=active, 값 있음=휴지통 // UI용 추가 필드 children?: Department[]; member_count?: number; @@ -59,7 +52,7 @@ export interface UserDepartmentMapping { created_at?: string; } -// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 +// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 (V019 정리 후) export interface DepartmentFormData { dept_name: string; // 부서명 (필수) parent_dept_code?: string | null; @@ -68,23 +61,15 @@ export interface DepartmentFormData { org_system?: string | null; approval_manager?: string | null; dept_manager?: string | null; - org_head?: string | null; zipcode?: string | null; address1?: string | null; address2?: string | null; start_date?: string | null; end_date?: string | null; - erp_managed?: "Y" | "N" | null; - show_in_chart?: "Y" | "N" | null; sort_order?: number | null; status?: "active" | "inactive" | null; - // dept_info 추가 필드 - master_sabun?: string | null; - master_user_id?: string | null; + // dept_info 추가 필드 (location 코드만 유지) location?: string | null; - location_name?: string | null; - data_type?: string | null; - sales_yn?: "Y" | "N" | null; dept_code?: string | null; // 일괄등록용 (자동 부여 시 미전달) } diff --git a/notes/johngreen/2026-05-04-invyone-dept-vs-srm-gap-analysis.md b/notes/johngreen/2026-05-04-invyone-dept-vs-srm-gap-analysis.md new file mode 100644 index 00000000..522bba66 --- /dev/null +++ b/notes/johngreen/2026-05-04-invyone-dept-vs-srm-gap-analysis.md @@ -0,0 +1,427 @@ +# Invyone 부서관리 — SRM_SAMYANGFOOD 대비 부족한 점 분석 + +> **V1 적용 완료 (2026-05-04, slim scope)** +> Phase 1 (DELETED_AT schema) + Slice 2.1 (soft-delete + 복구) + Slice 2.2 (DepartmentPicker) 적용 완료. 메타 + 3 테넌트 (test01/test02/siflex) 자동 동기화. 변경이력·직책 모델은 V2 행 (사용자 결정). +> +> 적용 우선순위 표 매핑: +> - ✅ #7 Soft-delete (DELETE → DELETED_AT 마킹 + 복구 endpoint + 토글 UI) +> - ✅ #2 부서 선택 팝업 — 컴포넌트 자체 (V1.1 에서 다른 화면 마이그레이션) +> - ⏸️ #1 변경이력 — V2 (감사 요구사항 발생 시) +> - ⏸️ #3 직책 — V2 (결재 모듈 도입 시 함께) +> - 그 외 #4·5·6·8·9·10 모두 V2+ + + +- 작성일: 2026-05-04 +- 비교 대상 + - **invyone** : `C:\Dev\projects\invyone` (Spring Boot + Next.js 멀티테넌트 로우코드) + - **SRM_SAMYANGFOOD** : `C:\Dev\projects\SRM_SAMYANGFOOD` (Spring + JSP 레거시 SAP 연동 SRM) +- 비교 범위 : 부서관리(부서/조직/Dept) 모듈 한정 — 백엔드/스키마/프론트/연동/감사 + +--- + +## 0. 한 줄 요약 + +Invyone 은 **단일 회사 단위의 부서 CRUD/계층/멤버 할당** 은 잘 작동하지만, 한국형 ERP/SAP 연동 환경에서 표준이 된 **8 가지 축** (결재 라인 통합, 회계/Cost Center 연계, 변경 이력, 외부 시스템 동기화, 직책 모델, 부서 선택 팝업, 법인 분리, 본격적 단계 표현) 이 모두 빠져있다. 현재 코드는 **MVP 수준**이고, ERP 시장 진입 전 보강이 필요. + +--- + +## 1. 두 시스템 스냅샷 + +### 1.1 SRM_SAMYANGFOOD 부서관리 핵심 +- **컨트롤러** : `com.st_ones.everadmin.system.dept.web.DEPT01_Controller` (212줄) +- **서비스** : `DEPT01_Service` (148줄) +- **메인 테이블** : `STOCOGDP` — 부서 마스터, `STOCOGDT` — 디테일 +- **외부 동기화 테이블** : `M_DEPT` (그룹웨어/SAP-인접) ← Quartz 배치 `GwDept` 가 정기 풀링 +- **UI 화면 4종** + - `DEPT01_010` — 본부/부/팀/파트 4-pane 드릴다운 그리드 (gridT/M/B/DP) + - `DEPT01_020` — 9-단계 TreeGrid + in-cell edit + 자식 카운트 + - `DEPT01_020P01` — 부서 상세 팝업 (depth 선택 + insert/update merge) + - `DEPT01_030` — 다른 화면 재사용용 부서 선택 트리 (multi/single) + +### 1.2 Invyone 부서관리 핵심 +- **컨트롤러** : `com.erp.controller.DepartmentController` — `/api/departments` +- **서비스** : `com.erp.service.DepartmentService` (BaseService 상속, sqlSession 직접) +- **매퍼** : `mapper/department.xml` (namespace: `department`) +- **테이블** : `DEPT_INFO` (22 컬럼), `USER_DEPT` (사용자↔부서 m:n + IS_PRIMARY) +- **UI 화면 2종** + - `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (legacy 단독 페이지) + - `frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx` (메인) — `DepartmentStructure` + `DepartmentMembers` 컴포넌트 + +--- + +## 2. 갭 — Tier 별 정리 + +### 🔴 Tier 1 — 운영 표면화 (즉시 막힘) + +#### 2.1 결재 라인 통합 부재 +| | SRM | Invyone | +|---|---|---| +| 결재 참여 토글 | `STOCOGDP.APPROVE_USE_FLAG` | ❌ 없음 | +| 결재 라인 모델 | AUTH 시스템과 연결 | 없음 | +| 결재 매니저 | TEAM_LEADER_USER_ID + 별도 결재 체인 | `approval_manager` 한 명 필드만, 결재 흐름 미연결 | + +**영향** : 결재 모듈을 붙이는 순간 부서 모델을 다시 손대야 한다. + +#### 2.2 변경 이력 (Audit Trail) — 백엔드 0% +- 화면(`DepartmentStructure.tsx` 등) 에 `변경이력` 모달 스켈레톤만 있고 백엔드 테이블·쿼리 모두 부재. +- `DEPT_INFO` 컬럼 : `CREATED_DATE` 만, **UPDATED_DATE / UPDATED_BY 없음**. +- SRM 도 완벽하진 않지만 `REG_DATE / MOD_DATE` 는 있음. + +**영향** : "누가 언제 부서명 바꿨냐" 추적 불가 → 감사·컴플라이언스 실패. + +#### 2.3 부서 선택 재사용 컴포넌트 부재 +- SRM `DEPT01_030` : 다른 화면에서 부서 골라야 할 때 띄우는 트리 팝업 (single/multi-select, 부모 클릭 시 자식 cascade). +- Invyone : `searchUsers` (사용자 검색) 만 있음. 부서 picker 가 없어서 다른 화면에서 부서 참조하려면 매번 자체 셀렉트 만들어야 함. + +#### 2.4 회계 코드 / Cost Center 미연계 +- SRM : `ACC_CODE`, `INDEPT`(내부부서), `DIVISION_YN`. +- Invyone : 없음. 회계/원가 모듈을 붙일 때 부서 schema 재설계 필요. + +--- + +### 🟠 Tier 2 — 한국형 ERP 표준 누락 + +#### 2.5 직책 (Position) 모델 부재 +| | SRM | Invyone | +|---|---|---| +| 부서장 | `TEAM_LEADER_USER_ID` | `dept_manager`, `org_head` (각 1명) | +| 사용자↔부서 관계 | DEPT_CD + 직급 별도 | `USER_DEPT.IS_PRIMARY` boolean 만 | + +**영향** : "팀의 PL 3명, 팀장 1명, 부장 1명" 같은 한국 조직 모델 표현 불가. + +#### 2.6 DEPT_TYPE 표현력 부족 +| 시스템 | enum 값 | +|---|---| +| SRM | `100=Division / 200=Department / 300=Team / 400=SubTeam` + `LVL` 숫자 | +| Invyone | `dept / team / temp` 3종 (`temp` 의미도 모호) | + +**영향** : 한국 조직의 "본부 → 실 → 부 → 팀 → 파트" 5단계 구분이 안 들어감. + +#### 2.7 외부 시스템 동기화 인프라 부재 +- SRM : `GwDept` Quartz 배치 → 그룹웨어 `M_DEPT` 풀링 → 재귀 CTE 트리 재구성 → 회사 코드별(BUYER_CD: 100/300/500/700/800) `STOCOGDP` 클리어 후 일괄 적재. +- Invyone : CSV 한 줄씩 import 외 정기 동기화 없음. + +**영향** : ERP 마이그레이션 / SAP 연동 고객 받으면 즉각 장애. +**참고** : 로우코드 플랫폼 철학상 의도적 미구현일 수 있음 → 그러나 ERP 도메인 진입 시 필요. + +#### 2.8 법인 / 사업자 정보 분산 +- SRM : 부서 자체에 `DEPT_IRS_NUM`(사업자등록번호), `DEPT_CEO_NM`(대표자) — **부서 = 별도 법인 단위 운영 가능**. +- Invyone : 사업자번호·대표자는 `COMPANY_MNG` 에만. **한 회사 안에 복수 법인** (계열사·자회사) 표현 불가 → 회사를 나누는 수밖에 없음. + +--- + +### 🟡 Tier 3 — UX / 조회 효율 + +| # | 항목 | SRM | Invyone | +|---|---|---|---| +| 2.9 | 자식 노드 카운트 표시 | 트리 노드별 자식 수 표시 | ❌ 펼쳐봐야 알 수 있음 | +| 2.10 | 4-단계 그리드 드릴다운 화면 | `DEPT01_010` 4-pane | ❌ 단일 트리만 | +| 2.11 | In-cell 편집 | TreeGrid 안에서 직접 | ❌ 모달/사이드폼만 | +| 2.12 | 드래그앤드롭 재배열 | (명시적은 없으나 SEQ + tree edit) | ❌ `sort_order` 필드 있음, UI 없음 | +| 2.13 | Org Chart 시각화 | (없음) | ❌ `show_in_chart` 필드 있음 → **죽은 필드** | +| 2.14 | 개인정보 마스킹 | `J*n D*e` 마스킹 | ❌ 없음 | + +--- + +### 🟢 Tier 4 — 모델링 / 성능 / 안정성 + +#### 2.15 Denormalized 계층 path 부재 +- SRM : `ITEM_CLS1` ~ `ITEM_CLS9` + `LVL` 9-단계 비정규화 → breadcrumb · 검색 · 정렬 한 방. +- Invyone : `parent_dept_code` 만 → 깊은 트리는 클라이언트 재귀 join. +- 트레이드오프 : SRM 갱신 비용↑ 조회 비용↓ / Invyone 그 반대. +- **5단계 이상 깊은 조직** 에서는 SRM 모델이 유리. + +#### 2.16 Hard Delete (soft-delete 부재) +- SRM : `DEL_FLAG='1'` 논리 삭제 → 이력·복구 가능. +- Invyone : `DELETE` 가 진짜 row 제거 + `USER_DEPT` 강제 정리 → 잘못 지우면 복구 안 됨. + +#### 2.17 부서 폐지 / 합병 절차 부재 +- SRM : DEL_FLAG + 직원 재배치 + 결재 라인 재구성. +- Invyone : 멤버 자동 제거 후 hard delete — "이 부서 직원들 어디로?" 안내 없음. + +--- + +## 3. 우선순위 권장 (보강 로드맵) + +| 순위 | 작업 | 작업량 | 막힘 위험 | 기대 효과 | +|---|---|---|---|---| +| 1 | **변경 이력** : `UPDATED_DATE/BY` + `DEPT_HISTORY` 테이블 + 모달 백엔드 | S | 즉시 감사 실패 | stub → 동작 | +| 2 | **부서 선택 팝업** 컴포넌트 (DEPT01_030 류) | M | 다른 화면 만들 때마다 막힘 | 재사용성 폭발 | +| 3 | **직책 모델** : `USER_DEPT.ROLE_IN_DEPT` (팀장/팀원/매니저...) | S | 권한·결재 모듈에서 필수 | 진짜 조직 표현 | +| 4 | **DEPT_TYPE 확장** : 5-단계 + `LVL` 숫자 | S | 데이터 입력 시 표현 부족 | 단계별 정렬·필터 | +| 5 | **회계 코드** : `ACC_CODE` + 매핑 UI | M | 회계 모듈 붙이는 순간 막힘 | 비용 집계 가능 | +| 6 | **결재 워크플로우** 통합 (`APPROVE_USE_FLAG` + 결재 라인 모델) | L | 결재 모듈 별도 | 한국형 ERP 진입 | +| 7 | **Soft-delete** : `DELETED_AT` + 복구 흐름 | S | 잘못된 hard delete 위험 | 데이터 안정성 | +| 8 | **Org chart 화면** (`show_in_chart` 플래그 활용) | M | 죽은 필드 살리기 | 시각화 | +| 9 | **외부 동기화** : SAP/그룹웨어 API 풀러 | L | 마이그레이션 시 | 이관 가능 | +| 10 | **UX 개선** : 자식 카운트 / 드래그앤드롭 / 드릴다운 그리드 | M~L | UX 차원 | 사용성 | + +--- + +## 4. 추가 관찰 + +### 4.1 Invyone 강점 (잊지 말 것) +- 멀티테넌시 (`COMPANY_CODE='*'` 글로벌 + 회사별 격리) — SRM 의 `GATE_CD + BUYER_CD` 보다 깔끔 +- 사용자 다중 부서 + IS_PRIMARY — SRM 은 사용자 1↔1 부서 기본 +- React 컴포넌트화 (`DepartmentStructure` + `DepartmentMembers`) — 재사용성·테스트 용이 +- 트랜잭셔널 일관성 + 명확한 cascade (자식 부서 있으면 삭제 거부) + +### 4.2 Invyone 의 "이상한" 부분 +- `DEPT_TYPE='temp'` — 의미 미정의, 일회성 부서? +- `MASTER_SABUN` (legacy 사번) 컬럼 — 어디서 쓰는지 불분명 +- `ERP_MANAGED='Y'` 기본값 — Y/N 인데 실제 분기 로직 안 보임 +- `DATA_TYPE='real'|'temp'` — `DEPT_TYPE='temp'` 와 의미 충돌 가능성 + +### 4.3 모델링 결정 : 단일 회사 vs. 다법인 +Invyone 이 향후 **한 회사 안에서 복수 법인** 운영 (그룹사·계열사 모델) 을 지원할지가 분기점. +- 지원 안 함 : 현재 모델 유지, 회사를 더 만들면 됨 (사업자번호는 COMPANY_MNG 에) +- 지원 : 부서에 `legal_entity_code` 추가 + 사업자 정보 분리 → 큰 마이그레이션 + +### 4.4 Tier 1~2 만 했을 때의 결과 +대략 1~2 sprint 분량. 이걸로 일반 한국 중견기업 ERP 부서관리 표준은 **충분히 커버**. + +--- + +## 5. 다음 액션 + +- [ ] 본 문서를 stake-holder (사용자/내부) 와 함께 검토 → 우선순위 합의 +- [ ] Tier 1 (변경이력 + 부서 선택 팝업 + 직책 모델 + DEPT_TYPE 확장) 1차 설계 문서 작성 +- [ ] 마이그레이션 (V018 ~) 초안 +- [ ] Tier 1 구현 → Tier 2 → ... + +--- + +## 부록 A. 참고 코드 위치 + +### Invyone +``` +backend-spring/src/main/java/com/erp/controller/DepartmentController.java +backend-spring/src/main/java/com/erp/service/DepartmentService.java +backend-spring/src/main/resources/mapper/department.xml (namespace: department) + +frontend/app/(main)/admin/userMng/deptMngList/page.tsx +frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx +frontend/lib/api/department.ts +frontend/types/department.ts +frontend/components/.../DepartmentStructure.tsx +frontend/components/.../DepartmentMembers.tsx +``` + +### SRM_SAMYANGFOOD +``` +src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java +src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java +src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java +src/main/java/com/st_ones/batch/gwDept/web/GwDept.java +src/main/java/com/st_ones/batch/gwDept/service/GwDept_Service.java +src/main/java/com/st_ones/batch/gwDept/DeptMapper.java +``` + +## 부록 B. 주요 컬럼 비교 매트릭스 + +| 의미 | SRM (`STOCOGDP`) | Invyone (`DEPT_INFO`) | +|---|---|---| +| PK | DEPT_CD | DEPT_CODE | +| 이름 | DEPT_NM | DEPT_NAME | +| 상위 | PARENT_DEPT_CD | PARENT_DEPT_CODE | +| 깊이 | LVL | ❌ 없음 | +| Path | ITEM_CLS1 ~ ITEM_CLS9 | ❌ 없음 | +| 단계타입 | DEPT_TYPE (4종) | DEPT_TYPE (3종) | +| 부서장 | TEAM_LEADER_USER_ID | DEPT_MANAGER, ORG_HEAD | +| 결재 토글 | APPROVE_USE_FLAG | ❌ 없음 | +| 회계코드 | ACC_CODE | ❌ 없음 | +| 사업자번호 | DEPT_IRS_NUM | ❌ 부서엔 없음 (회사에만) | +| 대표자 | DEPT_CEO_NM | ❌ 부서엔 없음 | +| 내부부서 | INDEPT | ❌ 없음 | +| 사업부 플래그 | DIVISION_YN | ❌ 없음 | +| 정렬 | SEQ | SORT_ORDER | +| 소프트삭제 | DEL_FLAG | ❌ hard delete | +| 회사범위 | GATE_CD + BUYER_CD | COMPANY_CODE (+ '*' 글로벌) | +| 등록일 | REG_DATE | CREATED_DATE | +| 수정일 | MOD_DATE | ❌ 없음 | +| 시작/종료 | ❌ | START_DATE / END_DATE | +| 위치 | ❌ | LOCATION, LOCATION_NAME, ZIPCODE, ADDRESS1/2 | +| 매출부서 | ❌ | SALES_YN | +| 차트표시 | ❌ | SHOW_IN_CHART | + +(Invyone 에만 있는 항목도 일부 있음 — 위치/주소/SALES_YN/SHOW_IN_CHART. 현재 활용도는 낮음.) + +--- + +# 부록 C. KDSCont ↔ SRM_SAMYANGFOOD 부서관리 공통 패턴 + +(2026-05-04 추가) — KDSCont 도 함께 비교해 본 결과, 두 시스템의 공통점이 단순 우연이 아니라 **동일 SI 벤더의 ERP 프레임워크 표준 청사진** 임이 확인됨. 그래서 이 공통 패턴이 사실상 **한국 ERP/SI 시장의 부서관리 표준** 으로 봐도 됨. + +## C.1 결정적 발견 — 같은 SI 벤더 산출물 + +| 항목 | KDSCont | SRM_SAMYANGFOOD | +|---|---|---| +| 패키지 root | `com.st_ones.eversrm` | `com.st_ones.everadmin` | +| 부서 테이블 | **STOCOGDP** | **STOCOGDP** (동일) | +| 화면 코드 체계 | MOGA0030/0031/0032 | DEPT01_010/020/030 | +| 베이스 클래스 | BaseController / BaseService | BaseController / BaseService | +| 매퍼 방식 | MyBatis XML + Mapper interface | MyBatis XML + Mapper interface | + +→ **stones (st_ones)** 라는 동일 SI 의 ERP 프레임워크 위 다른 프로젝트. 두 시스템의 공통점 = **stones 표준** = 한국 ERP/SI 의 사실상 표준. + +--- + +## C.2 데이터 모델 — 95% 일치하는 부분 + +### C.2.1 테이블 / PK 구조 +``` +STOCOGDP (부서 마스터) + PK : (GATE_CD, BUYER_CD, DEPT_CD) + ├ 멀티테넌시 : GATE_CD (시스템 단위) + BUYER_CD (회사 단위) + └ 부서 식별 : DEPT_CD +``` + +### C.2.2 핵심 컬럼 (둘 다 동일) +| 컬럼 | 의미 | 비고 | +|---|---|---| +| `DEPT_CD` | 부서 코드 (PK) | **사용자 직접 입력** + 중복 체크 (auto-gen 아님) | +| `DEPT_NM` | 한국어 부서명 | KDSCont 는 `DEPT_NM_ENG` 영어 칼럼도 있음 | +| `PARENT_DEPT_CD` | 상위 부서 코드 | self-FK; level 1 은 BUYER_CD 가리킴 | +| `LVL` | 깊이 (0=회사 root) | KDSCont 0~4 / SRM 0~9 | +| `DEPT_TYPE` | 단계 enum (100~400) | ★ 핵심 — 100단위 4단계 | +| `SEQ` | 같은 부모 안에서 정렬 | | +| `ACC_CODE` | 회계/Cost Center 코드 | | +| `TEAM_LEADER_USER_ID` | 부서장 user_id (1명) | m:n 아님, 단일 | +| `DIVISION_YN` | 사업부 여부 | | +| `DEL_FLAG` | 소프트 삭제 ('0'/'1') | | +| `REG_DATE`, `MOD_DATE` | 감사 타임스탬프 | | +| `REG_USER_ID`, `MOD_USER_ID` | 감사 사용자 | | +| `ITEM_CLS1` ~ `ITEM_CLS{depth}` | 비정규화 path 세그먼트 | breadcrumb 용 | + +### C.2.3 DEPT_TYPE 100/200/300/400 컨벤션 (★ stones 표준) +| 코드 | KDSCont | SRM | 한국 조직 의미 | +|---|---|---|---| +| 100 | 부 (Division) | Division | 본부/사업부 | +| 200 | 팀 (Team) | Department | 부 | +| 300 | 조 (Section) | Team | 팀 | +| 400 | 계 (Sub-section) | SubTeam | 파트 | + +핵심 : **100단위 부여, 4-단계 cap, enum 기반 타입 분리**. 한국 SI 시장 사실상 표준. + +### C.2.4 계층 표현 — 비정규화 path 컬럼 +``` +ITEM_CLS1=회사명 ITEM_CLS2=부 ITEM_CLS3=팀 ITEM_CLS4=조 ITEM_CLS5=계 +ITEM_CLS_PATH_NM = "회사명 > 부 > 팀 > 조" (concat) +``` +양쪽 모두 `parent_dept_cd` 만으로는 안 풀고 path 세그먼트를 박아둠 → 트리·검색·breadcrumb 한 방. + +### C.2.5 멀티테넌시 — `GATE_CD + BUYER_CD` 복합 +- `GATE_CD` : 시스템/오퍼레이션 단위 (super-user 만 관리) +- `BUYER_CD` : 회사 단위 +- 모든 부서 쿼리는 `WHERE GATE_CD=? AND BUYER_CD=?` 필수. + +--- + +## C.3 UI 패턴 — 동일한 화면 구성 + +### C.3.1 화면 2종 세트 (★) +| 화면 | KDSCont | SRM | +|---|---|---| +| 4-단계 그리드 드릴다운 | MOGA0030 | DEPT01_010 | +| 트리뷰 + in-cell edit | MOGA0031/0032 | DEPT01_020 | +| 부서 상세 팝업 | (그리드 cell 클릭) | DEPT01_020P01 | +| 부서 선택 재사용 팝업 | SP0067/0066 | DEPT01_030 | + +### C.3.2 그리드 드릴다운 +- 4 패널 grid: LV1 / LV2 / LV3 / LV4 +- 라디오로 활성 grid 전환 +- LV1 cell 클릭 → LV2 cascade 로드 → ... + +### C.3.3 트리뷰 + 인라인 편집 +- 회사 root (LV0) 부터 풀 트리 펼침 +- ACC_CODE / 부서장 등 selected row 만 편집 → hidden grid 모아서 일괄 저장 + +### C.3.4 개인정보 마스킹 (★ 양쪽 동일) +- `GETUSERNAME(gateCd, userId, langCd)` 함수가 마스킹 처리 +- 첫글자 + `*` + 마지막 N자 (예: `홍*동`, `J*n D*e`) + +### C.3.5 사용자 입력 + 중복 체크 (auto-gen 아님) +- DEPT_CD 사용자 직접 입력 → save 시 unique 체크 +- SAP/외부 시스템 코드 매핑을 위한 의도적 사용자 제어 + +--- + +## C.4 컨벤션 공통점 — invyone 과 다른 부분 + +| | stones (KDSCont · SRM) | invyone | +|---|---|---| +| 매퍼 | XML + Mapper interface (`@Mapper`) | XML namespace 만, **Mapper interface 금지** | +| DTO | Map + 일부 VO | Map 만 (DTO 금지) | +| 화면 코드 | 4-digit (MOGA0030 / DEPT01_010) | URL 기반 | +| Save 패턴 | MyBatis MERGE / UPSERT | INSERT / UPDATE 분리 | +| 다국어 | DEPT_NM + DEPT_NM_ENG | 미지원 | +| 부서 코드 | 사용자 입력 + 중복체크 | 자동 생성 (`DEPT_`) | + +--- + +## C.5 Invyone 에 시사하는 것 + +### C.5.1 받아들여야 할 것 (★ 한국 SI 표준) +- **DEPT_TYPE = 100/200/300/400** 4단계 enum (현재 invyone 은 dept/team/temp 3종 → 표현력 부족) +- **`LVL` 숫자 컬럼** +- **`ITEM_CLS1~5` 비정규화 path** + `ITEM_CLS_PATH_NM` 자동 갱신 +- **`ACC_CODE`** (Cost Center) +- **`DIVISION_YN`** +- **`REG_USER_ID / MOD_USER_ID / MOD_DATE`** 감사 컬럼 +- **`DEL_FLAG`** 소프트삭제 (현재 hard delete) + +### C.5.2 UI 에서 받아들여야 할 것 +- **4-단계 그리드 드릴다운** 화면 (현재 invyone 은 단일 트리만) +- **부서 선택 팝업** 컴포넌트 (재사용) +- **부서장 user picker + 개인정보 마스킹** + +### C.5.3 받아들이지 말 것 (invyone 이 더 나은 부분) +- **GATE_CD + BUYER_CD 2단계 멀티테넌시** → invyone 의 단일 `COMPANY_CODE + '*'` 가 더 깔끔. +- **`@Mapper` interface** → invyone 의 sqlSession 직접 사용이 더 가볍고 CLAUDE.md 원칙과 부합. +- **사용자 직접 입력 DEPT_CD** → 외부 시스템 매핑 필요할 때 옵션으로만, 기본 자동생성 유지. +- **다국어 컬럼** → 진짜 글로벌 진출 전엔 over-engineering, 나중에. + +### C.5.4 stones 모델에 없는데 invyone 이 이미 더 나은 부분 +- 사용자 ↔ 부서 m:n + IS_PRIMARY (stones 는 1:1) +- 시작/종료 일자 (START_DATE/END_DATE) +- 위치 정보 (LOCATION/ADDRESS) +- React 컴포넌트화 + +--- + +## C.6 결론 — 우선순위 보강 + +본문 §3 의 우선순위에서 **확신도가 올라간 항목** (KDSCont 에서도 동일 패턴 확인): + +1. ✓ **DEPT_TYPE 4-단계 enum (100/200/300/400)** — 한국 SI 표준 +2. ✓ **ACC_CODE 회계 코드** — 한국 SI 표준 +3. ✓ **변경 이력 (MOD_DATE / MOD_USER_ID)** — 한국 SI 표준 +4. ✓ **DIVISION_YN** — 한국 SI 표준 +5. ✓ **소프트 삭제 (DEL_FLAG)** — 한국 SI 표준 +6. ✓ **부서장 user picker + 마스킹** — 한국 SI 표준 +7. ✓ **`ITEM_CLS1~N` 비정규화 path** — 한국 SI 표준 (성능·breadcrumb 동시 해결) + +특히 **`ITEM_CLS1~N` 비정규화 path** 는 양쪽 모두 채택한 만큼, invyone 도 이걸 따르면 깊은 트리 성능 + breadcrumb 표시를 한 번에 잡을 수 있음. + +--- + +## C.7 참고 코드 위치 + +### KDSCont +``` +src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0030Controller.java (4-단계 grid) +src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0031Controller.java (트리뷰) +src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0032Controller.java (트리뷰 변형) +src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0020Controller.java (회사관리) +src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0010Controller.java (게이트관리) +src/main/resources/mappers/com/st_ones/eversrm/manager/org/MOGA0030~32Mapper.xml +src/main/webapp/WEB-INF/views/eversrm/manager/org/MOGA0030~32.jsp +``` + +### SRM_SAMYANGFOOD +``` +src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java +src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java +src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java +src/main/java/com/st_ones/batch/gwDept/web/GwDept.java (그룹웨어 sync 배치) +``` diff --git a/scripts/start/invyone-start-docker-all.bat b/scripts/start/invyone-start-docker-all.bat index 6ae4eb85..ede6108a 100644 --- a/scripts/start/invyone-start-docker-all.bat +++ b/scripts/start/invyone-start-docker-all.bat @@ -5,6 +5,9 @@ chcp 65001 >nul pushd "%~dp0..\.." set COMPOSE_FILE=docker\dev\docker-compose.invyone.yml +REM Windows 전용 override — Turbopack 끄고 webpack 으로 폴백해서 host 편집 자동 HMR 복원. +REM (Docker Desktop on Windows 의 bind mount inotify 미전파 이슈 회피) +set COMPOSE_WIN=docker\dev\docker-compose.windows.yml where docker >nul 2>&1 if errorlevel 1 ( @@ -28,9 +31,15 @@ if not exist "%COMPOSE_FILE%" ( pause exit /b 1 ) +if not exist "%COMPOSE_WIN%" ( + echo [invyone] Windows override 파일을 찾을 수 없음: %COMPOSE_WIN% + popd + pause + exit /b 1 +) echo [invyone] 도커 컨테이너 기동 중... -docker compose -f %COMPOSE_FILE% up -d +docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% up -d if errorlevel 1 ( echo [invyone] 기동 실패. 로그를 확인해주세요. popd @@ -40,7 +49,7 @@ if errorlevel 1 ( echo. echo [invyone] 컨테이너 상태: -docker compose -f %COMPOSE_FILE% ps +docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% ps echo. echo [invyone] 접속 URL: