Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리 탭 라벨 fallback, Windows dev HMR 복원 흡수. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ApiResponse<List<Map<String, Object>>>> 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<Map<String, Object>> departments = departmentService.getDepartments(companyCode);
|
||||
List<Map<String, Object>> 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<ApiResponse<Map<String, Object>>> getDepartment(
|
||||
@PathVariable String deptCode) {
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||
|
||||
Map<String, Object> department = departmentService.getDepartment(deptCode);
|
||||
Map<String, Object> department = includeDeleted
|
||||
? departmentService.getDepartmentIncludingDeleted(deptCode)
|
||||
: departmentService.getDepartment(deptCode);
|
||||
if (department == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(department, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -87,57 +99,148 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
|
||||
if (updated == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제
|
||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||
* - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가
|
||||
* - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남
|
||||
* DELETE /api/departments/{deptCode}
|
||||
*/
|
||||
@DeleteMapping("/{deptCode}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteDepartment(
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
int 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<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("soft_deleted", true);
|
||||
data.put("dept_code", deptCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)"));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 복구 (V1 slim scope).
|
||||
* - DELETED_AT = NULL 로 되돌림
|
||||
* - 부모도 deleted 상태면 차단
|
||||
* POST /api/departments/{deptCode}/restore
|
||||
*/
|
||||
@PostMapping("/{deptCode}/restore")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> restoreDepartment(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
DepartmentService.RestoreResult result;
|
||||
try {
|
||||
result = departmentService.restoreDepartment(deptCode);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
switch (result) {
|
||||
case OK:
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("dept_code", deptCode);
|
||||
data.put("restored", true);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다."));
|
||||
case NOT_FOUND:
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
case NOT_DELETED:
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다."));
|
||||
case PARENT_DELETED:
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요."));
|
||||
default:
|
||||
return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
* GET /api/departments/{deptCode}/members
|
||||
*/
|
||||
@GetMapping("/{deptCode}/members")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
|
||||
@PathVariable String deptCode) {
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode) {
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
|
||||
@@ -150,8 +253,17 @@ public class DepartmentController {
|
||||
@GetMapping("/companies/{companyCode}/users/search")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
|
||||
}
|
||||
|
||||
if (search == null || search.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
|
||||
}
|
||||
@@ -168,15 +280,27 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Void>> addDeptMember(
|
||||
@PathVariable String deptCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
|
||||
Object userIdObj = body.get("user_id");
|
||||
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<ApiResponse<Void>> 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<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
boolean removed = departmentService.removeDeptMember(deptCode, userId);
|
||||
if (!removed) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다."));
|
||||
@@ -221,14 +358,31 @@ public class DepartmentController {
|
||||
public ResponseEntity<ApiResponse<Void>> 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<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
departmentService.setPrimaryDept(deptCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -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<String, Object> dept, String userCompanyCode) {
|
||||
if (dept == null) return false;
|
||||
if (isSuperAdmin(userCompanyCode)) return true;
|
||||
String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null;
|
||||
if (deptCompanyCode == null) return false;
|
||||
return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,55 @@ public class StartupSchemaMigrator {
|
||||
|
||||
private static final List<String> 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)
|
||||
|
||||
@@ -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<Map<String, Object>> getDepartments(String companyCode) {
|
||||
return getDepartments(companyCode, false);
|
||||
}
|
||||
|
||||
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
|
||||
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
params.put("include_deleted", includeDeleted);
|
||||
List<Map<String, Object>> 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<String, Object> getDepartment(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCode", params);
|
||||
}
|
||||
|
||||
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
|
||||
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> createDepartment(String companyCode, Map<String, Object> body) {
|
||||
// 프론트엔드는 snake_case로 전송 (Node.js 호환)
|
||||
@@ -65,10 +81,30 @@ public class DepartmentService extends BaseService {
|
||||
? (String) company.get("company_name")
|
||||
: companyCode;
|
||||
|
||||
// 부서 코드 생성
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", candidate));
|
||||
if (existing == null) {
|
||||
deptCode = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (deptCode == null) {
|
||||
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
|
||||
}
|
||||
|
||||
// 부서 생성 (전체 필드)
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", deptCompanyCode);
|
||||
dupParams.put("dept_name", deptName);
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> memberParams = new HashMap<>();
|
||||
memberParams.put("dept_code", deptCode);
|
||||
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
|
||||
|
||||
// 부서 삭제
|
||||
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCodeObj.toString());
|
||||
dupParams.put("dept_name", deptNameObj.toString());
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String> visited = new HashSet<>();
|
||||
String cur = newParent;
|
||||
while (cur != null && !visited.contains(cur)) {
|
||||
if (deptCode.equals(cur)) {
|
||||
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
|
||||
}
|
||||
visited.add(cur);
|
||||
Map<String, Object> 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<String, Object> params = new HashMap<>();
|
||||
params.put("user_id", userId);
|
||||
params.put("dept_code", deptCode);
|
||||
int deleted = sqlSession.delete("department.deleteDeptMember", params);
|
||||
// 1. 제거 전 — 이 row 가 primary 였는지 확인
|
||||
Map<String, Object> existParams = new HashMap<>();
|
||||
existParams.put("user_id", userId);
|
||||
existParams.put("dept_code", deptCode);
|
||||
Map<String, Object> 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<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
|
||||
Map.of("user_id", userId));
|
||||
if (remaining != null && remaining.get("dept_code") != null) {
|
||||
Map<String, Object> 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<String, Object> existParams = new HashMap<>();
|
||||
existParams.put("user_id", userId);
|
||||
existParams.put("dept_code", deptCode);
|
||||
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
|
||||
}
|
||||
|
||||
// 다른 부서의 주 부서 해제
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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 (
|
||||
'회사관리', '부서관리', '사용자관리',
|
||||
'메뉴관리', '권한관리', '권한 그룹관리'
|
||||
);
|
||||
@@ -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;
|
||||
+22
@@ -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;
|
||||
@@ -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 @@
|
||||
</if>
|
||||
<if test="search != null and search != ''">
|
||||
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
|
||||
OR DEPT_CODE ILIKE '%' || #{search} || '%'
|
||||
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
|
||||
OR DEPT_CODE ILIKE '%' || #{search} || '%')
|
||||
</if>
|
||||
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
|
||||
</select>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="department">
|
||||
|
||||
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
|
||||
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
|
||||
<select id="selectDepartments" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
D.DEPT_CODE,
|
||||
@@ -15,29 +15,30 @@
|
||||
D.ORG_SYSTEM,
|
||||
D.APPROVAL_MANAGER,
|
||||
D.DEPT_MANAGER,
|
||||
D.ORG_HEAD,
|
||||
D.ZIPCODE,
|
||||
D.ADDRESS1,
|
||||
D.ADDRESS2,
|
||||
D.START_DATE,
|
||||
D.END_DATE,
|
||||
D.ERP_MANAGED,
|
||||
D.SHOW_IN_CHART,
|
||||
D.SORT_ORDER,
|
||||
D.STATUS,
|
||||
D.DELETED_AT,
|
||||
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
|
||||
FROM DEPT_INFO D
|
||||
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
|
||||
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND D.DELETED_AT IS NULL
|
||||
</if>
|
||||
GROUP BY
|
||||
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER, D.ORG_HEAD,
|
||||
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
|
||||
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
|
||||
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
|
||||
D.SORT_ORDER, D.STATUS, D.DELETED_AT
|
||||
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
|
||||
</select>
|
||||
|
||||
<!-- 부서 단건 조회 -->
|
||||
<!-- 부서 단건 조회 (active 만) -->
|
||||
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
DEPT_CODE,
|
||||
@@ -49,33 +50,58 @@
|
||||
ORG_SYSTEM,
|
||||
APPROVAL_MANAGER,
|
||||
DEPT_MANAGER,
|
||||
ORG_HEAD,
|
||||
ZIPCODE,
|
||||
ADDRESS1,
|
||||
ADDRESS2,
|
||||
START_DATE,
|
||||
END_DATE,
|
||||
ERP_MANAGED,
|
||||
SHOW_IN_CHART,
|
||||
SORT_ORDER,
|
||||
STATUS
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
</select>
|
||||
|
||||
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
|
||||
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
DEPT_CODE,
|
||||
DEPT_NAME,
|
||||
COMPANY_CODE,
|
||||
PARENT_DEPT_CODE,
|
||||
SHORT_NAME,
|
||||
DEPT_TYPE,
|
||||
ORG_SYSTEM,
|
||||
APPROVAL_MANAGER,
|
||||
DEPT_MANAGER,
|
||||
ZIPCODE,
|
||||
ADDRESS1,
|
||||
ADDRESS2,
|
||||
START_DATE,
|
||||
END_DATE,
|
||||
SORT_ORDER,
|
||||
STATUS,
|
||||
DELETED_AT
|
||||
FROM DEPT_INFO
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
|
||||
<!-- 중복 부서명 확인 -->
|
||||
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
|
||||
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
|
||||
SELECT DEPT_CODE, DEPT_NAME
|
||||
FROM DEPT_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND DEPT_NAME = #{dept_name}
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
AND DELETED_AT IS NULL
|
||||
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
|
||||
</select>
|
||||
|
||||
<!-- 회사명 조회 -->
|
||||
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
|
||||
<select id="selectCompanyName" parameterType="map" resultType="map">
|
||||
SELECT COMPANY_NAME
|
||||
FROM COMPANY_MNG
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
|
||||
@@ -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()
|
||||
)
|
||||
</insert>
|
||||
@@ -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
|
||||
</update>
|
||||
|
||||
<!-- 하위 부서 수 조회 -->
|
||||
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
|
||||
<select id="selectChildDeptCount" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM DEPT_INFO
|
||||
WHERE PARENT_DEPT_CODE = #{dept_code}
|
||||
<if test="include_deleted == null or include_deleted == false">
|
||||
AND DELETED_AT IS NULL
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 부서 삭제 전 user_dept 삭제 -->
|
||||
<delete id="deleteUserDeptByDeptCode" parameterType="map">
|
||||
DELETE FROM USER_DEPT
|
||||
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
|
||||
<update id="deleteDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO
|
||||
SET DELETED_AT = NOW()
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</delete>
|
||||
AND DELETED_AT IS NULL
|
||||
</update>
|
||||
|
||||
<!-- 부서 삭제 -->
|
||||
<delete id="deleteDepartment" parameterType="map">
|
||||
DELETE FROM DEPT_INFO
|
||||
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
|
||||
<update id="restoreDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO
|
||||
SET DELETED_AT = NULL
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</delete>
|
||||
AND DELETED_AT IS NOT NULL
|
||||
</update>
|
||||
|
||||
<!-- 부서원 목록 조회 -->
|
||||
<select id="selectDeptMembers" parameterType="map" resultType="map">
|
||||
@@ -208,7 +218,7 @@
|
||||
D.DEPT_NAME,
|
||||
UD.IS_PRIMARY
|
||||
FROM USER_DEPT UD
|
||||
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
|
||||
WHERE UD.DEPT_CODE = #{dept_code}
|
||||
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
|
||||
@@ -225,8 +235,8 @@
|
||||
FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND (
|
||||
USER_ID ILIKE #{search}
|
||||
OR USER_NAME ILIKE #{search}
|
||||
USER_ID ILIKE #{search} ESCAPE '\'
|
||||
OR USER_NAME ILIKE #{search} ESCAPE '\'
|
||||
)
|
||||
ORDER BY USER_NAME
|
||||
LIMIT 20
|
||||
@@ -239,14 +249,23 @@
|
||||
WHERE USER_ID = #{user_id}
|
||||
</select>
|
||||
|
||||
<!-- 기존 부서원 확인 -->
|
||||
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
|
||||
<select id="selectExistingMember" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
|
||||
FROM USER_DEPT
|
||||
WHERE USER_ID = #{user_id}
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</select>
|
||||
|
||||
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
|
||||
<select id="selectFirstUserDept" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
FROM USER_DEPT
|
||||
WHERE USER_ID = #{user_id}
|
||||
ORDER BY CREATED_DATE ASC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 사용자의 주 부서 확인 -->
|
||||
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
|
||||
SELECT USER_ID, DEPT_CODE
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 085 마이그레이션 — 부서관리 데이터 무결성 제약 추가
|
||||
|
||||
작성일: 2026-05-08
|
||||
작성자: johngreen
|
||||
관련: 부서관리 페이지 버그 수정 (notes/johngreen/2026-05-08-부서관리-버그-정리.md)
|
||||
|
||||
## 목적
|
||||
|
||||
부서명/부서원 중복을 DB 레벨에서 방어. race condition 시에도 데이터 무결성 보장.
|
||||
|
||||
## 추가 제약
|
||||
|
||||
| 제약 | 대상 | 용도 |
|
||||
|---|---|---|
|
||||
| `idx_dept_info_company_name_unique` | DEPT_INFO `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL` | 회사 내 동일 이름 active 부서 중복 방지 |
|
||||
| `idx_user_dept_user_dept_unique` | USER_DEPT `(USER_ID, DEPT_CODE)` | 동일 사용자가 같은 부서 중복 등록 방지 |
|
||||
|
||||
(USER_DEPT 에 이미 PK 또는 UNIQUE 가 있으면 IF NOT EXISTS 로 안전 적용)
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- 085: 부서관리 무결성 제약
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dept_info_company_name_unique
|
||||
ON DEPT_INFO (COMPANY_CODE, LOWER(TRIM(DEPT_NAME)))
|
||||
WHERE DELETED_AT IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_dept_user_dept_unique
|
||||
ON USER_DEPT (USER_ID, DEPT_CODE);
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
각 테넌트 DB 에 적용 필요. 운영 DB 와 모든 회사 DB 에 한 번씩.
|
||||
|
||||
```bash
|
||||
# 메타 DB
|
||||
psql -h <host> -U postgres -d invyone -f RUN_085.sql
|
||||
|
||||
# 각 테넌트 DB (예시)
|
||||
for db in $(psql -tA -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||
psql -h <host> -U postgres -d "$db" -f RUN_085.sql
|
||||
done
|
||||
```
|
||||
|
||||
## 사전 점검
|
||||
|
||||
기존 데이터에 중복이 있으면 인덱스 생성이 실패합니다. 사전 확인:
|
||||
|
||||
```sql
|
||||
-- 부서명 중복 확인
|
||||
SELECT COMPANY_CODE, LOWER(TRIM(DEPT_NAME)), COUNT(*)
|
||||
FROM DEPT_INFO
|
||||
WHERE DELETED_AT IS NULL
|
||||
GROUP BY COMPANY_CODE, LOWER(TRIM(DEPT_NAME))
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- USER_DEPT 중복 확인
|
||||
SELECT USER_ID, DEPT_CODE, COUNT(*)
|
||||
FROM USER_DEPT
|
||||
GROUP BY USER_ID, DEPT_CODE
|
||||
HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
중복이 있다면 수동 정리 후 인덱스 생성.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS idx_dept_info_company_name_unique;
|
||||
DROP INDEX IF EXISTS idx_user_dept_user_dept_unique;
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
# Windows + Docker Desktop 전용 override.
|
||||
#
|
||||
# 배경: Docker Desktop (Windows + WSL2) 의 bind mount 가 host inotify 이벤트를
|
||||
# 컨테이너로 전파하지 못해, Turbopack 의 file watcher 가 host 편집을 감지 못 함.
|
||||
# webpack 은 WATCHPACK_POLLING=true 폴백을 지원하므로, Windows 에서만
|
||||
# Turbopack 을 끄고 webpack 으로 폴백 → 자동 HMR 복원.
|
||||
#
|
||||
# 적용 범위: scripts/start/invyone-start-docker-all.bat 에서 명시적으로 -f 추가.
|
||||
# Mac/Linux 사용자가 쓰는 다른 진입점에는 영향 없음.
|
||||
#
|
||||
# Trade-off: 첫 컴파일 약간 느려짐 (~10-30%). 그러나 수정 → 화면 반영이 1~3초로
|
||||
# 단축되어 전체 개발 사이클은 압도적으로 빨라짐.
|
||||
|
||||
services:
|
||||
frontend:
|
||||
command: ["npm", "run", "dev:docker:nopack"]
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
|
||||
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
|
||||
import type { Department } from "@/types/department";
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
|
||||
/**
|
||||
* 부서 관리 메인 페이지
|
||||
* 좌측: 부서 구조, 우측: 부서 인원
|
||||
*/
|
||||
export default function DepartmentManagementPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { companyCode } = params as { companyCode: string };
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("structure");
|
||||
const [companyName, setCompanyName] = useState<string>("");
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 부서원 변경 시 부서 구조 새로고침
|
||||
const handleMemberChange = () => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// 회사 정보 로드
|
||||
useEffect(() => {
|
||||
const loadCompanyInfo = async () => {
|
||||
const response = await getCompanyList();
|
||||
if (response.success && response.data) {
|
||||
const company = response.data.find((c) => c.company_code === companyCode);
|
||||
if (company) {
|
||||
setCompanyName(company.company_name);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCompanyInfo();
|
||||
}, [companyCode]);
|
||||
|
||||
const handleBackToList = () => {
|
||||
router.push("/admin/userMng/companyList");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
회사 목록
|
||||
</Button>
|
||||
<div className="bg-border h-6 w-px" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
|
||||
<p className="text-muted-foreground text-sm">부서 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 탭 네비게이션 (모바일용) */}
|
||||
<div className="lg:hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="structure">부서 구조</TabsTrigger>
|
||||
<TabsTrigger value="members">부서 인원</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="structure" className="mt-4">
|
||||
<DepartmentStructure
|
||||
companyCode={companyCode}
|
||||
selectedDepartment={selectedDepartment}
|
||||
onSelectDepartment={setSelectedDepartment}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members" className="mt-4">
|
||||
<DepartmentMembers
|
||||
companyCode={companyCode}
|
||||
selectedDepartment={selectedDepartment}
|
||||
onMemberChange={handleMemberChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 좌우 레이아웃 (데스크톱) */}
|
||||
<div className="hidden h-full gap-6 lg:flex">
|
||||
{/* 좌측: 부서 구조 (20%) */}
|
||||
<div className="w-[20%] border-r pr-6">
|
||||
<DepartmentStructure
|
||||
companyCode={companyCode}
|
||||
selectedDepartment={selectedDepartment}
|
||||
onSelectDepartment={setSelectedDepartment}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 부서 인원 (80%) */}
|
||||
<div className="w-[80%] pl-0">
|
||||
<DepartmentMembers
|
||||
companyCode={companyCode}
|
||||
selectedDepartment={selectedDepartment}
|
||||
onMemberChange={handleMemberChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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`);
|
||||
};
|
||||
|
||||
// 디스크 사용량 포맷팅 함수
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// V1: soft-delete 된 부서 표시 토글
|
||||
const [showDeleted, setShowDeleted] = useState(false);
|
||||
|
||||
// 부서 추가 모달
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
|
||||
@@ -42,15 +45,15 @@ export function DepartmentStructure({
|
||||
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
|
||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(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 (
|
||||
<div key={dept.dept_code}>
|
||||
{/* 부서 항목 */}
|
||||
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
|
||||
<div
|
||||
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
|
||||
isSelected ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
|
||||
style={{ marginLeft: `${level * 16}px` }}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
|
||||
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
|
||||
{/* 확장/축소 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
@@ -218,32 +253,54 @@ export function DepartmentStructure({
|
||||
<Users className="h-3 w-3" />
|
||||
<span>{dept.member_count || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* deleted 배지 */}
|
||||
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider">삭제됨</span>}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddDepartment(dept.dept_code);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-primary h-6 w-6"
|
||||
title="복구"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRestoreDepartment(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="하위 부서 추가"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddDepartment(dept.dept_code);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
title="삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,10 +316,23 @@ export function DepartmentStructure({
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">부서 구조</h3>
|
||||
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
최상위 부서 추가
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* V1: soft-delete 부서 표시 토글 */}
|
||||
<Button
|
||||
variant={showDeleted ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-2 text-sm"
|
||||
onClick={() => setShowDeleted((v) => !v)}
|
||||
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
|
||||
>
|
||||
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
|
||||
</Button>
|
||||
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
최상위 부서 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 트리 */}
|
||||
@@ -338,7 +408,9 @@ export function DepartmentStructure({
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{deptToDelete?.name}</span> 부서를 삭제하시겠습니까?
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">이 작업은 되돌릴 수 없습니다.</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
부서원은 보존됩니다. 휴지통(상단 '삭제 부서 보기' 토글)에서 복구할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
||||
@@ -71,8 +71,19 @@ export default function Step1Basic({
|
||||
const r: any = await checkAvailability(payload);
|
||||
if (payload.subdomain === state.subdomain) {
|
||||
const sub = r?.subdomain;
|
||||
// 우선순위: reserved > valid_format > available
|
||||
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
|
||||
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
|
||||
setSubStatus(
|
||||
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
|
||||
!sub
|
||||
? "idle"
|
||||
: sub.reserved
|
||||
? "reserved"
|
||||
: !sub.valid_format
|
||||
? "invalid"
|
||||
: sub.available
|
||||
? "available"
|
||||
: "taken",
|
||||
);
|
||||
}
|
||||
if (payload.dbPrefix === state.db_prefix) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
|
||||
*/
|
||||
|
||||
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
|
||||
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
|
||||
<XCircle size={13} /> 이미 사용 중
|
||||
</span>
|
||||
);
|
||||
if (status === "reserved")
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--v5-red)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<XCircle size={13} /> 예약어 (사용 불가)
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
|
||||
/** TextInput 의 status prop 과 매핑 */
|
||||
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
|
||||
if (a === "available") return "ok";
|
||||
if (a === "taken" || a === "invalid") return "err";
|
||||
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
|
||||
*
|
||||
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
|
||||
*
|
||||
* 사용 예:
|
||||
* <DepartmentPicker
|
||||
* companyCode="INVYONE"
|
||||
* mode="single"
|
||||
* value={parentDeptCode}
|
||||
* open={isOpen}
|
||||
* onSelect={(code) => setParentDeptCode(code as string)}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* excludeCodes={[currentDeptCode]}
|
||||
* />
|
||||
*
|
||||
* 동작:
|
||||
* - shadcn Dialog 안에 검색박스 + 트리뷰
|
||||
* - 부모 클릭 시 자식 cascade 펼침
|
||||
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
|
||||
* - single: 클릭 즉시 onSelect → close
|
||||
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
|
||||
* - excludeCodes 에 포함된 dept 는 disabled
|
||||
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
|
||||
*
|
||||
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Department } from "@/types/department";
|
||||
import { getDepartments } from "@/lib/api/department";
|
||||
|
||||
export interface DepartmentPickerProps {
|
||||
companyCode: string;
|
||||
mode: "single" | "multi";
|
||||
/** 현재 선택값. single 이면 string, multi 면 string[] */
|
||||
value?: string | string[];
|
||||
open: boolean;
|
||||
onSelect: (code: string | string[]) => void;
|
||||
onClose: () => void;
|
||||
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
|
||||
excludeCodes?: string[];
|
||||
/** soft-delete 된 부서도 보여줄지 (default false) */
|
||||
includeDeleted?: boolean;
|
||||
/** 모달 헤더 타이틀 (default: "부서 선택") */
|
||||
title?: string;
|
||||
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
|
||||
allowRoot?: boolean;
|
||||
}
|
||||
|
||||
export function DepartmentPicker({
|
||||
companyCode,
|
||||
mode,
|
||||
value,
|
||||
open,
|
||||
onSelect,
|
||||
onClose,
|
||||
excludeCodes,
|
||||
includeDeleted = false,
|
||||
title = "부서 선택",
|
||||
allowRoot = false,
|
||||
}: DepartmentPickerProps) {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState(""); // debounced
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// value -> selected 동기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "single") {
|
||||
setSelected(new Set(typeof value === "string" && value ? [value] : []));
|
||||
} else {
|
||||
setSelected(new Set(Array.isArray(value) ? value : []));
|
||||
}
|
||||
}, [value, mode, open]);
|
||||
|
||||
// 검색어 debounce 200ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 부서 목록 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
getDepartments(companyCode, { includeDeleted })
|
||||
.then((res: any) => {
|
||||
if (cancelled) return;
|
||||
if (res?.success && Array.isArray(res?.data)) {
|
||||
setDepartments(res.data);
|
||||
} else {
|
||||
setDepartments([]);
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setDepartments([]))
|
||||
.finally(() => !cancelled && setIsLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, companyCode, includeDeleted]);
|
||||
|
||||
// 코드 -> 부서 맵 (검색·자식 조회용)
|
||||
const byCode = useMemo(() => {
|
||||
const m = new Map<string, Department>();
|
||||
for (const d of departments) m.set(d.dept_code, d);
|
||||
return m;
|
||||
}, [departments]);
|
||||
|
||||
// 검색 매칭 (이름·코드 부분일치)
|
||||
const isMatch = (d: Department): boolean => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
d.dept_name.toLowerCase().includes(searchTerm) ||
|
||||
d.dept_code.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
};
|
||||
|
||||
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
|
||||
const visibleCodes = useMemo(() => {
|
||||
if (!searchTerm) return null; // null = 전체 visible
|
||||
const visible = new Set<string>();
|
||||
for (const d of departments) {
|
||||
if (isMatch(d)) {
|
||||
visible.add(d.dept_code);
|
||||
// 조상 visible (부모-부모-...) — 사이클 차단
|
||||
const visited = new Set<string>([d.dept_code]);
|
||||
let parentCode = d.parent_dept_code;
|
||||
while (parentCode && !visited.has(parentCode)) {
|
||||
visited.add(parentCode);
|
||||
visible.add(parentCode);
|
||||
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}, [searchTerm, departments, byCode]);
|
||||
|
||||
// 부모 코드 → 자식 정렬 리스트
|
||||
const childrenOf = (parentCode: string | null): Department[] => {
|
||||
return departments
|
||||
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
};
|
||||
|
||||
// 부모 + 자식 (재귀) cascade 코드들
|
||||
const collectDescendants = (rootCode: string): string[] => {
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const dfs = (code: string) => {
|
||||
if (visited.has(code)) return;
|
||||
visited.add(code);
|
||||
result.push(code);
|
||||
for (const child of childrenOf(code)) {
|
||||
dfs(child.dept_code);
|
||||
}
|
||||
};
|
||||
dfs(rootCode);
|
||||
return result;
|
||||
};
|
||||
|
||||
const toggleExpand = (code: string) => {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(code)) next.delete(code);
|
||||
else next.add(code);
|
||||
setExpanded(next);
|
||||
};
|
||||
|
||||
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
|
||||
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
|
||||
|
||||
const handleNodeClick = (d: Department) => {
|
||||
if (isExcluded(d.dept_code)) return;
|
||||
if (mode === "single") {
|
||||
onSelect(d.dept_code);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// multi: 자기 + 자손 모두 토글
|
||||
const next = new Set(selected);
|
||||
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
|
||||
const allSelected = codes.every((c) => next.has(c));
|
||||
if (allSelected) {
|
||||
for (const c of codes) next.delete(c);
|
||||
} else {
|
||||
for (const c of codes) next.add(c);
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const handleConfirmMulti = () => {
|
||||
onSelect(Array.from(selected));
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 트리 렌더 (재귀, visited Set 사이클 차단)
|
||||
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
|
||||
const list = childrenOf(parentCode);
|
||||
return list.map((d) => {
|
||||
if (visited.has(d.dept_code)) return null; // 사이클 차단
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(d.dept_code);
|
||||
|
||||
const hasChildren = childrenOf(d.dept_code).length > 0;
|
||||
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
|
||||
const isSel = selected.has(d.dept_code);
|
||||
const excluded = isExcluded(d.dept_code);
|
||||
const deleted = isDeleted(d);
|
||||
|
||||
// 검색 시: visible 아닌 노드는 숨김
|
||||
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
|
||||
|
||||
return (
|
||||
<div key={d.dept_code}>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
|
||||
excluded
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "hover:bg-muted cursor-pointer"
|
||||
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
|
||||
style={{ paddingLeft: `${level * 16 + 6}px` }}
|
||||
onClick={() => handleNodeClick(d)}
|
||||
>
|
||||
{/* expand/collapse */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(d.dept_code);
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4" />
|
||||
)}
|
||||
|
||||
{/* multi: 체크 표시 */}
|
||||
{mode === "multi" && (
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border ${
|
||||
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
|
||||
}`}
|
||||
>
|
||||
{isSel && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부서명 + 코드 */}
|
||||
<div className="flex flex-1 flex-col leading-tight">
|
||||
<span className="font-medium">{d.dept_name}</span>
|
||||
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
|
||||
</div>
|
||||
|
||||
{deleted && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">삭제됨</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 루트 — 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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="부서명 또는 코드로 검색"
|
||||
className="pl-8 pr-8"
|
||||
autoFocus
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput("")}
|
||||
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
||||
title="검색어 지우기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
|
||||
{mode === "single" && allowRoot && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect("");
|
||||
onClose();
|
||||
}}
|
||||
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
📂 (최상위 — 부모 없음)
|
||||
</button>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
||||
) : !hasAny ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">부서가 없습니다.</div>
|
||||
) : noResults ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">검색 결과 없음</div>
|
||||
) : (
|
||||
renderTree(null, 0, new Set())
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{mode === "multi" && (
|
||||
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
|
||||
선택 ({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DepartmentPicker;
|
||||
@@ -196,11 +196,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"),
|
||||
|
||||
@@ -409,7 +409,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
|
||||
if (pathname.startsWith("/admin") && pathname !== "/admin") {
|
||||
store.setMode("admin");
|
||||
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", admin_url: pathname });
|
||||
// menu API 가 실패하는 환경 (SUPER_ADMIN cross-tenant 등) 에서도 한글 라벨 유지
|
||||
const ADMIN_PATH_LABELS: Record<string, string> = {
|
||||
"/admin/userMng/deptMngList": "부서관리",
|
||||
};
|
||||
const fallbackTitle = ADMIN_PATH_LABELS[pathname] || pathname.split("/").pop() || "관리자";
|
||||
store.openTab({ type: "admin", title: fallbackTitle, admin_url: pathname });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -902,6 +907,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
}
|
||||
}, [activeTab, uiMenus, isMenuActive]);
|
||||
|
||||
// URL 직접 진입 / sessionStorage 복원 시 admin 탭의 영어 path-segment title 을
|
||||
// menu_name_kor (uiMenus 의 tabTitle/label/name) 로 갱신.
|
||||
// menu API 가 실패한 환경 (SUPER_ADMIN cross-tenant) 에서도 동작하도록 hardcoded map 도 같이 검사.
|
||||
useEffect(() => {
|
||||
const ADMIN_PATH_LABELS: Record<string, string> = {
|
||||
"/admin/userMng/deptMngList": "부서관리",
|
||||
};
|
||||
const store = useTabStore.getState();
|
||||
for (const tab of store.admin.tabs) {
|
||||
if (tab.type !== "admin" || !tab.admin_url) continue;
|
||||
const matched = uiMenus.find((m: any) => m.url === tab.admin_url);
|
||||
const koreanTitle: string | undefined =
|
||||
matched?.tabTitle || matched?.label || matched?.name || ADMIN_PATH_LABELS[tab.admin_url];
|
||||
if (koreanTitle && tab.title !== koreanTitle) {
|
||||
store.updateTabTitle(tab.id, koreanTitle);
|
||||
}
|
||||
}
|
||||
}, [uiMenus]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,6 +41,7 @@ interface TabState {
|
||||
closeAllTabs: () => void;
|
||||
|
||||
updateTabOrder: (fromIndex: number, toIndex: number) => void;
|
||||
updateTabTitle: (tabId: string, title: string) => void;
|
||||
}
|
||||
|
||||
// --- 헬퍼 함수 ---
|
||||
@@ -195,6 +196,16 @@ export const useTabStore = create<TabState>()(
|
||||
newTabs.splice(toIndex, 0, moved);
|
||||
set({ [mk]: { ...modeData, tabs: newTabs } });
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1 || modeData.tabs[idx].title === title) return;
|
||||
const newTabs = [...modeData.tabs];
|
||||
newTabs[idx] = { ...newTabs[idx], title };
|
||||
set({ [mk]: { ...modeData, tabs: newTabs } });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "erp-tab-store",
|
||||
|
||||
@@ -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; // 일괄등록용 (자동 부여 시 미전달)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Object> 만 (DTO 금지) |
|
||||
| 화면 코드 | 4-digit (MOGA0030 / DEPT01_010) | URL 기반 |
|
||||
| Save 패턴 | MyBatis MERGE / UPSERT | INSERT / UPDATE 분리 |
|
||||
| 다국어 | DEPT_NM + DEPT_NM_ENG | 미지원 |
|
||||
| 부서 코드 | 사용자 입력 + 중복체크 | 자동 생성 (`DEPT_<n>`) |
|
||||
|
||||
---
|
||||
|
||||
## 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 배치)
|
||||
```
|
||||
@@ -0,0 +1,365 @@
|
||||
# 부서관리 페이지 버그 정리 — 시나리오 중심
|
||||
|
||||
작성일: 2026-05-08
|
||||
목적: 사용자/개발자가 "어떤 일이 벌어지는지" 바로 이해할 수 있게 정리
|
||||
|
||||
---
|
||||
|
||||
## 🔴 진짜 위험한 것 — 즉시 수정
|
||||
|
||||
### 1. 다른 회사 사용자 정보가 그냥 보입니다 🚨
|
||||
|
||||
**어디서**: `DepartmentController.java:234` `searchUsers`
|
||||
|
||||
**무슨 일이 벌어지나**:
|
||||
- 부서원 추가 시 사용자 검색 API 가 있음
|
||||
- 그런데 이 API 는 "당신이 어느 회사 사람인지" 검사를 안 함
|
||||
- 즉, A 회사 직원이 URL 에 B 회사 코드만 박으면 → B 회사 직원 명단을 그대로 받아옴
|
||||
|
||||
**왜 심각한가**:
|
||||
- 멀티테넌시 (회사간 격리) 의 핵심 원칙이 깨짐
|
||||
- 다른 모든 부서 API 는 회사 격리 검사를 하는데 이거 하나만 빠짐
|
||||
- 또한 일반 USER 도 admin 검사 없이 호출 가능
|
||||
|
||||
**고치는 법**:
|
||||
- 컨트롤러에 `if (!isSuperAdmin(...) && !userCompanyCode.equals(companyCode)) return 403;` 추가
|
||||
- admin role 체크도 추가
|
||||
|
||||
---
|
||||
|
||||
### 2. 사용자의 "주 부서" 가 통째로 사라질 수 있습니다 🚨
|
||||
|
||||
**어디서**: `DepartmentService.java:356-370` `setPrimaryDept`
|
||||
|
||||
**무슨 일이 벌어지나**:
|
||||
1. 김철수 씨가 영업부 소속 (영업부 = 주 부서)
|
||||
2. 누군가 "김철수를 마케팅부의 주 부서로 설정" API 를 호출 (그런데 김철수는 마케팅부 소속이 아님)
|
||||
3. 코드는 "일단 김철수의 모든 주 부서 표시 해제" 부터 실행 → 영업부 주 부서 표시 사라짐
|
||||
4. 그 다음 "마케팅부를 주 부서로 설정" 시도 → 김철수가 마케팅부 소속이 아니므로 0 row 변경 (조용히 실패)
|
||||
5. 결과: 김철수는 어디에도 주 부서가 없는 사용자가 됨
|
||||
|
||||
**왜 심각한가**:
|
||||
- 데이터 무결성 (모든 사용자는 주 부서 1개) 이 깨진 채 commit
|
||||
- 에러도 안 남
|
||||
- 결재 흐름 등에서 주 부서 기준 로직이 망가짐
|
||||
|
||||
**고치는 법**:
|
||||
- API 진입 시 `selectExistingMember(userId, deptCode)` 로 멤버십 검증
|
||||
- 멤버 아니면 400 반환
|
||||
- `setUserPrimaryDept` 가 0 row affected 면 예외 던져서 트랜잭션 롤백
|
||||
|
||||
---
|
||||
|
||||
### 3. 다른 회사의 부서를 부모로 지정할 수 있습니다 🚨
|
||||
|
||||
**어디서**: `DepartmentService.java:107` (생성), `:251` `verifyParentCycle` (수정)
|
||||
|
||||
**무슨 일이 벌어지나**:
|
||||
- 부서 생성/수정 시 `parent_dept_code` 를 받음
|
||||
- 이 코드가 (a) 실제 존재하는지, (b) 같은 회사인지, (c) 삭제된 건 아닌지 **전혀 검사 안 함**
|
||||
- 사이클 검사 (`verifyParentCycle`) 는 있지만 "부모가 같은 회사인지" 검증은 빠져있음
|
||||
|
||||
**왜 심각한가**:
|
||||
- SUPER_ADMIN 또는 컨트롤러 우회 경로로 다른 회사 부서를 부모로 지정 가능
|
||||
- 회사간 데이터가 트리로 엮여 멀티테넌시 침해
|
||||
|
||||
**고치는 법**:
|
||||
- create/update 양쪽에서 parent 부서 조회 → company_code 일치 검증 + deleted_at IS NULL 검증
|
||||
|
||||
---
|
||||
|
||||
## 🟠 사용자가 자주 마주칠 버그
|
||||
|
||||
### 4. 회사를 바꿔도 우측 상세 패널에 이전 회사 부서가 그대로 남습니다
|
||||
|
||||
**어디서**: `page.tsx:658` 회사 Select 의 `onValueChange`
|
||||
|
||||
**시나리오**:
|
||||
1. 회사 A 의 "영업부" 클릭 → 우측에 영업부 정보
|
||||
2. 좌측 상단에서 회사 B 로 변경
|
||||
3. 좌측 트리는 회사 B 부서로 갱신됨
|
||||
4. **그런데 우측 상세 패널은 여전히 영업부 (회사 A) 정보 표시**
|
||||
5. 사용자가 모르고 저장 버튼 누르면 회사 코드 mismatch 로 이상한 일이 벌어짐
|
||||
|
||||
**고치는 법**:
|
||||
- 회사 변경 시 `handleClearDetail()` 호출하여 상세 패널 초기화
|
||||
|
||||
---
|
||||
|
||||
### 5. 부서 정렬 변경 (한 칸 위/아래) 이 부분 실패할 수 있습니다
|
||||
|
||||
**어디서**: `page.tsx:403` `handleMove`
|
||||
|
||||
**시나리오**:
|
||||
1. 형제 부서 5개 정렬 변경 시 코드는 5번의 PUT 요청을 동시에 보냄 (`Promise.all`)
|
||||
2. 그 중 1개가 실패하면 → 4개는 이미 DB 에 반영됨
|
||||
3. 코드는 toast 만 띄우고 화면 새로고침은 **안 함** (catch 블록에 `loadDepartments()` 없음)
|
||||
4. 화면은 이전 정렬, DB 는 부분 업데이트된 상태로 영구 불일치
|
||||
|
||||
**고치는 법**:
|
||||
- catch 블록에서도 `loadDepartments()` 호출하여 DB 상태로 동기화
|
||||
- 더 좋은 방법: 백엔드에 "정렬 일괄 업데이트" API 만들어 트랜잭션 보장
|
||||
|
||||
---
|
||||
|
||||
### 6. 검색하면 트리가 망가집니다
|
||||
|
||||
**어디서**: `page.tsx:231-246` `filteredDepts` + `childrenOf`
|
||||
|
||||
**시나리오**:
|
||||
- "인사" 검색 → "인사팀" 부서가 매칭됨
|
||||
- 그런데 인사팀의 부모인 "경영지원본부" 는 매칭 안 됨
|
||||
- `childrenOf(null)` 은 매칭된 루트만 보여주는데 인사팀의 부모가 없으니 트리에 안 나타남
|
||||
- **결과: 검색 결과가 있어도 트리에 아무것도 안 보일 수 있음**
|
||||
|
||||
**고치는 법**:
|
||||
- 매칭된 노드의 부모 체인을 자동으로 결과에 포함
|
||||
- 또는 검색 모드에서는 트리가 아닌 평면 리스트로 표시
|
||||
|
||||
---
|
||||
|
||||
### 7. `update` 가 삭제된 부서도 변경합니다 (silent corruption)
|
||||
|
||||
**어디서**: `department.xml:160-179` `updateDepartment`
|
||||
|
||||
**무슨 일이 벌어지나**:
|
||||
1. 사용자가 휴지통의 "옛 부서" 를 수정하려 시도
|
||||
2. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증 후 service 호출
|
||||
3. SQL UPDATE 는 `WHERE DEPT_CODE = ?` 만 있어 삭제 여부 무관하게 update 실행
|
||||
4. service 가 다음 줄에서 `selectDepartmentByCode` (DELETED_AT IS NULL) 로 재조회 → null
|
||||
5. controller 가 404 반환
|
||||
|
||||
**결과**: 사용자는 "404 부서를 찾을 수 없음" 메시지를 보지만 **DB 는 이미 update 된 상태**. 데이터 손상.
|
||||
|
||||
**고치는 법**:
|
||||
- SQL UPDATE WHERE 절에 `AND DELETED_AT IS NULL` 추가
|
||||
- 0 row 면 service 가 null 반환 → 404 일관됨
|
||||
|
||||
---
|
||||
|
||||
### 8. 부서명을 바꿀 때 중복 검사를 안 합니다
|
||||
|
||||
**어디서**: `DepartmentService.java:131-170` `updateDepartment`
|
||||
|
||||
**시나리오**:
|
||||
- 새 부서 생성 시에는 "이미 같은 이름 있어요" 체크함
|
||||
- 그런데 **부서명 수정 시에는 체크 없음**
|
||||
- 결과: 기존 "영업1팀" 을 "영업팀" 으로 수정 → 이미 있는 "영업팀" 과 동일 이름 두 부서 공존 가능
|
||||
|
||||
**같은 문제 — 복구 시에도**: `restoreDepartment` 에서도 동일. 삭제했던 부서를 살릴 때 같은 이름의 active 부서가 있어도 충돌 검사 없음.
|
||||
|
||||
**고치는 법**:
|
||||
- update/restore 양쪽에 `selectDuplicateDeptName` 호출 추가
|
||||
- 더 좋은 방법: DB 에 partial UNIQUE 인덱스 (`(company_code, lower(trim(dept_name))) WHERE deleted_at IS NULL`) 추가
|
||||
|
||||
---
|
||||
|
||||
### 9. 일반 회사 관리자가 글로벌 부서를 삭제할 수 있습니다
|
||||
|
||||
**어디서**: `DepartmentController.java:135` `deleteDepartment`
|
||||
|
||||
**시나리오**:
|
||||
- 시스템 공통 부서 (company_code='*') 가 있음 (예: 시스템 관리자, 공통 부서 등)
|
||||
- 어느 회사 admin 이든 이 글로벌 부서의 dept_code 를 알면 DELETE 호출 가능
|
||||
- 코드의 권한 검사: `isAdmin(role)` 통과 + `canAccessDept` (글로벌이라 통과) → 삭제 진행
|
||||
|
||||
**고치는 법**:
|
||||
- 글로벌 부서 (`company_code = '*'`) 의 write 작업은 `isSuperAdmin` 만 허용
|
||||
|
||||
---
|
||||
|
||||
### 10. 사용자가 입력한 부서코드가 조용히 무시됩니다 -> 사용자가 부서코드를 입력하게 하지말고 시스템이 동적으로 생성할수있게변경 입력값은 받지않게 수정
|
||||
|
||||
**어디서**: `DepartmentService.java:85-99` `createDepartment`
|
||||
|
||||
**시나리오**:
|
||||
1. 사용자가 부서코드란에 "SALES-A" 입력 (대시 포함)
|
||||
2. 백엔드 정규식은 `^[A-Za-z0-9_]+$` 만 허용 → 불일치
|
||||
3. 코드는 예외를 던지지 않고 자동으로 `DEPT_47` 같은 코드 생성
|
||||
4. 응답은 201 성공, 하지만 `dept_code` 가 `DEPT_47` (사용자가 입력한 값과 다름)
|
||||
5. 사용자는 "SALES-A" 로 만들어졌다고 착각
|
||||
|
||||
**고치는 법**:
|
||||
- 정규식 불일치 시 400 + "부서 코드는 영문/숫자/언더스코어만 가능합니다" 메시지
|
||||
|
||||
---
|
||||
|
||||
### 11. 새 부서의 "시작일" 이 항상 오늘로 강제 저장됩니다
|
||||
|
||||
**어디서**: `page.tsx:113` `emptyDraft`, `page.tsx:957` 일괄등록
|
||||
|
||||
**시나리오**:
|
||||
- UI 에 시작일/종료일 입력란은 V2 로 미뤄져 hidden 처리됨 (`{false && ...}`)
|
||||
- 그런데 `emptyDraft` 는 `start_date: new Date().toISOString().slice(0,10)` 으로 항상 today 입력
|
||||
- 결과: 사용자는 시작일 입력란을 본 적도 없는데 모든 신규 부서에 today 가 강제 저장됨
|
||||
- 일괄등록도 동일 — 100개 import 하면 100개 모두 import 한 날짜가 시작일
|
||||
|
||||
**고치는 법**:
|
||||
- `emptyDraft` 의 `start_date` 기본값을 빈 문자열로 변경
|
||||
- 또는 컬럼이 hidden 인 상태에서는 payload 에 포함하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 🟡 알아두면 좋은 문제
|
||||
|
||||
### 12. 부서원 목록이 다른 부서 것으로 잠깐 보일 수 있습니다
|
||||
|
||||
**어디서**: `page.tsx:219-228` 멤버 fetch useEffect
|
||||
|
||||
**상황**: 네트워크 느린 환경에서 부서 A 클릭 → 멤버 로딩 중 → 부서 B 클릭 → A 의 응답이 늦게 도착하여 B 의 패널에 A 의 멤버 표시. fetch 취소 로직 (AbortController) 없음.
|
||||
|
||||
**고치는 법**: useEffect 에 cancellation flag 추가
|
||||
|
||||
---
|
||||
|
||||
### 13. 삭제 다이얼로그를 열어둔 채 다른 부서를 선택하면 잘못된 부서가 삭제됩니다
|
||||
|
||||
**어디서**: `page.tsx:503` `handleDelete`
|
||||
|
||||
**상황**:
|
||||
1. 영업부 선택 → 메인 패널의 "삭제" 버튼 클릭 → 다이얼로그 열림
|
||||
2. 다이얼로그 뒤로 트리에서 다른 부서 (예: 인사부) 클릭 → `selectedCode` = 인사부
|
||||
3. 다이얼로그의 "삭제" 확정 → 코드는 최신 `selectedCode` 사용 → **인사부가 삭제됨**
|
||||
|
||||
shadcn Dialog 는 보통 포커스 트랩이 있지만 dismiss 가능한 설정이면 위험.
|
||||
|
||||
**고치는 법**: 다이얼로그 열 때 dept_code 를 캡처해서 클로저로 전달
|
||||
|
||||
---
|
||||
|
||||
### 14. X 버튼 누르면 미저장 변경이 경고 없이 사라집니다
|
||||
|
||||
**어디서**: `page.tsx:299` `handleClearDetail`
|
||||
|
||||
**상황**: 부서 정보 수정 중 우측 상단 X 버튼 클릭 → 즉시 폼 초기화. `isDirty` 상태는 계산되지만 사용 안 함.
|
||||
|
||||
**고치는 법**: `if (isDirty && !confirm("변경사항이 있습니다. 폐기하시겠습니까?")) return;`
|
||||
|
||||
---
|
||||
|
||||
### 15. 변경이력 기능이 화면만 있고 실제로는 동작 안 합니다
|
||||
|
||||
**어디서**: `page.tsx:997-1027`
|
||||
|
||||
**상황**: "변경이력" 버튼 클릭 → 다이얼로그 열림 → 항상 "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 표시. 실제 API 호출 코드 없음.
|
||||
|
||||
**고치는 법**: 백엔드 audit log API 와 연동. 기능 미구현 명시.
|
||||
|
||||
---
|
||||
|
||||
### 16. 부서원 추가/제거 UI 가 없습니다
|
||||
|
||||
**어디서**: `page.tsx:1469` `MembersPanel`
|
||||
|
||||
**상황**:
|
||||
- 부서원 정보 탭에서 멤버 목록만 표시
|
||||
- 추가/제거 버튼 없음
|
||||
- 백엔드 API (`addDepartmentMember`, `removeDepartmentMember`, `searchUsers`) 는 존재하지만 UI 미연결
|
||||
|
||||
**고치는 법**: 멤버 추가 모달 + 행별 제거 버튼 구현
|
||||
|
||||
---
|
||||
|
||||
### 17. 일괄등록 실패 사유가 안 보입니다
|
||||
|
||||
**어디서**: `page.tsx:957-985`
|
||||
|
||||
**상황**: 100개 import → "성공 95건 / 실패 5건" 토스트만. 어떤 부서코드가 왜 실패했는지 알 수 없어 재시도 불가.
|
||||
|
||||
**고치는 법**: 실패 항목을 모달이나 다운로드 가능한 결과 화면으로 표시
|
||||
|
||||
---
|
||||
|
||||
## 🟢 사소한 문제 (정리/보강)
|
||||
|
||||
### 18. 동시에 부서 생성하면 실패할 수 있음
|
||||
|
||||
**어디서**: `DepartmentService.java:96` `selectNextDeptNumber`
|
||||
|
||||
`MAX(번호) + 1` 이 비원자적이라 두 사람이 동시에 부서 생성 시 같은 번호 → 두 번째 INSERT 가 PK 위반으로 5xx 에러. catch 로 잡지도 않아 사용자에게 raw 500 노출.
|
||||
|
||||
### 19. 부서 코드/이름의 앞뒤 공백이 그대로 저장됨
|
||||
|
||||
**어디서**: `DepartmentService.java:107`+ `nullIfBlank`
|
||||
|
||||
`nullIfBlank` 는 "완전히 빈 문자열만 null 로" 처리. ` " DEPT_3 "` 같은 공백 포함 코드는 그대로 저장됨. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정.
|
||||
|
||||
### 20. 검색 후 "전체 펼치기" 누르면 검색 해제 시 부분 펼침 상태로 남음
|
||||
|
||||
**어디서**: `page.tsx:249` `expandAll`
|
||||
|
||||
`filteredDepts` 만 펼치므로 검색 결과만 expanded. 검색 해제 후 트리가 일부만 펼쳐진 상태로 보임.
|
||||
|
||||
### 21. 컨트롤러에 dead code 있음
|
||||
|
||||
**어디서**: `DepartmentController.java:271-272`
|
||||
|
||||
```java
|
||||
Object userIdObj = body.get("user_id");
|
||||
if (userIdObj == null) userIdObj = body.get("user_id"); // 같은 키 두 번
|
||||
```
|
||||
|
||||
camelCase fallback 의도였는데 오타로 같은 snake_case. → `body.get("userId")` 로 수정.
|
||||
|
||||
### 22. Picker 에 "최상위로 이동" 옵션이 UI 에 없음
|
||||
|
||||
**어디서**: `page.tsx:920` `DepartmentPicker.onSelect`
|
||||
|
||||
`handleConfirmMoveTo(null)` 코드 경로는 있지만 picker UI 에서 "부모 없음 (최상위)" 선택지가 없어 트리거 불가.
|
||||
|
||||
### 23. SQL 의 LIKE 와일드카드 미이스케이프
|
||||
|
||||
**어디서**: `DepartmentService.java:283` `searchUsers`
|
||||
|
||||
`%` + 사용자입력 + `%`. 사용자가 `_` 만 입력하면 모든 사용자 매칭. 데이터 누출은 아니지만 enumeration 가능.
|
||||
|
||||
### 24. dead SQL 쿼리 잔존
|
||||
|
||||
**어디서**: `department.xml:192-195` `deleteUserDeptByDeptCode`
|
||||
|
||||
주석에 "Slice 2.1 이후 사용 안 함" 명시되어 있지만 쿼리 그대로 남아있음.
|
||||
|
||||
### 25. 부서원 목록 INNER JOIN
|
||||
|
||||
**어디서**: `department.xml:213` `selectDeptMembers`
|
||||
|
||||
USER_INFO 에서 사용자가 삭제되면 USER_DEPT row 가 남아있어도 멤버에 안 나타남. `member_count` 와 실제 표시되는 멤버 수가 불일치 가능.
|
||||
|
||||
---
|
||||
|
||||
## 📋 수정 권장 순서
|
||||
|
||||
### Phase 1 — 보안/데이터 손상 (최우선)
|
||||
- #1 searchUsers 회사/role 가드
|
||||
- #2 setPrimaryDept 멤버십 검증
|
||||
- #3 parent_dept_code 회사 격리 검증
|
||||
- #9 글로벌 부서 write 권한 강화
|
||||
|
||||
### Phase 2 — 데이터 무결성
|
||||
- #7 update SQL 의 DELETED_AT 누락 수정
|
||||
- #8 update/restore 부서명 중복 검증
|
||||
- #4 회사 변경 시 상세 패널 초기화
|
||||
- #5 handleMove 실패 처리
|
||||
|
||||
### Phase 3 — 사용자 경험
|
||||
- #6 검색 트리 broken 수정
|
||||
- #10 dept_code silent fallback 제거
|
||||
- #11 start_date 강제 저장 제거
|
||||
- #12 ~ #14 race/실수 방지
|
||||
|
||||
### Phase 4 — 기능 완성
|
||||
- #15 변경이력 API 연동
|
||||
- #16 부서원 추가/제거 UI
|
||||
- #17 일괄등록 실패 상세
|
||||
|
||||
### Phase 5 — 정리
|
||||
- #18 ~ #25 race/공백/dead code/검색 정밀화
|
||||
|
||||
---
|
||||
|
||||
## 도메인별 상세 리포트
|
||||
|
||||
- 프론트엔드 React: `2026-05-08-부서관리-버그헌팅-frontend.md`
|
||||
- 백엔드 Java: `2026-05-08-부서관리-버그헌팅-backend.md`
|
||||
- SQL/Mapper: `2026-05-08-부서관리-버그헌팅-sql.md`
|
||||
- UX 시나리오: `2026-05-08-부서관리-버그헌팅-ux.md`
|
||||
@@ -0,0 +1,134 @@
|
||||
# 부서관리 백엔드 버그 헌팅 (2026-05-08)
|
||||
|
||||
대상:
|
||||
- `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` (참고)
|
||||
|
||||
---
|
||||
|
||||
## 1. createDepartment dept_code silent fallback ❌ HIGH
|
||||
|
||||
`DepartmentService.java:85-99` — 사용자가 명시한 `dept_code` 가 정규식 `^[A-Za-z0-9_]+$` 위반 시 예외 없이 자동 코드 생성으로 폴백. 사용자는 201 응답을 받지만 응답의 `dept_code` 가 요청과 다름 (silent override). `IllegalArgumentException("부서 코드 형식이 올바르지 않습니다")` 던지는 게 맞음.
|
||||
|
||||
## 2. createDepartment parent_dept_code 검증 누락 ❌ CRITICAL — cross-tenant
|
||||
|
||||
`DepartmentService.java:107`, `DepartmentController.java:80-82` — parent 가 (a) 존재, (b) 같은 회사, (c) deleted 아님 검증 전혀 없음. SUPER_ADMIN 또는 controller 우회 경로로 cross-tenant 부모 지정 가능. FK 가 같은 DB 내 다른 회사 부서를 참조 가능.
|
||||
|
||||
## 3. updateDepartment 부서명 중복 검증 누락 ❌ HIGH
|
||||
|
||||
`DepartmentService.java:131-170` — create 는 `selectDuplicateDeptName` 체크하지만 update 에는 없음. UNIQUE 제약도 mapper 에 없음. 회사 내 동일 이름의 active 부서 두 개 공존 가능.
|
||||
|
||||
## 4. verifyParentCycle 회사 격리 미검증 ❌ HIGH
|
||||
|
||||
`DepartmentService.java:251-271` — cycle 만 체크. newParent.company_code 와 deptCode.company_code 비교 없음. update 흐름에서 #2 와 동일 결함 재현.
|
||||
|
||||
## 5. selectNextDeptNumber race condition ❌ MEDIUM
|
||||
|
||||
`DepartmentService.java:96-99`, `department.xml:108-111` — `MAX(...)+1` 비원자적. 두 요청 동시 진입 시 같은 `next_number` 읽음 → 두 번째 INSERT PK 위반으로 500. 컨트롤러 catch 절은 `DuplicateDeptNameException`/`IllegalArgumentException` 만 잡으므로 raw `DataIntegrityViolationException` 누출.
|
||||
|
||||
## 6. delete-restore trap (활성 자식만 체크) ⚠️ MEDIUM
|
||||
|
||||
`DepartmentService.java:181-202` (`include_deleted=false` 로 자식 카운트) — 활성 자식 0이면 부모 soft-delete OK. 자식 단독 복구 시도 시 `restoreDepartment` 가 부모 deleted 검사로 차단(`PARENT_DELETED`) → 부모 먼저 복구 필요. 영구 trap 은 아니지만 UX 혼선.
|
||||
|
||||
## 7. restoreDepartment 부서명 충돌 미검증 ❌ HIGH
|
||||
|
||||
`DepartmentService.java:210-239` — 시나리오: A 부서 삭제 → 같은 이름 B 부서 생성 (create 는 active 만 검사하므로 통과) → A 부서 복구 → 동일 이름 active 두 부서 공존. 100% 재현 가능.
|
||||
|
||||
## 8. restoreDepartment 부모 1단계 검증 ✅
|
||||
|
||||
`DepartmentService.java:220-228` — 시스템 invariant 상 부모 active 면 조부모도 active 보장됨 → 1단계로 충분. 외부 직접 SQL 조작 시나리오는 막지 못하지만 OK.
|
||||
|
||||
## 9. addDeptMember dead code ❌ LOW
|
||||
|
||||
`DepartmentController.java:271-272`:
|
||||
```java
|
||||
Object userIdObj = body.get("user_id");
|
||||
if (userIdObj == null) userIdObj = body.get("user_id"); // 동일 키
|
||||
```
|
||||
camelCase fallback 의도였던 것이 작성 실수로 같은 snake_case 가 됨. → `body.get("userId")` 로 수정.
|
||||
|
||||
## 10. setPrimaryDept 멤버십 미검증 → DATA CORRUPTION ❌ CRITICAL
|
||||
|
||||
`DepartmentService.java:356-370` — 시나리오:
|
||||
1. 사용자가 deptA 소속 (IS_PRIMARY=TRUE)
|
||||
2. 클라이언트가 사용자가 소속되지 않은 deptB 로 PUT 호출
|
||||
3. `clearUserPrimaryDept` → 사용자의 모든 USER_DEPT 행 IS_PRIMARY=FALSE (deptA primary 제거)
|
||||
4. `setUserPrimaryDept` (WHERE USER_ID=? AND DEPT_CODE=deptB) → 0 rows affected
|
||||
5. 결과: 어떤 부서도 primary 가 아닌 상태로 남음 (invariant 깨짐)
|
||||
|
||||
Service 진입 시 `selectExistingMember` 선검증 + 0 row affected 시 예외/롤백 필수.
|
||||
|
||||
## 11. canAccessDept — 글로벌 부서(`*`) read 허용 ⚠️ MEDIUM
|
||||
|
||||
`DepartmentController.java:361-367` — `userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode)`. 일반 USER 도 글로벌 부서 GET 가능 (의도된 듯). 단 read/write 분리 필요.
|
||||
|
||||
## 12. deleteDepartment — COMPANY_ADMIN 의 글로벌 부서 삭제 ❌ HIGH
|
||||
|
||||
`DepartmentController.java:135-165` — 임의 테넌트 COMPANY_ADMIN 이 글로벌 부서 (`'*'`) 를 DELETE 호출 → `isAdmin(role)` 통과 + `canAccessDept` 통과 (글로벌 매칭) → 삭제 진행. 글로벌 자원 write 는 SUPER_ADMIN 전용 가드 필요.
|
||||
|
||||
## 13. trimString 일관성 ❌ MEDIUM
|
||||
|
||||
`DepartmentService.java:62, 85, 133` — `trimString` 은 dept_name, requestedCode 에만 적용. parent_dept_code, dept_type, short_name, address1/2, zipcode 등은 `nullIfBlank` 만 거치는데 이건 빈 문자열만 null 로 바꿈 — 선행/후행 공백 보존됨. `parent_dept_code=" DEPT_3 "` 입력 시 DB 에 공백 포함 코드 저장. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정.
|
||||
|
||||
## 14. removeDeptMember 트랜잭션 race ⚠️ LOW
|
||||
|
||||
`DepartmentService.java:324-354` — 메서드 전체 `@Transactional` 이라 데이터 일관성 OK. 단 `selectFirstUserDept` 후 다른 트랜잭션이 그 row 도 삭제하는 race 시 `setUserPrimaryDept` 0 row affected, 예외 없음 → primary 부재 commit. 권장: row count 0 이면 재조회 또는 단일 SQL `UPDATE ... WHERE USER_ID=? AND DEPT_CODE=(SELECT ...)` 합치기.
|
||||
|
||||
## 15. SUPER_ADMIN 식별 — `company_code = '*'` 의미 충돌 ⚠️ MEDIUM
|
||||
|
||||
`DepartmentController.java:353-355` — `isSuperAdmin(companyCodeOrRole)` 가 `*` 또는 `SUPER_ADMIN` 둘 다 true. 일반 user 의 `company_code` 가 우연히 `*` 로 저장되면 super 권한 부여. provisioning 레이어에서 `company_code='*'` 차단 필수. 권장: role 만으로 super 판별, company_code 와 분리.
|
||||
|
||||
---
|
||||
|
||||
## 추가 발견
|
||||
|
||||
### A. createDepartment 중복명 race ❌ HIGH
|
||||
`selectDuplicateDeptName` → INSERT 사이 동시 요청 race. DB UNIQUE 제약 없으면 중복 이름 공존 가능. (`department.xml` 에서 unique 제약 없음.)
|
||||
|
||||
### B. searchUsers SQL LIKE wildcard ⚠️ MEDIUM
|
||||
`DepartmentService.java:283-288` — `params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드. `_` 단독 검색 시 모든 사용자 매칭. 데이터 누출은 아니나 enumeration 가능.
|
||||
|
||||
### C. searchUsers 컨트롤러 권한 검증 누락 ❌ CRITICAL — TENANT BREACH
|
||||
`DepartmentController.java:234-245` — `userCompanyCode` 가드 **없음**. 임의 사용자가 다른 회사 코드를 path 에 넣으면 그 회사 사용자 목록 검색 가능. 다른 모든 메서드엔 가드 있는데 여기만 누락.
|
||||
|
||||
### D. searchUsers role 검증 누락 ❌ MEDIUM
|
||||
admin 검사 없음. 일반 USER 가 회사 내 모든 사용자 (이름/아이디) 자유 검색 → 사용자 enumeration.
|
||||
|
||||
### E. getDeptMembers — soft-deleted 부서 멤버 노출 ⚠️ LOW/MEDIUM
|
||||
`getDepartmentIncludingDeleted` 로 검증만 하고 deleted 부서 members 그대로 반환. 의도된 사양인지 확인 필요.
|
||||
|
||||
### F. ClassCastException 위험 ⚠️ LOW
|
||||
`DepartmentService.java:81` — `(String) company.get("company_name")`. 컬럼 타입 변경 시 CCE. `Objects.toString(...)` 권장.
|
||||
|
||||
### G. setPrimaryDept 비원자성 ⚠️ LOW
|
||||
`DepartmentService.java:356-370` — clear + set 두 SQL. 단일 UPDATE 합치기 가능: `UPDATE USER_DEPT SET IS_PRIMARY = (DEPT_CODE = #{dept_code}) WHERE USER_ID = #{user_id}`.
|
||||
|
||||
### H. nextNumber unboxing NPE ⚠️ LOW
|
||||
`DepartmentService.java:97` — `((Number) codeResult.get("next_number")).longValue()`. 항상 non-null 보장이지만 NPE 가드 권장.
|
||||
|
||||
### I. 로그 PII 평문 ⚠️ LOW
|
||||
`DepartmentService.java:124, 200, 237, 321, 348, 352, 369` — userId 평문 로깅. PIPA/GDPR 환경 고려.
|
||||
|
||||
### J. selectNextDeptNumber 회사 격리 ✅
|
||||
DB-per-tenant 아키텍처상 OK. 글로벌 `*` 회사도 같은 시퀀스 공유.
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 정리
|
||||
|
||||
| 등급 | 항목 |
|
||||
|---|---|
|
||||
| **CRITICAL** | #2 parent cross-tenant 무검증 / #10 setPrimaryDept data corruption / **C** searchUsers tenant breach |
|
||||
| **HIGH** | #1 dept_code silent fallback / #3 update 중복명 / #4 verifyParentCycle 회사 / #7 restore 중복명 / #12 글로벌 삭제 / **A** create race / **D** searchUsers role |
|
||||
| **MEDIUM** | #5 nextNumber race / #6 delete-restore trap / #11 글로벌 write 정책 / #13 trim 일관성 / #15 `*` 의미 / **B** LIKE wildcard / **E** deleted member 노출 |
|
||||
| **LOW** | #9 dead code / #14 removeMember race / **F-I** |
|
||||
|
||||
## 즉시 수정 권장
|
||||
|
||||
1. `searchUsers` 컨트롤러 권한/회사 가드 추가
|
||||
2. `setPrimaryDept` 선검증 + 0 rows 시 예외
|
||||
3. `create/updateDepartment` parent 회사 격리·존재·미삭제 검증
|
||||
4. `update/restoreDepartment` 에 `selectDuplicateDeptName` 추가
|
||||
5. 글로벌 부서 (`*`) write 작업 SUPER_ADMIN 전용 가드
|
||||
6. `dept_code` 형식 위반 시 silent fallback 대신 400
|
||||
@@ -0,0 +1,328 @@
|
||||
# 부서관리 페이지 프론트엔드 버그 헌팅 리포트
|
||||
|
||||
**대상**: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (1504줄)
|
||||
**분석일**: 2026-05-08
|
||||
**분석자**: Debugger (oh-my-claudecode)
|
||||
|
||||
---
|
||||
|
||||
## 가설별 판정
|
||||
|
||||
---
|
||||
|
||||
### 1. State 동기화 / race condition — ⚠️ 잠재 위험
|
||||
|
||||
**판정**: ⚠️ 잠재 위험 (rapid 클릭 시 stale 덮어쓰기)
|
||||
|
||||
`loadDepartments` (line 199)는 `useCallback`으로 메모이즈되어 있지만, 연속 호출 시 race condition 방지 로직이 없다. 예를 들어 사용자가 회사 셀렉트를 빠르게 두 번 변경하면:
|
||||
|
||||
- 호출 A (회사1) → 호출 B (회사2) 순으로 시작
|
||||
- B가 먼저 응답 → `setDepartments(회사2 데이터)`
|
||||
- A가 나중에 응답 → `setDepartments(회사1 데이터)` 로 덮어씀
|
||||
|
||||
```
|
||||
page.tsx:199-212 — loadDepartments: AbortController / 취소 플래그 없음
|
||||
page.tsx:214-216 — useEffect([loadDepartments]) 가 즉시 재실행됨
|
||||
```
|
||||
|
||||
현실적으로 API 응답 속도가 유사하면 발생 가능성은 낮지만, 느린 네트워크에서 재현 가능.
|
||||
|
||||
---
|
||||
|
||||
### 2. Dirty tracking — ⚠️ 잠재 위험
|
||||
|
||||
**판정**: ⚠️ 잠재 위험 (false dirty 발생 조건 존재)
|
||||
|
||||
`isDirty` (line 553-555)는 `JSON.stringify` 순서 의존성 문제보다, **타입 변환** 문제가 더 현실적이다.
|
||||
|
||||
```
|
||||
page.tsx:553-555
|
||||
const isDirty = originalDraft
|
||||
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
|
||||
: isNewMode && ...
|
||||
```
|
||||
|
||||
**구체적 시나리오**: `sort_order` 는 `number` 타입인데, `BasicInfoForm`의 `<Input type="number">` 에서 `Number(e.target.value) || 0` 로 변환한다 (line 1398). 그런데 백엔드가 `sort_order: "10"` (string)으로 내려주면 `originalDraft.sort_order`가 string, `draft.sort_order`가 number가 되어 stringify 비교 시 `"10" !== 10` → **값이 동일해도 isDirty=true**.
|
||||
|
||||
또한 `JSON.stringify`는 객체 키 삽입 순서에 따라 직렬화하므로, 두 객체의 키 순서가 다르면 내용이 같아도 불일치가 발생할 수 있다. `emptyDraft` 스프레드 후 override하는 패턴(line 267-288)에서 키 순서는 보통 일정하지만, 런타임에서 `(dept as any)` 캐스팅을 통해 추가 키가 혼입되면 오탐 가능.
|
||||
|
||||
---
|
||||
|
||||
### 3. Draft 데이터 손실 — ✅ 진짜 버그
|
||||
|
||||
**판정**: ✅ 진짜 버그 (경고 없이 draft 폐기됨)
|
||||
|
||||
다음 세 가지 시나리오 모두에서 `isDirty` 체크 없이 즉시 draft를 덮어쓴다.
|
||||
|
||||
**시나리오 A — 트리 노드 클릭**
|
||||
`handleSelectDepartment` (line 263)는 `isDirty` 체크 없이 곧바로 `setDraft(loaded)` / `setOriginalDraft(loaded)` 실행. 사용자가 신규 부서명을 절반쯤 입력하다 트리의 다른 노드를 클릭하면 입력 내용이 경고 없이 사라진다.
|
||||
|
||||
**시나리오 B — 회사 셀렉트 변경**
|
||||
`selectedCompanyCode` 변경 시 `loadDepartments`만 재호출되고 (line 214-216), `setDraft`/`setOriginalDraft`/`setSelectedCode`/`setIsNewMode` 초기화 로직이 없다 (가설 9와 연동). 작성 중인 신규 draft가 다른 회사로 전환 후에도 우측 패널에 그대로 남아있다가 저장하면 **잘못된 회사에 저장** 되는 위험까지 존재.
|
||||
|
||||
**시나리오 C — 검색 입력**
|
||||
검색은 `filteredDepts` 필터링만 하므로 draft 자체는 건드리지 않는다. 이 시나리오는 오탐.
|
||||
|
||||
재현:
|
||||
1. "+ 추가" 클릭 → 부서명 입력 시작
|
||||
2. 트리에서 다른 부서 클릭 OR 회사 셀렉트 변경
|
||||
3. 입력 내용 소실, 토스트/확인창 없음
|
||||
|
||||
---
|
||||
|
||||
### 4. emptyDraft start_date 기본값 — ✅ 진짜 버그
|
||||
|
||||
**판정**: ✅ 진짜 버그 (사용자 의도 없는 today 저장)
|
||||
|
||||
```
|
||||
page.tsx:113
|
||||
start_date: new Date().toISOString().slice(0, 10),
|
||||
```
|
||||
|
||||
`emptyDraft()`는 매 호출 시 그 시점의 날짜를 `start_date`에 박는다. UI에서 시작일 행은 `{false && ...}` 로 숨겨져 있어 (line 1372) 사용자가 이 값을 인지하거나 수정할 수 없다. 결과:
|
||||
|
||||
- 신규 부서 생성 시 → `start_date = 오늘` 로 항상 DB에 저장됨
|
||||
- 일괄등록 시 → `emptyDraft(selectedCompanyCode)` 스프레드로 동일 문제 (line 968)
|
||||
- 사용자가 명시적으로 시작일을 지정한 적이 없음에도 not-null 값이 저장됨
|
||||
|
||||
기대 동작: `start_date: ""` 또는 `null` 로 초기화, UI가 숨겨져 있는 동안에는 서버에 null 전송.
|
||||
|
||||
---
|
||||
|
||||
### 5. handleMove — ✅ 진짜 버그 (부분 업데이트)
|
||||
|
||||
**판정**: ✅ 진짜 버그
|
||||
|
||||
```
|
||||
page.tsx:403-411
|
||||
const results = await Promise.all(
|
||||
reordered.map((s, i) =>
|
||||
departmentAPI.updateDepartment(s.dept_code, toUpdatePayload(s, { sort_order: (i+1)*10 }))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
`Promise.all`로 형제 부서 전체를 동시 PUT 호출한다. 일부 요청 실패 시:
|
||||
|
||||
- `results.find((r) => !r.success)` 로 첫 번째 실패를 감지하고 toast를 띄우지만 (line 412-413)
|
||||
- 이미 성공한 요청의 `sort_order` 변경은 DB에 반영됨
|
||||
- `loadDepartments()` 재호출도 catch 블록에서는 실행되지 않음 (line 414)
|
||||
|
||||
결과: 형제 5개 중 3번째가 실패하면 1, 2번 sort_order는 변경, 3, 4, 5번은 미변경인 불일치 상태가 DB에 영구 잔존. 게다가 실패 후 화면은 재로드 없이 이전 상태를 유지하므로 **UI와 DB가 불일치**.
|
||||
|
||||
---
|
||||
|
||||
### 6. 검색 + 트리 broken tree — ✅ 진짜 버그
|
||||
|
||||
**판정**: ✅ 진짜 버그 (자식만 매칭 시 트리에서 사라짐)
|
||||
|
||||
```
|
||||
page.tsx:231-239 — filteredDepts: 이름/코드 단순 includes 필터
|
||||
page.tsx:241-246 — childrenOf: filteredDepts 기준으로 자식 조회
|
||||
page.tsx:1064 — DeptTree 내부: sub = allDepts.filter(...) ← allDepts는 filteredDepts
|
||||
```
|
||||
|
||||
**시나리오**: 부서 구조가 `경영지원본부 > 인사팀 > 채용파트` 일 때 "채용"으로 검색하면:
|
||||
|
||||
- `filteredDepts` = [`채용파트`] (부모 2개는 미포함)
|
||||
- `childrenOf(null)` = `filteredDepts`에서 `parent_dept_code === null` 인 것 → 없음
|
||||
- 트리에 아무것도 표시 안 됨
|
||||
|
||||
반대 시나리오: "경영"으로 검색하면 `filteredDepts` = [`경영지원본부`], `childrenOf(null)` = [`경영지원본부`] → 렌더됨. `DeptTree` 내부의 `sub = allDepts.filter(d => d.parent_dept_code === dept.dept_code)` 에서 `allDepts`는 `filteredDepts`이므로 `인사팀`, `채용파트`는 없음. 펼쳐도 자식 없음으로 표시.
|
||||
|
||||
검색 키워드가 있을 때는 트리 구조가 무너져 매칭 결과를 찾을 수 없는 케이스가 다수 발생.
|
||||
|
||||
---
|
||||
|
||||
### 7. handleSave new mode — ⚠️ 잠재 위험
|
||||
|
||||
**판정**: ⚠️ 잠재 위험
|
||||
|
||||
```
|
||||
page.tsx:477
|
||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, payload);
|
||||
```
|
||||
|
||||
`selectedCompanyCode`는 클로저로 캡처된 현재 state값이다. 사용자가:
|
||||
|
||||
1. 회사 A 선택 → "+ 추가" 클릭 → 신규 입력 시작
|
||||
2. 회사 셀렉트를 회사 B로 변경 (draft는 그대로 우측에 남아있음 — 가설 9)
|
||||
3. "저장" 클릭
|
||||
|
||||
→ `draft.company_code`는 A이지만, `createDepartment(selectedCompanyCode=B, ...)` 로 **회사 B에 부서가 생성됨**. `payload`에 `company_code` 필드는 없으므로 서버가 URL path의 company_code를 사용하면 B 소속이 됨.
|
||||
|
||||
`draft.company_code`와 `selectedCompanyCode`가 분리된 것 자체가 근본 원인.
|
||||
|
||||
---
|
||||
|
||||
### 8. Bulk register — ✅ 진짜 버그 + ⚠️ 잠재 위험
|
||||
|
||||
**판정**: ✅ start_date 강제 삽입 (진짜 버그) + ⚠️ 직렬 성능 (잠재 위험)
|
||||
|
||||
**start_date 강제 삽입** (진짜 버그):
|
||||
```
|
||||
page.tsx:968
|
||||
...emptyDraft(selectedCompanyCode), ← start_date=today 강제 포함
|
||||
dept_code,
|
||||
dept_name,
|
||||
```
|
||||
가설 4와 동일. 일괄등록된 모든 부서에 `start_date=오늘` 이 박힌다.
|
||||
|
||||
**직렬 호출 성능** (잠재 위험):
|
||||
```
|
||||
page.tsx:959-979
|
||||
for (const line of lines) {
|
||||
await departmentAPI.createDepartment(...) // 직렬
|
||||
}
|
||||
```
|
||||
100건 입력 시 API 평균 300ms 가정 → 30초 소요. `setBulkUploading(true)` 후 대기하지만 타임아웃 처리가 없어 네트워크 불량 환경에서 UI가 장시간 블로킹됨. `Promise.allSettled` 병렬화로 해결 가능하나 현재는 누락.
|
||||
|
||||
---
|
||||
|
||||
### 9. 회사 변경 시 selected/draft 초기화 누락 — ✅ 진짜 버그
|
||||
|
||||
**판정**: ✅ 진짜 버그
|
||||
|
||||
```
|
||||
page.tsx:214-216
|
||||
useEffect(() => {
|
||||
loadDepartments(); // 트리는 재로드
|
||||
}, [loadDepartments]);
|
||||
// selectedCode, draft, isNewMode 초기화 없음
|
||||
```
|
||||
|
||||
`selectedCompanyCode`가 바뀌면 `loadDepartments`가 새 회사 부서 목록을 로드하지만, 우측 패널의 `selectedCode`, `draft`, `isNewMode`, `originalDraft`는 이전 회사 값 그대로 남는다.
|
||||
|
||||
재현:
|
||||
1. 회사 A에서 `DEPT001` 선택 → 우측에 상세 표시
|
||||
2. 회사 셀렉트를 회사 B로 변경
|
||||
3. 우측 패널에 여전히 회사 A의 `DEPT001` 정보 표시
|
||||
4. "저장" 클릭 시 회사 B context에서 `DEPT001` PUT 요청 발생
|
||||
|
||||
트리가 회사 B 부서를 보여주는 동안 상세 패널은 회사 A 데이터를 편집하는 모순 상태.
|
||||
|
||||
---
|
||||
|
||||
### 10. dept_code 입력 검증 부재 — ⚠️ 잠재 위험
|
||||
|
||||
**판정**: ⚠️ 잠재 위험 (UX 혼란, 서버 silent override)
|
||||
|
||||
```
|
||||
page.tsx:1257-1263
|
||||
<Input
|
||||
value={draft.dept_code}
|
||||
onChange={(e) => update("dept_code", e.target.value)}
|
||||
placeholder="저장 시 자동 부여 (DEPT_n)"
|
||||
readOnly={!!draft.dept_code} ← 신규 시 draft.dept_code=""이므로 편집 가능
|
||||
/>
|
||||
```
|
||||
|
||||
신규 입력 시 `draft.dept_code`가 빈 문자열이므로 `readOnly={false}` 상태로 사용자가 임의 입력 가능. 그러나 프론트에는 `^[A-Za-z0-9_]+$` 패턴 검증이 없다. 백엔드가 패턴 불일치 시 자동 코드로 폴백한다면 사용자가 입력한 코드와 실제 저장된 코드가 달라지는 silent override 발생.
|
||||
|
||||
부수 문제: 신규 모드에서 사용자가 `dept_code`에 값을 직접 입력하면 `readOnly={!!draft.dept_code}`에 의해 즉시 readonly가 되어 수정이 불가능해진다 (onBlur 없이 onChange로 바로 잠김).
|
||||
|
||||
---
|
||||
|
||||
### 11. members 탭 effect — ✅ 진짜 버그
|
||||
|
||||
**판정**: ✅ 진짜 버그 (stale data 가능)
|
||||
|
||||
```
|
||||
page.tsx:219-228
|
||||
useEffect(() => {
|
||||
if (activeTab !== "members" || !selectedCode || isNewMode) {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||
})();
|
||||
}, [activeTab, selectedCode, isNewMode]);
|
||||
```
|
||||
|
||||
AbortController가 없다. 재현 시나리오:
|
||||
|
||||
1. 부서 A 선택 → "부서원 정보" 탭 클릭 → API 호출 시작 (느린 네트워크)
|
||||
2. 트리에서 부서 B 클릭 → selectedCode 변경 → effect 재실행 → B 멤버 API 호출
|
||||
3. B 응답 먼저 도착 → `setMembers(B 멤버)`
|
||||
4. A 응답 나중 도착 → `setMembers(A 멤버)` 로 덮어씀
|
||||
5. 화면에는 부서 B가 선택된 상태이지만 A의 멤버 목록이 표시됨
|
||||
|
||||
cleanup 함수에서 `let cancelled = true` 플래그 또는 `AbortController` 로 이전 fetch를 무효화해야 한다.
|
||||
|
||||
---
|
||||
|
||||
### 12. originalDraft 동일 ref 공유 — ❌ 오탐
|
||||
|
||||
**판정**: ❌ 오탐 (현재 코드에서 실제 문제 없음)
|
||||
|
||||
```
|
||||
page.tsx:287-288
|
||||
setDraft(loaded);
|
||||
setOriginalDraft(loaded);
|
||||
```
|
||||
|
||||
`loaded`는 `handleSelectDepartment` 내에서 `{...emptyDraft(...), ...}` 객체 리터럴로 생성된 새 객체이므로 `setDraft`와 `setOriginalDraft`가 같은 ref를 공유해도, React의 `setDraft((prev) => ({...prev, [key]: value}))` 패턴 (line 1244)이 spread로 새 객체를 만들기 때문에 `originalDraft`를 mutate하지 않는다. 현재 코드 기준으로 실제 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## 추가 발견 버그
|
||||
|
||||
### B1. DeptTree — sub 필터가 sort 없음 ⚠️
|
||||
|
||||
```
|
||||
page.tsx:1064
|
||||
const sub = allDepts.filter((d) => d.parent_dept_code === dept.dept_code);
|
||||
```
|
||||
|
||||
`DeptTree` 컴포넌트 내부의 `sub` 계산에는 `sort_order` 정렬이 없다. 루트 레벨은 `childrenOf(null)` (line 245)에서 sort가 적용되지만, 2단계 이하 자식들은 `allDepts.filter`로 순서 보장 없이 렌더된다. `handleMove`로 sort_order를 변경해도 2단계 이하는 화면에 반영되지 않음.
|
||||
|
||||
### B2. handleMove — 삭제된 부서가 siblings에 포함될 수 있음 ⚠️
|
||||
|
||||
```
|
||||
page.tsx:381-382
|
||||
.filter((d) => ... && !(d as any).deleted_at)
|
||||
```
|
||||
|
||||
`loadDepartments`에서 `showDeleted=true`일 때 삭제된 부서도 `departments`에 포함된다. `handleMove`는 `!(d as any).deleted_at`로 필터하므로 siblings에서 제외하는 의도는 맞다. 그러나 `sort_order` normalize 결과가 삭제된 부서의 기존 sort_order와 충돌할 수 있다. 낮은 심각도.
|
||||
|
||||
### B3. bulkOpen 취소 시 진행 중 요청 취소 불가 ✅
|
||||
|
||||
```
|
||||
page.tsx:986
|
||||
setBulkOpen(false);
|
||||
```
|
||||
|
||||
`bulkUploading` 중에 다이얼로그 외부 클릭(onOpenChange)으로 닫으면 `setBulkOpen(false)`로 UI는 닫히지만 `for...of` 루프는 계속 실행된다. 완료 후 `loadDepartments()`가 호출되는데 이 시점에 이미 사용자가 다른 작업을 하고 있으면 예상치 못한 트리 재로드가 발생.
|
||||
|
||||
```
|
||||
page.tsx:929 — <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
```
|
||||
`setBulkUploading` 중에는 다이얼로그를 닫지 못하도록 `onOpenChange={(o) => !bulkUploading && setBulkOpen(o)}` 가 필요.
|
||||
|
||||
---
|
||||
|
||||
## 요약 테이블
|
||||
|
||||
| # | 가설 | 판정 | 심각도 | 라인 |
|
||||
|---|------|------|--------|------|
|
||||
| 1 | Race condition (loadDepartments) | ⚠️ 잠재 위험 | 낮음 | 199-216 |
|
||||
| 2 | isDirty false dirty (타입 불일치) | ⚠️ 잠재 위험 | 낮음 | 553-555 |
|
||||
| 3 | Draft 손실 (트리 클릭 / 회사 변경) | ✅ 진짜 버그 | **높음** | 263-288 |
|
||||
| 4 | start_date=today 강제 저장 | ✅ 진짜 버그 | 중간 | 113 |
|
||||
| 5 | handleMove 부분 업데이트 | ✅ 진짜 버그 | **높음** | 403-417 |
|
||||
| 6 | 검색 broken tree | ✅ 진짜 버그 | **높음** | 231-246 |
|
||||
| 7 | handleSave new mode 회사 불일치 | ⚠️ 잠재 위험 | 중간 | 477 |
|
||||
| 8 | Bulk start_date 강제 / 직렬 성능 | ✅+⚠️ | 중간/낮음 | 959-979 |
|
||||
| 9 | 회사 변경 시 상태 초기화 누락 | ✅ 진짜 버그 | **높음** | 214-216 |
|
||||
| 10 | dept_code 검증 부재 / 즉시 readonly | ⚠️ 잠재 위험 | 낮음 | 1257-1263 |
|
||||
| 11 | members 탭 AbortController 누락 | ✅ 진짜 버그 | 중간 | 219-228 |
|
||||
| 12 | originalDraft 동일 ref | ❌ 오탐 | 없음 | 287-288 |
|
||||
| B1 | DeptTree sub 정렬 없음 | ⚠️ 잠재 위험 | 낮음 | 1064 |
|
||||
| B2 | handleMove 삭제 부서 충돌 | ⚠️ 잠재 위험 | 낮음 | 381 |
|
||||
| B3 | bulkUploading 중 다이얼로그 강제 닫힘 | ✅ 진짜 버그 | 낮음 | 929 |
|
||||
|
||||
**진짜 버그**: 8건 (3, 4, 5, 6, 8-start_date, 9, 11, B3)
|
||||
**잠재 위험**: 6건 (1, 2, 7, 8-성능, 10, B1, B2)
|
||||
**오탐**: 1건 (12)
|
||||
@@ -0,0 +1,95 @@
|
||||
# 부서관리 SQL/매퍼 버그 헌팅 (2026-05-08)
|
||||
|
||||
대상: `backend-spring/src/main/resources/mapper/department.xml`
|
||||
|
||||
---
|
||||
|
||||
## ❌ 1. updateDepartment WHERE 절에 `DELETED_AT IS NULL` 누락 — HIGH
|
||||
|
||||
`department.xml:160-179`
|
||||
```xml
|
||||
<update id="updateDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO SET ... WHERE DEPT_CODE = #{dept_code}
|
||||
</update>
|
||||
```
|
||||
|
||||
소프트 삭제된 부서도 update 가 통과해버림. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증한 뒤 호출하므로 deleted 부서에 대해서도 흐름이 진행됨. 그 후 service 의 재조회 (`selectDepartmentByCode` — `DELETED_AT IS NULL`) 가 null 반환 → controller 가 404 응답. **DB 는 이미 update 되었는데 사용자는 404 받음 → silent corruption**.
|
||||
|
||||
→ `WHERE DEPT_CODE = #{dept_code} AND DELETED_AT IS NULL` 추가 필수. update 0 row 면 service 가 null 반환하여 404 도 일관됨.
|
||||
|
||||
## ⚠️ 2. selectDepartments — 글로벌 부서 모든 회사에 노출 — MEDIUM
|
||||
|
||||
`department.xml:29` `(D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')`
|
||||
|
||||
글로벌 (`*`) 부서가 모든 회사 트리에 자식으로 섞여 표시됨. PARENT_DEPT_CODE 일치까지 고려하면 어느 부모 아래로 들어갈지 비결정적. 의도된 사양인지 확인 필요. INVYONE 은 DB-per-tenant 인데 글로벌 부서가 동일 DB 에 들어가는 경우 — 멀티 테넌시 모델과 충돌.
|
||||
|
||||
## ✅ 3. selectChildDeptCount — 회사 격리 — OK (DB-per-tenant)
|
||||
|
||||
`department.xml:182-189` — `WHERE PARENT_DEPT_CODE = #{dept_code}` 만 있어 회사 필터 없음. 단 INVYONE 은 DB-per-tenant 라 같은 DB 안에 다른 회사 부서가 거의 없음 (글로벌 `*` 제외). 실제 위험은 낮음.
|
||||
|
||||
## ✅ 4. selectNextDeptNumber 회사 필터 없음 — OK (DB-per-tenant)
|
||||
|
||||
`department.xml:108-112` — 전역 시퀀스. DB-per-tenant 라 회사별 충돌은 발생 안 함. 단 글로벌 (`*`) 부서까지 같은 시퀀스 공유는 의도된 듯.
|
||||
|
||||
## ⚠️ 5. `#{date}::date` cast — MEDIUM
|
||||
|
||||
`department.xml:150-151, 173-174` — null 또는 valid date 형식이면 OK. 잘못된 형식 ("2026/05/08", "abc") 이면 SQLException → 500. service 가 `nullIfBlank` 만 처리하지 형식 검증 안 함. controller 도 안 함. 사용자가 비정상 날짜 입력 시 5xx 누출.
|
||||
|
||||
## ✅ 6. selectDuplicateDeptName 정규화 — OK
|
||||
|
||||
`department.xml:91-97` — `TRIM(LOWER(...))` 양쪽 적용. 케이스/공백 정규화 OK. 단 update 흐름에서 호출 안 됨 (backend 리포트 #3 참조).
|
||||
|
||||
## ⚠️ 7. selectDeptMembers INNER JOIN — LOW/MEDIUM
|
||||
|
||||
`department.xml:213-230` — `JOIN USER_INFO U ON UD.USER_ID = U.USER_ID` (INNER). 사용자가 USER_INFO 에서 삭제되었지만 USER_DEPT row 가 남으면 orphan 멤버 안 보임. member_count (selectDepartments) 는 USER_DEPT 만 카운트하므로 카운트와 실제 표시되는 멤버 수가 불일치 가능.
|
||||
|
||||
## ❌ 8. searchUsers ILIKE 와일드카드 미이스케이프 — MEDIUM
|
||||
|
||||
`department.xml:233-248`, service 의 `params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드로 처리됨. `_` 단독 검색 시 모든 사용자 매칭. SQL injection 은 parameterized 라 안전하지만 enumeration 가능. ESCAPE 절 또는 사전 escaping 권장.
|
||||
|
||||
## ⚠️ 9. deleteUserDeptByDeptCode dead query — LOW
|
||||
|
||||
`department.xml:192-195` — 주석에 "Slice 2.1 이후 사용 안 함" 명시. 안 쓰는 쿼리 잔존. 정리 권장.
|
||||
|
||||
## ✅ 10. selectCompanyName LIMIT 1 — OK (PK)
|
||||
|
||||
`department.xml:100-105` — `LIMIT 1` 만 있고 ORDER BY 없음. company_code 가 PK 면 단일 행만 매칭되므로 OK. PK 가 아니면 비결정적.
|
||||
|
||||
## ⚠️ 11. insertDeptMember UNIQUE 제약 의존 — MEDIUM
|
||||
|
||||
`department.xml:283-286` — INSERT 만. service 가 `selectExistingMember` 로 사전 체크하지만 race 시 두 트랜잭션이 동시 통과 → 두 INSERT. USER_DEPT 에 (USER_ID, DEPT_CODE) UNIQUE 제약이 있어야 race 방어. **mapper 외부 스키마 확인 필요** — 없으면 중복 row 가능.
|
||||
|
||||
## ⚠️ 12. selectFirstUserDept 비결정적 — LOW
|
||||
|
||||
`department.xml:266-272` — `ORDER BY CREATED_DATE ASC LIMIT 1`. 동시 INSERT 시 동일 NOW() 다중 row 가능 → 비결정적 첫 row. primary 자동 승격 시 어떤 부서가 선택될지 예측 불가. 보조 정렬 키 (예: DEPT_CODE) 추가 권장.
|
||||
|
||||
## ⚠️ 13. selectDepartments member_count 와 includeDeleted — LOW
|
||||
|
||||
`department.xml:7-39` — `LEFT JOIN USER_DEPT UD`. USER_DEPT 는 soft-delete 컬럼 없음. 부서가 deleted 여도 USER_DEPT row 는 보존되므로 (멤버 보존 정책) member_count 는 그대로. 휴지통 표시 시 의미 있는 카운트인지 UI 정책 확인.
|
||||
|
||||
## ⚠️ 14. GROUP BY 에 PARENT_DEPT_CODE 포함되었으나 필요한가 — LOW
|
||||
|
||||
`department.xml:33-37` — GROUP BY 에 모든 SELECT 컬럼 나열 (PostgreSQL 요구). `MEMBER_COUNT` 만 집계라 OK. 단 컬럼 추가 시 GROUP BY 도 같이 늘려야 하는 운영 부담. 향후 컬럼 추가 시 누락 위험.
|
||||
|
||||
## ⚠️ 15. dept_name UNIQUE 제약 없음 — HIGH (스키마 종속)
|
||||
|
||||
mapper 만으로는 확정 불가하지만, `selectDuplicateDeptName` 를 SQL 레벨로 보강하는 UNIQUE 제약 (예: `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL`) 이 없으면 race 시 중복 부서명 공존. backend 리포트 추가 발견 A 와 동일 결함 — DB 스키마 차원의 방어 필요.
|
||||
|
||||
---
|
||||
|
||||
## 우선순위
|
||||
|
||||
| 등급 | 항목 |
|
||||
|---|---|
|
||||
| **HIGH** | #1 update 의 DELETED_AT 누락 (silent corruption) / #15 dept_name UNIQUE 부재 |
|
||||
| **MEDIUM** | #2 글로벌 부서 회사 트리 혼입 / #5 date cast 5xx / #8 LIKE 와일드카드 / #11 USER_DEPT UNIQUE |
|
||||
| **LOW** | #7 INNER JOIN orphan / #9 dead query / #12 first-dept 비결정성 / #13-14 |
|
||||
| **OK** | #3 #4 #6 #10 |
|
||||
|
||||
## 즉시 수정 권장
|
||||
|
||||
1. `updateDepartment` WHERE 절에 `AND DELETED_AT IS NULL` 추가
|
||||
2. USER_DEPT (USER_ID, DEPT_CODE) UNIQUE 제약 확인 / 추가
|
||||
3. DEPT_INFO (COMPANY_CODE, dept_name) partial UNIQUE 인덱스 (DELETED_AT IS NULL) 추가
|
||||
4. searchUsers 입력값에 LIKE escaping 적용
|
||||
5. service 또는 controller 에서 date 형식 검증 → 400 반환
|
||||
@@ -0,0 +1,261 @@
|
||||
# 부서관리 UX Edge Case / 버그 헌팅 (2026-05-08)
|
||||
|
||||
대상 파일:
|
||||
- `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
|
||||
- `frontend/components/departments/DepartmentPicker.tsx`
|
||||
- `frontend/lib/api/department.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. 휴지통 모드 — 삭제된 부서 클릭 / 컨텍스트 메뉴 ✅
|
||||
|
||||
- `page.tsx:1081` onClick 가드 `!isDeleted && handlers.onSelect(dept)` — 클릭 차단 OK.
|
||||
- `page.tsx:1105–1121` 삭제됨 노드는 ⋮ 메뉴 대신 복구 버튼만 렌더. 조건 분기 `isDeleted ? <복구> : <DropdownMenu>` 명확.
|
||||
- isNewMode 중 showDeleted 토글해도 우측 폼은 그대로 유지 — 의도된 동작이므로 문제 없음.
|
||||
|
||||
**추가 시나리오**: 삭제된 부서를 복구 버튼 클릭 → 부모 부서도 deleted 상태면 백엔드가 400 반환 (`restoreDepartment` 주석 참조). `page.tsx:538–549` toast 처리 있음. OK.
|
||||
|
||||
---
|
||||
|
||||
## 2. collectAllDescendants — deleted 자손 포함 여부 ⚠️
|
||||
|
||||
- `page.tsx:329–341` `collectAllDescendants`는 `departments` 배열 전체를 순회.
|
||||
- `departments`는 `showDeleted=true`이면 deleted 자손 포함, `false`면 미포함.
|
||||
- **문제**: `showDeleted=false`(기본)일 때 deleted 자손은 `departments`에 없으므로 `excludeCodes`에 들어가지 않음. picker에서 이미 soft-delete된 자손 코드를 새 부모로 선택 가능 — 백엔드 cycle 가드가 없으면 실제로는 무해하지만, deleted 부서를 새 부모로 지정하는 비정합 데이터 생성 가능성.
|
||||
- `DepartmentPicker.tsx:63` `includeDeleted` prop이 있으나 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(기본 false) — picker 자체는 deleted 부서를 보여주지 않으므로 선택 불가. 결과적으로 deleted 자손 선택 자체는 차단됨.
|
||||
- **실질 위험**: `showDeleted=true`이면서 picker도 `includeDeleted`를 받았을 경우에만 deleted 부서를 새 부모로 지정 가능. 현재 코드에선 picker에 `includeDeleted` 미전달이므로 실질 위험 없음. 단, 향후 includeDeleted 전달 시 cycle 검증 미비로 이어질 수 있음.
|
||||
- 백엔드 cycle 가드: 코드 내 확인 불가 (백엔드 영역).
|
||||
|
||||
---
|
||||
|
||||
## 3. picker "최상위로" 선택 — 빈 문자열 vs null ⚠️
|
||||
|
||||
- `page.tsx:920–922`:
|
||||
```ts
|
||||
onSelect={(code) =>
|
||||
handleConfirmMoveTo(typeof code === "string" && code ? code : null)
|
||||
}
|
||||
```
|
||||
- `DepartmentPicker.tsx:179–184` single 모드에서 `onSelect(d.dept_code)` 호출 — 항상 실제 부서코드 문자열 전달. "최상위로" 선택 버튼이 picker에 **없음**.
|
||||
- **문제**: picker에 "최상위로 이동 (부모 없음)" 선택지가 없어 root 레벨로 이동하는 UX가 불가능. `handleConfirmMoveTo(null)` 경로 자체는 구현되어 있으나 트리거할 방법이 없음.
|
||||
- 빈 문자열 처리 로직은 방어코드로만 존재 — 실제로는 도달 불가.
|
||||
|
||||
---
|
||||
|
||||
## 4. 부서원 탭 — 신규 부서 ✅
|
||||
|
||||
- `page.tsx:839–845` `disabled={isNewMode}` — 탭 버튼 클릭 차단. OK.
|
||||
- `page.tsx:220–228` useEffect에서 `isNewMode`이면 `setMembers([])` 즉시 반환. OK.
|
||||
|
||||
---
|
||||
|
||||
## 5. selectedCompanyCode 변경 시 selectedCode/draft 잔존 ❌
|
||||
|
||||
- `page.tsx:658` 회사 Select `onValueChange={setSelectedCompanyCode}` — `selectedCode`, `draft`, `isNewMode` 초기화 없음.
|
||||
- **문제**: 회사 A의 부서를 선택 → 회사 B로 전환 → 우측 상세에 회사 A 부서 정보가 그대로 표시됨. breadcrumb도 회사 A 부서명. 회사 B 트리를 클릭하기 전까지 stale 데이터 노출.
|
||||
- `loadDepartments`(line 199)는 `selectedCompanyCode` 의존성으로 재호출되어 트리는 갱신되지만 우측 패널은 그대로.
|
||||
- `file:line` — `page.tsx:658` `onValueChange={setSelectedCompanyCode}` 에서 추가로 `handleClearDetail()` 호출 필요.
|
||||
|
||||
---
|
||||
|
||||
## 6. 부서원 fetch race condition ❌
|
||||
|
||||
- `page.tsx:219–228` useEffect:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (activeTab !== "members" || !selectedCode || isNewMode) { setMembers([]); return; }
|
||||
(async () => {
|
||||
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||
})();
|
||||
}, [activeTab, selectedCode, isNewMode]);
|
||||
```
|
||||
- **문제**: cleanup 함수(cancellation flag)가 없음. fetch 도중 다른 부서 클릭 → 이전 fetch가 완료되면 `setMembers`가 구형 데이터로 상태 덮어씀. stale closure 문제.
|
||||
- `DepartmentPicker.tsx:92–107` 에는 `cancelled` flag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음.
|
||||
- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## 7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌
|
||||
|
||||
- `page.tsx:291–297` `handleAddNew`:
|
||||
```ts
|
||||
setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });
|
||||
```
|
||||
`emptyDraft(selectedCompanyCode)` 시점에 `company_code`가 고정됨.
|
||||
- 이후 회사 변경 → `selectedCompanyCode` 갱신되지만 `draft.company_code`는 이전 회사 코드 유지.
|
||||
- `page.tsx:477` `createDepartment(selectedCompanyCode, payload)` — URL 파라미터는 새 `selectedCompanyCode` 사용, payload 내 `draft`의 `company_code`는 구 회사 코드 → 불일치.
|
||||
- 백엔드가 URL 파라미터 `companyCode`를 우선하면 실질 저장은 새 회사로 되지만, payload에 잘못된 `company_code`가 포함되어 백엔드 유효성 검증에서 실패할 수도 있음.
|
||||
- **5번 버그와 동일한 근본 원인**: 회사 변경 시 `handleClearDetail()` 미호출.
|
||||
|
||||
---
|
||||
|
||||
## 8. 동시 두 삭제 다이얼로그 ⚠️
|
||||
|
||||
- `page.tsx:881` `deleteConfirmOpen` 다이얼로그 (메인 패널 "삭제" 버튼).
|
||||
- `page.tsx:898` `contextDeleteDept` 다이얼로그 (트리 ⋮ 메뉴 "삭제").
|
||||
- **문제**: 두 다이얼로그 열기 조건이 독립적. 사용자가 ⋮ → 삭제를 클릭해 `contextDeleteDept` 다이얼로그가 열린 상태에서, 뒤에 있는 메인 패널의 "삭제" 버튼(`page.tsx:800`)도 클릭 가능 여부는 Dialog의 z-index/모달 동작에 따라 다름.
|
||||
- 실제로 shadcn Dialog는 포커스 트랩을 걸므로 동시 두 개 열리기는 어렵지만, 빠른 클릭 시퀀스로 두 state가 모두 true가 될 수는 있음. React 렌더링 사이클 타이밍에 따라 두 Dialog가 동시에 `open={true}`인 상태 가능.
|
||||
- 치명적 버그는 아니지만 UI 혼란 가능.
|
||||
|
||||
---
|
||||
|
||||
## 9. handleDelete race — selectedCode 변경 후 삭제 ❌
|
||||
|
||||
- `page.tsx:503–523` `handleDelete`:
|
||||
```ts
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCode) return;
|
||||
const res = await departmentAPI.deleteDepartment(selectedCode); // (A) selectedCode 캡처
|
||||
...
|
||||
};
|
||||
```
|
||||
- `page.tsx:881–895` 삭제 확인 다이얼로그 `open={deleteConfirmOpen}`.
|
||||
- **문제**: 삭제 다이얼로그가 열린 상태에서 사용자가 배경 트리(Dialog가 포커스 트랩 없는 경우)를 클릭해 `selectedCode`가 변경될 경우, "삭제" 버튼 클릭 시 `handleDelete`는 **최신 `selectedCode`** 를 사용. 즉, 다이얼로그를 열 때 의도한 부서가 아닌 다른 부서가 삭제될 수 있음.
|
||||
- shadcn Dialog는 기본 포커스 트랩 제공으로 일반 사용 시 배경 클릭은 차단되지만, `onOpenChange`로 dismiss도 가능하고, 로직상 `selectedCode`가 mutable 상태인 것 자체가 위험.
|
||||
- `handleDelete` 시 클로저로 코드를 고정하지 않는 구조 — 컨텍스트 삭제의 `handleConfirmDeleteContext`(line 421)는 `contextDeleteDept` state를 사용해 이 문제가 없음. 메인 패널 삭제만 취약.
|
||||
|
||||
---
|
||||
|
||||
## 10. DepartmentPicker — 다른 회사 부서 선택 가능 여부 ✅
|
||||
|
||||
- `page.tsx:916` picker 호출:
|
||||
```ts
|
||||
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
|
||||
```
|
||||
- `DepartmentPicker.tsx:94` `getDepartments(companyCode, ...)` — 해당 회사 부서만 로드. 다른 회사 부서 노출 없음.
|
||||
- `moveTargetDept?.company_code`를 사용하므로 이동 대상 부서의 원래 회사 기준으로 picker가 열림. 올바른 동작.
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경이력 — API 호출 없음 (미구현) ❌
|
||||
|
||||
- `page.tsx:997–1027` 변경이력 Dialog: hardcoded "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 텍스트만 표시.
|
||||
- `page.tsx:995–1002` `historyOpen` state 변경 시 fetch 트리거 없음. 실제 API 호출 코드 없음.
|
||||
- `department.ts` 전체 검색 시 history/changelog 관련 API 함수 없음.
|
||||
- 기능 미구현 상태. 사용자는 변경이력 버튼을 눌러도 항상 "이력 없음" 문구만 봄.
|
||||
|
||||
---
|
||||
|
||||
## 12. expandAll — 검색 필터 상태에서의 부분 적용 ⚠️
|
||||
|
||||
- `page.tsx:249–251`:
|
||||
```ts
|
||||
const expandAll = () => {
|
||||
setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
|
||||
};
|
||||
```
|
||||
- `filteredDepts`는 검색 키워드가 있으면 필터된 부서만 포함.
|
||||
- **문제**: 검색어 "인사" 입력 후 expandAll 클릭 → `expandedSet`에 "인사" 매칭 부서들만 저장 → 검색어 삭제 → 전체 트리 표시되지만 expandedSet은 이전 검색 결과에 해당하는 코드만 expanded — 트리가 부분 펼침 상태로 보임.
|
||||
- 이후 collapseAll/expandAll 재클릭으로 복구 가능. 치명적이지 않으나 UX 혼란.
|
||||
|
||||
---
|
||||
|
||||
## 13. siteOpen 회사 변경 시 유지 ✅
|
||||
|
||||
- `siteOpen` state는 회사 변경에 리셋 로직 없음 → 기존 상태 유지.
|
||||
- 기본값 `true` — 첫 로드, 회사 전환 모두 자동 펼침. 의도된 동작.
|
||||
|
||||
---
|
||||
|
||||
## 14. handleClearDetail — isDirty 경고 없음 ❌
|
||||
|
||||
- `page.tsx:299–304`:
|
||||
```ts
|
||||
const handleClearDetail = () => {
|
||||
setSelectedCode(null);
|
||||
setIsNewMode(false);
|
||||
setDraft(emptyDraft(selectedCompanyCode));
|
||||
setOriginalDraft(null);
|
||||
};
|
||||
```
|
||||
- `page.tsx:553–555` `isDirty` 계산은 존재하지만 `handleClearDetail` 진입 시 확인 없음.
|
||||
- `page.tsx:808–815` X 버튼 `onClick={handleClearDetail}` — 폼에 미저장 내용이 있어도 즉시 초기화.
|
||||
- 또한 `handleConfirmDeleteContext`(line 435) 내부에서도 `handleClearDetail()` 직접 호출 — 삭제 성공 후 폼 클리어는 정상이지만, 만약 다른 부서가 선택된 상태에서 컨텍스트 삭제가 실행되면 해당 부서 폼도 무조건 지워짐 (line 435: `if (selectedCode === d.dept_code) handleClearDetail()` — 조건 있어서 이 케이스는 OK).
|
||||
|
||||
---
|
||||
|
||||
## 15. bulk register — 실패 부서 상세 없음 ⚠️
|
||||
|
||||
- `page.tsx:957–985`:
|
||||
```ts
|
||||
toast({
|
||||
title: `일괄등록 완료`,
|
||||
description: `성공 ${success}건 / 실패 ${failed}건`,
|
||||
});
|
||||
```
|
||||
- 실패 건의 `dept_code`, `dept_name`, 실패 사유(에러 메시지)를 수집하지 않음.
|
||||
- 100개 중 5개 실패 시 어떤 코드가 왜 실패했는지 사용자가 알 수 없음. 재시도 불가.
|
||||
- `createDepartment` 반환의 `error`, `isDuplicate` 필드를 버리고 `failed++`만 카운트.
|
||||
|
||||
---
|
||||
|
||||
## 16. member_count — UI에서 멤버 추가/제거 불가 ⚠️
|
||||
|
||||
- `page.tsx:1469–1502` `MembersPanel` — 멤버 목록 표시만 있고 추가/제거 버튼 없음.
|
||||
- `department.ts:148–176` `addDepartmentMember`, `removeDepartmentMember` API 함수 존재하나 page.tsx에서 호출되지 않음.
|
||||
- `department.ts:132–143` `searchUsers` API도 미사용.
|
||||
- `page.tsx:1472` 멤버 수 표시만 있고 편집 액션 없음 → 기능 미구현.
|
||||
- 트리 노드의 `member_count`(line 1125–1128)는 부서 목록 재로드 시 갱신되지만 UI에서 변경할 방법 없음.
|
||||
|
||||
---
|
||||
|
||||
## 추가 발견 시나리오
|
||||
|
||||
### A. 정렬 이동(handleMove) — deleted 부서 포함 siblings ⚠️
|
||||
|
||||
- `page.tsx:382`:
|
||||
```ts
|
||||
const siblings = departments
|
||||
.filter((d) => (d.parent_dept_code ?? null) === (dept.parent_dept_code ?? null) && !(d as any).deleted_at)
|
||||
```
|
||||
- `deleted_at` 체크로 deleted 제외. OK.
|
||||
- 단, `showDeleted=true`일 때 deleted 부서도 `departments`에 포함되어 있음 — siblings 필터가 올바르게 제외하므로 문제 없음.
|
||||
|
||||
### B. DepartmentPicker single 모드 — deleted 부서 클릭 가능 ⚠️
|
||||
|
||||
- `DepartmentPicker.tsx:179–184` `handleNodeClick`:
|
||||
```ts
|
||||
if (isExcluded(d.dept_code)) return;
|
||||
if (mode === "single") { onSelect(d.dept_code); onClose(); return; }
|
||||
```
|
||||
- `isDeleted` 체크가 없음 — `includeDeleted=true`로 picker를 열면 deleted 부서도 선택 가능.
|
||||
- 현재 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(false)이므로 실질 위험 없으나, 향후 includeDeleted 활성화 시 deleted 부서를 새 부모로 지정 가능.
|
||||
|
||||
### C. handleSave — isNewMode에서 draft.dept_code 중복 ⚠️
|
||||
|
||||
- `page.tsx:1258–1262` 부서코드 Input: `readOnly={!!draft.dept_code}` — 신규 시 빈 문자열이면 편집 가능.
|
||||
- 사용자가 이미 존재하는 `dept_code`를 수동 입력 후 저장 → `createDepartment` 409 응답 → `page.tsx:484–486` toast "생성 실패"만 표시. `isDuplicate` 플래그 존재하지만 별도 메시지 없음.
|
||||
|
||||
### D. ancestors breadcrumb — deleted 조상 표시 ⚠️
|
||||
|
||||
- `page.tsx:164–178` `ancestors` useMemo: `departments` 배열에서 parent 체인 추적.
|
||||
- `showDeleted=false`이면 deleted 조상은 `departments`에 없어 체인이 중간에 끊김 → breadcrumb 불완전.
|
||||
- `showDeleted=true`이면 deleted 조상도 표시 — strikethrough 없이 일반 텍스트로 표시됨 (ancestors 렌더에 deleted 스타일링 없음, `page.tsx:772–777`).
|
||||
|
||||
---
|
||||
|
||||
## 요약 테이블
|
||||
|
||||
| # | 시나리오 | 판정 | 심각도 |
|
||||
|---|---|---|---|
|
||||
| 1 | 휴지통 모드 삭제 부서 클릭/메뉴 | ✅ | - |
|
||||
| 2 | collectAllDescendants deleted 자손 | ⚠️ | 낮음 |
|
||||
| 3 | picker "최상위로" 선택 UX 누락 | ⚠️ | 중간 |
|
||||
| 4 | 부서원 탭 신규 부서 차단 | ✅ | - |
|
||||
| 5 | 회사 변경 시 selectedCode/draft 잔존 | ❌ | 높음 |
|
||||
| 6 | 부서원 fetch race condition | ❌ | 중간 |
|
||||
| 7 | 신규 작성 중 회사 변경 company_code mismatch | ❌ | 높음 |
|
||||
| 8 | 동시 두 삭제 다이얼로그 | ⚠️ | 낮음 |
|
||||
| 9 | handleDelete selectedCode race | ❌ | 중간 |
|
||||
| 10 | picker 다른 회사 부서 선택 차단 | ✅ | - |
|
||||
| 11 | 변경이력 API 미구현 | ❌ | 중간 |
|
||||
| 12 | expandAll 검색 후 부분 펼침 | ⚠️ | 낮음 |
|
||||
| 13 | siteOpen 회사 변경 유지 | ✅ | - |
|
||||
| 14 | handleClearDetail isDirty 경고 없음 | ❌ | 중간 |
|
||||
| 15 | bulk register 실패 상세 없음 | ⚠️ | 중간 |
|
||||
| 16 | 부서원 추가/제거 UI 미구현 | ❌ | 중간 |
|
||||
| A | handleMove deleted siblings 제외 | ✅ | - |
|
||||
| B | picker single deleted 부서 선택 가능성 | ⚠️ | 낮음 |
|
||||
| C | handleSave dept_code 중복 안내 미흡 | ⚠️ | 낮음 |
|
||||
| D | ancestors breadcrumb deleted 조상 스타일 누락 | ⚠️ | 낮음 |
|
||||
@@ -0,0 +1,81 @@
|
||||
# 부서관리 버그 헌팅 통합 요약 (2026-05-08)
|
||||
|
||||
4개 도메인 병렬 분석 결과. 상세 리포트는 별도 파일 참조.
|
||||
|
||||
- [Frontend (page.tsx)](2026-05-08-부서관리-버그헌팅-frontend.md)
|
||||
- [Backend (Controller/Service)](2026-05-08-부서관리-버그헌팅-backend.md)
|
||||
- [SQL/Mapper](2026-05-08-부서관리-버그헌팅-sql.md)
|
||||
- [UX Edge Cases](2026-05-08-부서관리-버그헌팅-ux.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL (즉시 수정)
|
||||
|
||||
| # | 위치 | 한 줄 |
|
||||
|---|---|---|
|
||||
| C1 | `DepartmentService.java:356-370` | **setPrimaryDept 데이터 손상** — 사용자가 소속되지 않은 부서로 호출 시 다른 부서 primary 만 해제되고 새 primary 미설정 → 사용자가 어떤 부서도 primary 가 아닌 invariant 깨진 상태로 commit |
|
||||
| C2 | `DepartmentController.java:234-245` | **searchUsers 회사 격리 누락** — `userCompanyCode` 가드 없음. 임의 사용자가 다른 회사 사용자 목록 검색 가능 → 멀티테넌시 침해 |
|
||||
| C3 | `DepartmentService.java:107` + `:251` | **parent_dept_code cross-tenant** — 존재/회사/삭제 검증 전혀 없음. update 의 verifyParentCycle 도 회사 격리 검증 없음. 다른 회사 부서를 부모로 지정 가능 |
|
||||
|
||||
## 🟠 HIGH
|
||||
|
||||
| # | 위치 | 한 줄 |
|
||||
|---|---|---|
|
||||
| H1 | `page.tsx:658` (회사 Select) | 회사 변경 시 `selectedCode`/`draft`/`isNewMode` 초기화 안 됨 → 다른 회사 부서가 우측 패널에 stale 노출 + 잘못된 회사 코드로 저장 위험 |
|
||||
| H2 | `page.tsx:403` `handleMove` | `Promise.all` 로 N개 PUT — 일부 실패 시 부분 업데이트 영구 잔존 (catch 에서 loadDepartments 미호출). 트랜잭션 없음 |
|
||||
| H3 | `page.tsx:231-246` 검색 필터 | 자식 매칭/부모 미매칭 시 트리 구조가 깨져 부서 안 보임 (broken tree) |
|
||||
| H4 | `department.xml:160-179` | **updateDepartment 의 WHERE 에 `DELETED_AT IS NULL` 누락** — 삭제된 부서도 update 통과 후 controller 가 404 반환 → DB 는 변경되었는데 사용자는 실패로 인식 (silent corruption) |
|
||||
| H5 | `DepartmentService.java:131` | **updateDepartment 부서명 중복 검증 없음** — create 에는 있지만 update 에 없어 동일 이름 active 부서 두 개 공존 가능 |
|
||||
| H6 | `DepartmentService.java:210` `restoreDepartment` | 복구 시 동일 이름 active 부서 충돌 검증 없음 → 100% 재현되는 중복 이름 공존 |
|
||||
| H7 | `DepartmentController.java:135` `deleteDepartment` | COMPANY_ADMIN 이 글로벌 부서 (`*`) 삭제 가능 → SUPER_ADMIN 전용 가드 필요 |
|
||||
| H8 | `DepartmentService.java:85-99` `createDepartment` | 사용자 명시 dept_code 가 정규식 위반 시 silent fallback → 자동 코드로 발행되지만 사용자는 알지 못함 |
|
||||
| H9 | `DepartmentController.java:234` `searchUsers` | role 검사 없음 — 일반 USER 가 회사 내 사용자 enumeration 가능 |
|
||||
| H10 | `page.tsx:113` `emptyDraft` | `start_date` 가 `new Date().toISOString().slice(0,10)` 으로 고정 — UI 에서 hidden 인데도 today 가 강제 저장됨. 일괄등록도 동일 |
|
||||
| H11 | DEPT_INFO 스키마 (mapper 외 확인 필요) | `(COMPANY_CODE, dept_name)` partial UNIQUE 인덱스 부재 가능성 → race 시 중복 부서명 공존 (#H5/H6 의 근본 방어선) |
|
||||
|
||||
## 🟡 MEDIUM
|
||||
|
||||
| # | 위치 | 한 줄 |
|
||||
|---|---|---|
|
||||
| M1 | `page.tsx:219-228` 멤버 fetch | AbortController/cancellation flag 없음 → 빠른 부서 전환 시 stale 멤버 데이터 표시 |
|
||||
| M2 | `page.tsx:503` `handleDelete` | `selectedCode` 를 클로저로 캡처 안 함 → 다이얼로그 열린 상태에서 다른 부서 선택되면 엉뚱한 부서 삭제 위험 |
|
||||
| M3 | `page.tsx:299` `handleClearDetail` | `isDirty` 무시. X 버튼 클릭 시 미저장 변경 즉시 폐기 |
|
||||
| M4 | `page.tsx:997` 변경이력 모달 | API 호출 없음 — 항상 "이력 없음" 표시. 기능 미구현 |
|
||||
| M5 | `DepartmentService.java:96` `selectNextDeptNumber` | `MAX+1` 비원자적 → 동시 생성 시 PK 충돌 5xx (catch 누락) |
|
||||
| M6 | `DepartmentService.java:181` `deleteDepartment` | 활성 자식만 카운트 → 자식 단독 복구 시 부모 deleted 차단되는 UX trap |
|
||||
| M7 | `DepartmentService.java:283` `searchUsers` | LIKE 와일드카드 `%`/`_` 미이스케이프 → 동작 이상 + enumeration |
|
||||
| M8 | `DepartmentService.java:107`+ | `nullIfBlank` 만 적용. 선행/후행 공백 보존 → DB 에 공백 포함 코드 저장 가능 |
|
||||
| M9 | `department.xml:150-174` | `#{date}::date` cast 시 잘못된 형식 → SQLException 5xx |
|
||||
| M10 | DEPT_INFO `*` 회사 정책 | 글로벌 부서 read 일반 user 허용 / write 권한 정책 모호 |
|
||||
| M11 | `DepartmentController.java:353` super 식별 | `company_code='*'` 우연 충돌 시 super 권한 부여 — provisioning 가드 필수 |
|
||||
|
||||
## 🟢 LOW
|
||||
|
||||
| # | 위치 | 한 줄 |
|
||||
|---|---|---|
|
||||
| L1 | `DepartmentController.java:271-272` | dead code (동일 키 두 번 lookup) |
|
||||
| L2 | `DepartmentService.java:324` `removeDeptMember` | primary 자동 승격 race 가능성 (낮음) |
|
||||
| L3 | `page.tsx:920` picker "최상위로" 선택 | UX 미구현 — `handleConfirmMoveTo(null)` 트리거 경로 없음 |
|
||||
| L4 | `page.tsx:957` bulk register | 실패 부서 코드/사유 표시 없음 (성공/실패 카운트만) |
|
||||
| L5 | `page.tsx:1469` 멤버 패널 | 멤버 추가/제거 UI 미구현. API 만 존재 |
|
||||
| L6 | `page.tsx:249` `expandAll` + 검색 | 검색 후 expandAll → 검색 해제 시 부분 펼침 상태 |
|
||||
| L7 | `department.xml:213` selectDeptMembers INNER JOIN | USER_INFO 삭제 시 orphan 멤버 표시 안 됨 |
|
||||
| L8 | `department.xml:192` deleteUserDeptByDeptCode | dead query |
|
||||
| L9 | `department.xml:266` selectFirstUserDept | 동일 NOW() 다중 row 시 비결정적 |
|
||||
|
||||
---
|
||||
|
||||
## 추천 수정 순서
|
||||
|
||||
**Phase 1 (긴급/보안)**: C1, C2, C3, H7
|
||||
**Phase 2 (데이터 무결성)**: H4, H5, H6, H11, H1
|
||||
**Phase 3 (UX/안정성)**: H2, H3, H8, H10, M1~M3
|
||||
**Phase 4 (기능 보강)**: M4, L4, L5, L3
|
||||
**Phase 5 (정리)**: L1, L7~L9, M5~M11
|
||||
|
||||
## 도메인별 통계
|
||||
|
||||
- Frontend: 진짜 8 / 잠재 6 / 오탐 1
|
||||
- Backend: CRITICAL 3 + HIGH 7 + MEDIUM 7 + LOW 4
|
||||
- SQL: HIGH 2 + MEDIUM 4 + LOW 4 + OK 4
|
||||
- UX: 진짜 6 + 잠재 10 + OK 4
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user