diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index fe1acefb..7ee10c98 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -113,6 +113,10 @@ public class DepartmentController { if (!canAccessDept(existing, userCompanyCode)) { return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다.")); } + String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null; + if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다.")); + } try { Map updated = departmentService.updateDepartment(deptCode, body); @@ -120,6 +124,8 @@ public class DepartmentController { 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())); } @@ -149,6 +155,10 @@ public class DepartmentController { if (!canAccessDept(existing, userCompanyCode)) { return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다.")); } + String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null; + if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다.")); + } try { int result = departmentService.deleteDepartment(deptCode); @@ -187,8 +197,17 @@ public class DepartmentController { 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 = departmentService.restoreDepartment(deptCode); + DepartmentService.RestoreResult result; + try { + result = departmentService.restoreDepartment(deptCode); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage())); + } switch (result) { case OK: Map data = new java.util.HashMap<>(); @@ -234,8 +253,17 @@ public class DepartmentController { @GetMapping("/companies/{companyCode}/users/search") public ResponseEntity>>> searchUsers( @PathVariable String companyCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestAttribute("role") String role, @RequestParam(required = false) String search) { + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다.")); + } + if (search == null || search.isBlank()) { return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요.")); } @@ -266,10 +294,13 @@ public class DepartmentController { 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를 입력해주세요.")); } @@ -307,6 +338,10 @@ public class DepartmentController { 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) { @@ -337,9 +372,17 @@ public class DepartmentController { 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.setPrimaryDept(deptCode, userId); - return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다.")); + try { + departmentService.setPrimaryDept(deptCode, userId); + return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다.")); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage())); + } } // ────────────────────────────────────────────────── diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index f30dfb82..e774d84c 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -81,21 +81,29 @@ public class DepartmentService extends BaseService { ? (String) company.get("company_name") : companyCode; - // 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성 - String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code")); - String deptCode; - if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) { - // 중복 체크 - Map existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", - Map.of("dept_code", requestedCode)); - if (existing != null) { - throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다."); - } - deptCode = requestedCode; - } else { + // parent_dept_code cross-tenant / 존재 / 삭제 검증 + Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")); + String parentCode = parentObj != null ? parentObj.toString() : null; + validateParent(parentCode, companyCode); + + // 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08) + // 재시도 로직 (race condition 대비, 최대 3회) + String deptCode = null; + for (int attempt = 0; attempt < 3; attempt++) { Map codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null); - long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L; - deptCode = "DEPT_" + nextNumber; + long nextNumber = codeResult != null && codeResult.get("next_number") != null + ? ((Number) codeResult.get("next_number")).longValue() + : 1L; + String candidate = "DEPT_" + nextNumber; + Map existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", candidate)); + if (existing == null) { + deptCode = candidate; + break; + } + } + if (deptCode == null) { + throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요."); } // 부서 생성 (전체 필드) @@ -104,7 +112,7 @@ 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"))); @@ -135,9 +143,34 @@ public class DepartmentService extends BaseService { throw new IllegalArgumentException("부서명을 입력해주세요."); } + // 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용) + Map existingDept = sqlSession.selectOne( + "department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", deptCode) + ); + String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null + ? existingDept.get("company_code").toString() + : null; + // 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단 Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")); - verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null); + String newParentCode = newParent != null ? newParent.toString() : null; + // parent_dept_code cross-tenant / 존재 / 삭제 검증 + if (deptCompanyCode != null) { + validateParent(newParentCode, deptCompanyCode); + } + verifyParentCycle(deptCode, newParentCode); + + // 부서명 중복 검증 — 본인 dept_code 는 제외 + if (deptCompanyCode != null) { + Map dupParams = new HashMap<>(); + dupParams.put("company_code", deptCompanyCode); + dupParams.put("dept_name", deptName); + Map duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); + if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) { + throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다."); + } + } Map params = new HashMap<>(); params.put("dept_code", deptCode); @@ -227,6 +260,19 @@ public class DepartmentService extends BaseService { } } + // 동일 이름의 active 부서 중복 검증 (복구 시점) + Object companyCodeObj = dept.get("company_code"); + Object deptNameObj = dept.get("dept_name"); + if (companyCodeObj != null && deptNameObj != null) { + Map dupParams = new HashMap<>(); + dupParams.put("company_code", companyCodeObj.toString()); + dupParams.put("dept_name", deptNameObj.toString()); + Map duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); + if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) { + throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다."); + } + } + Map params = new HashMap<>(); params.put("dept_code", deptCode); int restored = sqlSession.update("department.restoreDepartment", params); @@ -242,6 +288,28 @@ public class DepartmentService extends BaseService { OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED } + /** + * parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증. + * null/blank 면 검증 스킵 (최상위 부서). + */ + private void validateParent(String parentCode, String companyCode) { + if (parentCode == null || parentCode.isBlank()) return; + Map parent = sqlSession.selectOne( + "department.selectDepartmentByCodeIncludingDeleted", + Map.of("dept_code", parentCode) + ); + if (parent == null) { + throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode); + } + if (parent.get("deleted_at") != null) { + throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode); + } + Object parentCompany = parent.get("company_code"); + if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) { + throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다."); + } + } + /** * parent_dept_code 변경 시 사이클 검증. * deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor @@ -355,6 +423,15 @@ public class DepartmentService extends BaseService { @Transactional public void setPrimaryDept(String deptCode, String userId) { + // 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지 + Map existParams = new HashMap<>(); + existParams.put("user_id", userId); + existParams.put("dept_code", deptCode); + Map existing = sqlSession.selectOne("department.selectExistingMember", existParams); + if (existing == null) { + throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요."); + } + // 다른 부서의 주 부서 해제 Map clearParams = new HashMap<>(); clearParams.put("user_id", userId); @@ -385,10 +462,13 @@ public class DepartmentService extends BaseService { return val != null ? val : body.get(camelCase); } - /** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */ + /** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */ private Object nullIfBlank(Object value) { if (value == null) return null; - if (value instanceof String s && s.trim().isEmpty()) return null; + if (value instanceof String s) { + String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; + } return value; } diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index f84c2ba4..be469617 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -176,6 +176,7 @@ STATUS = #{status}, LOCATION = #{location} WHERE DEPT_CODE = #{dept_code} + AND DELETED_AT IS NULL @@ -188,12 +189,6 @@ - - - DELETE FROM USER_DEPT - WHERE DEPT_CODE = #{dept_code} - - UPDATE DEPT_INFO @@ -223,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 @@ -240,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 diff --git a/db/migrations/RUN_085_MIGRATION.md b/db/migrations/RUN_085_MIGRATION.md new file mode 100644 index 00000000..58532d47 --- /dev/null +++ b/db/migrations/RUN_085_MIGRATION.md @@ -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 -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 -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; +``` diff --git a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx index 6993815e..5df0b9b2 100644 --- a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx +++ b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx @@ -110,7 +110,7 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({ zipcode: "", address1: "", address2: "", - start_date: new Date().toISOString().slice(0, 10), + start_date: "", end_date: "", sort_order: 10, }); @@ -143,12 +143,13 @@ export default function DeptMngListPage() { const [activeTab, setActiveTab] = useState<"info" | "members">("info"); const [members, setMembers] = useState([]); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null); // ── 일괄등록 / 변경이력 모달 ───────────────────────── const [bulkOpen, setBulkOpen] = useState(false); const [bulkText, setBulkText] = useState(""); const [bulkUploading, setBulkUploading] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); + const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]); // ── 트리 ⋮ 메뉴: 이동/삭제 대상 ─────────────────────── const [moveTargetDept, setMoveTargetDept] = useState(null); @@ -221,21 +222,40 @@ export default function DeptMngListPage() { setMembers([]); return; } + let cancelled = false; (async () => { const res = await departmentAPI.getDepartmentMembers(selectedCode); + if (cancelled) return; if (res.success && (res as any).data) setMembers((res as any).data); })(); + return () => { + cancelled = true; + }; }, [activeTab, selectedCode, isNewMode]); // ── 트리 구성 ──────────────────────────────────────── const filteredDepts = useMemo(() => { if (!searchKeyword.trim()) return departments; const kw = searchKeyword.toLowerCase(); - return departments.filter( - (d) => - d.dept_name?.toLowerCase().includes(kw) || - d.dept_code?.toLowerCase().includes(kw), + // 1차: 직접 매칭된 부서들 + const directMatches = new Set( + departments + .filter((d) => d.dept_name?.toLowerCase().includes(kw) || d.dept_code?.toLowerCase().includes(kw)) + .map((d) => d.dept_code), ); + // 2차: 매칭된 노드의 모든 조상도 포함 (트리 구조 유지) + const visible = new Set(directMatches); + const byCode = new Map(departments.map((d) => [d.dept_code, d])); + for (const code of directMatches) { + let cur: string | null | undefined = byCode.get(code)?.parent_dept_code ?? null; + const visited = new Set([code]); + while (cur && !visited.has(cur)) { + visited.add(cur); + visible.add(cur); + cur = byCode.get(cur)?.parent_dept_code ?? null; + } + } + return departments.filter((d) => visible.has(d.dept_code)); }, [departments, searchKeyword]); const childrenOf = useCallback( @@ -247,7 +267,7 @@ export default function DeptMngListPage() { ); const expandAll = () => { - setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code))); + setExpandedSet(new Set(departments.map((d) => d.dept_code))); }; const collapseAll = () => setExpandedSet(new Set()); @@ -414,6 +434,7 @@ export default function DeptMngListPage() { await loadDepartments(); } catch (err: any) { toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" }); + await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화 } }; @@ -501,15 +522,16 @@ export default function DeptMngListPage() { // ── 삭제 ───────────────────────────────────────────── const handleDelete = async () => { - if (!selectedCode) return; + const target = pendingDeleteDept; + if (!target) return; try { - const res = await departmentAPI.deleteDepartment(selectedCode); + const res = await departmentAPI.deleteDepartment(target.code); if (res.success) { const softDeleted = (res as any).data?.soft_deleted; toast({ title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다", description: softDeleted - ? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.` + ? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.` : undefined, }); await loadDepartments(); @@ -519,6 +541,7 @@ export default function DeptMngListPage() { } } finally { setDeleteConfirmOpen(false); + setPendingDeleteDept(null); } }; @@ -594,14 +617,9 @@ export default function DeptMngListPage() { + @@ -915,6 +967,7 @@ export default function DeptMngListPage() { @@ -935,14 +988,15 @@ export default function DeptMngListPage() {

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

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

-

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

+

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

+

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