diff --git a/backend-spring/src/main/java/com/erp/controller/ApprovalController.java b/backend-spring/src/main/java/com/erp/controller/ApprovalController.java index adc270a5..0e8d2e9b 100644 --- a/backend-spring/src/main/java/com/erp/controller/ApprovalController.java +++ b/backend-spring/src/main/java/com/erp/controller/ApprovalController.java @@ -190,9 +190,11 @@ public class ApprovalController { public ResponseEntity>> getRequests( @RequestParam Map params, @RequestAttribute("company_code") String companyCode, - @RequestAttribute("user_id") String userId) { + @RequestAttribute("user_id") String userId, + @RequestAttribute(name = "effective_user_ids", required = false) List effectiveUserIds) { params.put("company_code", companyCode); params.put("user_id", userId); + params.put("effective_user_ids", effectiveUserIds); return ResponseEntity.ok(ApiResponse.success(approvalService.getRequests(params))); } @@ -277,10 +279,12 @@ public class ApprovalController { @GetMapping("/my-pending") public ResponseEntity>>> getMyPendingLines( @RequestAttribute("company_code") String companyCode, - @RequestAttribute("user_id") String userId) { + @RequestAttribute("user_id") String userId, + @RequestAttribute(name = "effective_user_ids", required = false) List effectiveUserIds) { Map params = new HashMap<>(); params.put("company_code", companyCode); params.put("user_id", userId); + params.put("effective_user_ids", effectiveUserIds); return ResponseEntity.ok(ApiResponse.success(approvalService.getMyPendingLines(params))); } diff --git a/backend-spring/src/main/java/com/erp/controller/SubstituteController.java b/backend-spring/src/main/java/com/erp/controller/SubstituteController.java new file mode 100644 index 00000000..e0ef5e32 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/controller/SubstituteController.java @@ -0,0 +1,175 @@ +package com.erp.controller; + +import com.erp.dto.ApiResponse; +import com.erp.service.SubstituteService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 대무자(代務者) 관리 API. + * + * Spec: .omc/specs/deep-dive-user-substitute-management.md + * Plan: .omc/plans/autopilot-impl.md (T4) + * + * 정책: + * - GET /mine 은 본인 read-only (누구나 가능) + * - 나머지는 관리자(ADMIN/SUPER_ADMIN) 만 — Service 의 requireAdmin 이 2차 방어 + */ +@RestController +@RequestMapping("/api/substitutes") +@RequiredArgsConstructor +@Slf4j +public class SubstituteController { + + private final SubstituteService substituteService; + + // ───────────────────────────────────────────────────────────── + // 조회 — 관리자 + // ───────────────────────────────────────────────────────────── + + @GetMapping + public ResponseEntity>> getList( + @RequestParam Map params, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + params.put("company_code", companyCode); + params.put("role", role); + try { + return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteList(params))); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage())); + } + } + + @GetMapping("/{id}") + public ResponseEntity>> getOne( + @PathVariable("id") Long substituteId, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("관리자만 조회할 수 있습니다.")); + } + Map params = new HashMap<>(); + params.put("substitute_id", substituteId); + params.put("company_code", companyCode); + try { + return ResponseEntity.ok(ApiResponse.success(substituteService.getSubstituteInfo(params))); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage())); + } + } + + // ───────────────────────────────────────────────────────────── + // 본인 조회 — ProfileModal read-only + // ───────────────────────────────────────────────────────────── + + @GetMapping("/mine") + public ResponseEntity>> getMine( + @RequestAttribute("user_id") String userId, + @RequestAttribute("company_code") String companyCode) { + Map params = new HashMap<>(); + params.put("user_id", userId); + params.put("company_code", companyCode); + return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params))); + } + + // ───────────────────────────────────────────────────────────── + // 변경 — 관리자 + // ───────────────────────────────────────────────────────────── + + @PostMapping + public ResponseEntity>> create( + @RequestBody Map body, + @RequestAttribute("user_id") String userId, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + body.put("company_code", companyCode); + body.put("role", role); + body.put("created_by", userId); + try { + Map created = substituteService.insertSubstitute(body); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(created, "대무자가 지정되었습니다.")); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage())); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + log.error("대무자 등록 오류", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("대무자 등록 중 오류가 발생했습니다.")); + } + } + + @PutMapping("/{id}") + public ResponseEntity>> update( + @PathVariable("id") Long substituteId, + @RequestBody Map body, + @RequestAttribute("user_id") String userId, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + body.put("substitute_id", substituteId); + body.put("company_code", companyCode); + body.put("role", role); + body.put("updated_by", userId); + try { + return ResponseEntity.ok( + ApiResponse.success(substituteService.updateSubstitute(body), "대무 설정이 수정되었습니다.")); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage())); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + log.error("대무자 수정 오류", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("대무자 수정 중 오류가 발생했습니다.")); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable("id") Long substituteId, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("role") String role) { + Map params = new HashMap<>(); + params.put("substitute_id", substituteId); + params.put("company_code", companyCode); + params.put("role", role); + try { + substituteService.deleteSubstitute(params); + return ResponseEntity.ok(ApiResponse.success(null, "대무 설정이 해지되었습니다.")); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(e.getMessage())); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + log.error("대무자 해지 오류", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("대무자 해지 중 오류가 발생했습니다.")); + } + } + + // ───────────────────────────────────────────────────────────── + // 사전 검증 — UI 가 등록 직전 호출 + // ───────────────────────────────────────────────────────────── + + @PostMapping("/check-overlap") + public ResponseEntity>> checkOverlap( + @RequestBody Map body, + @RequestAttribute("company_code") String companyCode) { + body.put("company_code", companyCode); + int cnt = substituteService.checkOverlap(body); + Map result = new HashMap<>(); + result.put("overlap", cnt > 0); + result.put("count", cnt); + return ResponseEntity.ok(ApiResponse.success(result)); + } +} diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index b88eed8b..aad1c53d 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -104,7 +104,80 @@ public class StartupSchemaMigrator { CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID) ) """, - "CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)" + "CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)", + + // RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성 + "CREATE EXTENSION IF NOT EXISTS btree_gist", + + // RUN_086 (2) 대무자(代務者) 관리 테이블 + // self-위임 차단 (CHECK), 같은 쌍 활성 기간 겹침 차단 (EXCLUDE). + // 재실행 시 IF NOT EXISTS 로 안전. EXCLUDE/CHECK 제약은 첫 생성 때만 적용. + """ + CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES ( + SUBSTITUTE_ID BIGSERIAL PRIMARY KEY, + COMPANY_CODE VARCHAR(50) NOT NULL, + ORIGINAL_USER_ID VARCHAR(50) NOT NULL, + PROXY_USER_ID VARCHAR(50) NOT NULL, + START_DATE DATE NULL, + END_DATE DATE NOT NULL, + REASON VARCHAR(500), + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE, + CREATED_BY VARCHAR(50), + CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), + UPDATED_BY VARCHAR(50), + UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_user_substitutes_self + CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID), + CONSTRAINT chk_user_substitutes_date + CHECK (START_DATE IS NULL OR START_DATE <= END_DATE), + CONSTRAINT excl_user_substitutes_overlap + EXCLUDE USING gist ( + COMPANY_CODE WITH =, + ORIGINAL_USER_ID WITH =, + PROXY_USER_ID WITH =, + daterange(START_DATE, END_DATE, '[]') WITH && + ) WHERE (IS_ACTIVE = TRUE) + ) + """, + + // RUN_086 (3) USER_SUBSTITUTES 인덱스 — Filter 핫패스 + 조회 가속 + "CREATE INDEX IF NOT EXISTS idx_user_substitutes_original ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)", + "CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)", + + // RUN_086 (4) SYSTEM_AUDIT_LOG — 처리자(actual processor) 분리 기록 컬럼 + "ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50)", + "ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100)", + + // RUN_086 (5) APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 데이터 복사 (idempotent) + // 기존 운영 데이터 보존 + 어댑터 read 경로가 즉시 동작하도록. + // IS_ACTIVE 매핑: APPROVAL_PROXY_SETTINGS 의 CHAR('Y'/'N') → BOOLEAN. + // 메타데이터(created/updated) 는 원본 컬럼 의존 없이 'migration_086' + NOW() 로 고정 + // (APPROVAL_PROXY_SETTINGS 의 timestamp 컬럼명이 환경별로 다를 수 있어 안전한 default 채택). + """ + INSERT INTO USER_SUBSTITUTES ( + COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, + START_DATE, END_DATE, REASON, IS_ACTIVE, + CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE + ) + SELECT + p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID, + CAST(NULLIF(p.START_DATE, '') AS DATE), + CAST(NULLIF(p.END_DATE, '') AS DATE), + p.REASON, + CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END, + 'migration_086', NOW(), + 'migration_086', NOW() + FROM APPROVAL_PROXY_SETTINGS p + WHERE NULLIF(p.END_DATE, '') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM USER_SUBSTITUTES s + WHERE s.COMPANY_CODE = p.COMPANY_CODE + AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID + AND s.PROXY_USER_ID = p.PROXY_USER_ID + AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE) + AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE) + ) + """ ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java index 7a88baee..7f8859f5 100644 --- a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java +++ b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java @@ -2,6 +2,7 @@ package com.erp.security; import com.erp.ai.security.AiApiKeyAuthFilter; import com.erp.ai.service.AiAgentApiKeyService; +import com.erp.service.SubstituteService; import com.erp.tenant.CompanyResolver; import com.erp.tenant.SubdomainResolverFilter; import com.erp.tenant.TenantDbSettings; @@ -37,6 +38,7 @@ public class SecurityConfig { private final TenantRoutingDataSource tenantRoutingDataSource; private final TenantDbSettings tenantDbSettings; private final AiAgentApiKeyService aiAgentApiKeyService; + private final SubstituteService substituteService; /** * CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com"). @@ -76,9 +78,12 @@ public class SecurityConfig { // JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조. .addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider), JwtAuthenticationFilter.class) - // TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과. + // TenantConsistencyGuardFilter 뒤 — 대무자(代務者) 컨텍스트 effective_user_ids 주입. + .addFilterAfter(new SubstituteContextFilter(substituteService), + TenantConsistencyGuardFilter.class) + // SubstituteContextFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과. .addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider), - TenantConsistencyGuardFilter.class); + SubstituteContextFilter.class); return http.build(); } diff --git a/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java new file mode 100644 index 00000000..a9e947f2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java @@ -0,0 +1,88 @@ +package com.erp.security; + +import com.erp.service.SubstituteService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 대무자(代務者) 컨텍스트 주입 필터. + * + * Spec: .omc/specs/deep-dive-user-substitute-management.md + * Plan: .omc/plans/autopilot-impl.md (T5) + * + * 동작: + * 1. /api/** 가 아니면 통과 + * 2. JWT user_id / company_code attribute 없으면 통과 (비로그인) + * 3. company_code == "*" (SUPER_ADMIN pre-switch) 이면 통과 — 대무 의미 없음 + * 4. substituteService.getActiveOriginalUserIds(userId, companyCode) 조회 + * 5. effective_user_ids = [userId, ...originalIds] → request attribute + * 6. actual_processor_id = userId → request attribute (의미 명시 alias) + * + * 예외 처리: + * DB 조회 실패 시 effective_user_ids 를 [userId] 만 담아 통과시킨다 — 대무 컨텍스트 + * 실패가 본 요청을 깨면 안 되기 때문 (가용성 우선). warn 로그 남김. + * + * 성능: + * - request 당 SELECT 1회 (인덱스 (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE) 매치, 보통 <1ms) + * - request-scope 자연 캐시 — 한 요청 내에서 attribute 만 참조하면 추가 조회 없음 + * + * 필터 순서: + * SubdomainResolver → AiApiKey → Jwt → TenantConsistencyGuard → **여기** → ForcePasswordChangeGuard + */ +@Slf4j +@RequiredArgsConstructor +public class SubstituteContextFilter extends OncePerRequestFilter { + + public static final String ATTR_EFFECTIVE_USER_IDS = "effective_user_ids"; + public static final String ATTR_ACTUAL_PROCESSOR_ID = "actual_processor_id"; + + private final SubstituteService substituteService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String path = request.getRequestURI(); + if (path == null || !path.startsWith("/api/")) { + chain.doFilter(request, response); + return; + } + + String userId = (String) request.getAttribute("user_id"); + String companyCode = (String) request.getAttribute("company_code"); + + // 비로그인 또는 SUPER_ADMIN pre-switch → 대무 컨텍스트 의미 없음, 통과 + if (userId == null || companyCode == null || "*".equals(companyCode)) { + chain.doFilter(request, response); + return; + } + + List effectiveIds = new ArrayList<>(); + effectiveIds.add(userId); + + try { + List originalIds = substituteService.getActiveOriginalUserIds(userId, companyCode); + if (originalIds != null && !originalIds.isEmpty()) { + effectiveIds.addAll(originalIds); + } + } catch (Exception e) { + // 대무 컨텍스트 조회 실패는 본 요청을 막지 않음 — 본인 권한만으로 동작 + log.warn("[SubstituteContext] failed to resolve proxy context for user={}: {}", + userId, e.getMessage()); + } + + request.setAttribute(ATTR_EFFECTIVE_USER_IDS, effectiveIds); + request.setAttribute(ATTR_ACTUAL_PROCESSOR_ID, userId); + + chain.doFilter(request, response); + } +} diff --git a/backend-spring/src/main/java/com/erp/service/ApprovalService.java b/backend-spring/src/main/java/com/erp/service/ApprovalService.java index d3933f02..964e5669 100644 --- a/backend-spring/src/main/java/com/erp/service/ApprovalService.java +++ b/backend-spring/src/main/java/com/erp/service/ApprovalService.java @@ -17,6 +17,26 @@ public class ApprovalService extends BaseService { @Autowired private ObjectMapper objectMapper; + @Autowired + private AuditLogService auditLogService; + + /** + * IN (:effective_user_ids) 쿼리용 fallback. + * SubstituteContextFilter 가 attribute 를 못 채운 경로(통합 테스트/배치 등) 에서도 + * 빈 IN () SQL 에러를 막기 위해 항상 최소 [user_id] 가 들어가도록 한다. + */ + @SuppressWarnings("unchecked") + private void ensureEffectiveUserIds(Map params) { + Object v = params.get("effective_user_ids"); + boolean empty = v == null || (v instanceof Collection && ((Collection) v).isEmpty()); + if (empty) { + Object userId = params.get("user_id"); + if (userId != null) { + params.put("effective_user_ids", List.of(userId)); + } + } + } + // ═══════════════════════════════════════════════════════════════ // approval_definitions // ═══════════════════════════════════════════════════════════════ @@ -149,6 +169,7 @@ public class ApprovalService extends BaseService { // ═══════════════════════════════════════════════════════════════ public Map getRequests(Map params) { + ensureEffectiveUserIds(params); int page = toInt(params.getOrDefault("page", "1")); int limit = toInt(params.getOrDefault("limit", "20")); params.put("page_limit", limit); @@ -359,6 +380,7 @@ public class ApprovalService extends BaseService { // ═══════════════════════════════════════════════════════════════ public List> getMyPendingLines(Map params) { + ensureEffectiveUserIds(params); return sqlSession.selectList("approval.selectMyPendingLines", params); } @@ -456,6 +478,24 @@ public class ApprovalService extends BaseService { activateNextStep(requestId, stepOrder, totalSteps, lineCC, userId, comment); } } + + // 결재 처리 audit log — 대무 시 user_id(A)와 processor_id(B) 분리 기록. + // 실패는 본 처리를 막지 않음 (가용성 우선). + try { + Map auditP = new HashMap<>(); + auditP.put("company_code", lineCC); + auditP.put("user_id", approverId); // 위임자 A + auditP.put("user_name", line.get("approver_name")); + auditP.put("processor_id", userId); // 실제 처리자 B + // processor_name 은 AuditLogService 가 USER_INFO 에서 lookup (T14) + auditP.put("action", "approval." + action); + auditP.put("resource_type", "approval_line"); + auditP.put("resource_id", String.valueOf(lineId)); + auditP.put("summary", "결재 " + action + (proxyFor != null ? " (대무)" : "")); + auditLogService.insertAuditLog(auditP); + } catch (Exception e) { + log.warn("결재 audit log 기록 실패 (line={}): {}", lineId, e.getMessage()); + } } // ═══════════════════════════════════════════════════════════════ diff --git a/backend-spring/src/main/java/com/erp/service/AuditLogService.java b/backend-spring/src/main/java/com/erp/service/AuditLogService.java index 0a8d08ec..ca006c8a 100644 --- a/backend-spring/src/main/java/com/erp/service/AuditLogService.java +++ b/backend-spring/src/main/java/com/erp/service/AuditLogService.java @@ -73,7 +73,12 @@ public class AuditLogService extends BaseService { } /** - * 감사 로그 1건 기록 + * 감사 로그 1건 기록. + * + * PROCESSOR 처리 (대무 추적): + * - processor_id 미지정 → user_id 로 채움 (평시 = 동일 = 본인 처리) + * - processor_id 가 user_id 와 다르고 processor_name 미지정 → USER_INFO 에서 lookup + * (대무 이벤트만 USER_INFO 단건 조회 1회 — 평시는 추가 DB 호출 없음) */ public void insertAuditLog(Map params) { // changes가 Map이면 JSON 문자열로 직렬화 @@ -86,6 +91,26 @@ public class AuditLogService extends BaseService { params.put("changes", null); } } + + Object processorId = params.get("processor_id"); + Object userId = params.get("user_id"); + if (processorId == null) { + params.put("processor_id", userId); + if (params.get("processor_name") == null) { + params.put("processor_name", params.get("user_name")); + } + } else if (!processorId.equals(userId) && params.get("processor_name") == null) { + try { + Map p = new HashMap<>(); + p.put("user_id", processorId); + p.put("company_code", params.get("company_code")); + String name = sqlSession.selectOne("auditLog.selectUserNameById", p); + params.put("processor_name", name); + } catch (Exception e) { + log.warn("processor_name lookup 실패 (processor_id={}): {}", processorId, e.getMessage()); + } + } + sqlSession.insert("auditLog.insertAuditLog", params); } diff --git a/backend-spring/src/main/java/com/erp/service/SubstituteService.java b/backend-spring/src/main/java/com/erp/service/SubstituteService.java new file mode 100644 index 00000000..6e69e81a --- /dev/null +++ b/backend-spring/src/main/java/com/erp/service/SubstituteService.java @@ -0,0 +1,246 @@ +package com.erp.service; + +import com.erp.common.BaseService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 대무자(代務者) 관리 서비스. + * + * Spec: .omc/specs/deep-dive-user-substitute-management.md + * Plan: .omc/plans/autopilot-impl.md (T3) + * + * 핵심 규칙: + * - 관리자만 위임 지정/수정/해지. 본인 self-위임 불가. + * - 종료일 필수, 시작일 옵션 (비우면 즉시). + * - 같은 (COMPANY, ORIGINAL, PROXY) 쌍의 활성 기간 겹침 금지 (DB EXCLUDE + 사전 검증). + * - 같은 회사 사용자끼리만. SUPER_ADMIN 은 대무자로 지정 불가. + */ +@Service +@Slf4j +public class SubstituteService extends BaseService { + + private static final String NS = "substitute."; + + // ───────────────────────────────────────────────────────────── + // 조회 + // ───────────────────────────────────────────────────────────── + + public Map getSubstituteList(Map params) { + requireAdmin(params); + + List> list = sqlSession.selectList(NS + "selectSubstituteList", params); + Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params); + + Map result = new HashMap<>(); + result.put("list", list); + result.put("total", total == null ? 0 : total); + return result; + } + + /** + * ProfileModal read-only: 내가 위임한(proxying_for_me) + 나를 대무 중인(my_proxies) 두 방향 한 번에. + * 결과를 Java 단에서 partition. + */ + public Map getMySubstitutes(Map params) { + if (params.get("user_id") == null) { + throw new IllegalArgumentException("user_id 가 필요합니다."); + } + + List> rows = sqlSession.selectList(NS + "selectMySubstitutes", params); + + List> proxyingForMe = new ArrayList<>(); + List> myProxies = new ArrayList<>(); + for (Map row : rows) { + Object relation = row.get("relation"); + if ("proxying_for_me".equals(relation)) { + proxyingForMe.add(row); + } else if ("my_proxies".equals(relation)) { + myProxies.add(row); + } + } + + Map result = new HashMap<>(); + result.put("proxying_for_me", proxyingForMe); + result.put("my_proxies", myProxies); + return result; + } + + /** + * SubstituteContextFilter 핫 패스. 트랜잭션 없이 가볍게. + * 반환: B 가 현재 대무 중인 A 의 ID 목록 (없으면 빈 리스트). + */ + public List getActiveOriginalUserIds(String proxyUserId, String companyCode) { + if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) { + return List.of(); + } + Map p = new HashMap<>(); + p.put("proxy_user_id", proxyUserId); + p.put("company_code", companyCode); + List ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p); + return ids == null ? List.of() : ids; + } + + public Map getSubstituteInfo(Map params) { + Map row = sqlSession.selectOne(NS + "selectSubstituteInfo", params); + if (row == null) { + throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다."); + } + return row; + } + + /** + * ApprovalService 어댑터: B 가 A 의 대무자로 활성 상태인지 검증. + * 결재 처리 중 호출. + */ + public Map getActiveProxyForLine(Map params) { + return sqlSession.selectOne(NS + "selectActiveProxyForLine", params); + } + + public int checkOverlap(Map params) { + Integer cnt = sqlSession.selectOne(NS + "countOverlap", params); + return cnt == null ? 0 : cnt; + } + + // ───────────────────────────────────────────────────────────── + // 변경 + // ───────────────────────────────────────────────────────────── + + public Map insertSubstitute(Map params) { + requireAdmin(params); + validateInsertParams(params); + + sqlSession.insert(NS + "insertSubstitute", params); + + Map info = new HashMap<>(); + info.put("substitute_id", params.get("substitute_id")); + info.put("company_code", params.get("company_code")); + return getSubstituteInfo(info); + } + + public Map updateSubstitute(Map params) { + requireAdmin(params); + + Map existing = getSubstituteInfo(params); + + // 변경되는 사용자 ID 가 있으면 회사 소속 + SUPER_ADMIN 검증 + Object newProxy = params.get("proxy_user_id"); + if (newProxy != null && !newProxy.equals(existing.get("proxy_user_id"))) { + validateUserInCompany((String) newProxy, (String) params.get("company_code"), "proxy"); + rejectSuperAdminAsProxy((String) newProxy); + } + + // 기간/대무자 변경 시 겹침 재검증 + if (params.get("start_date") != null || params.get("end_date") != null + || params.get("clear_start_date") != null || newProxy != null) { + Map overlapParams = new HashMap<>(); + overlapParams.put("company_code", params.get("company_code")); + overlapParams.put("original_user_id", existing.get("original_user_id")); + overlapParams.put("proxy_user_id", + newProxy != null ? newProxy : existing.get("proxy_user_id")); + overlapParams.put("start_date", + Boolean.TRUE.equals(params.get("clear_start_date")) ? null + : (params.get("start_date") != null ? params.get("start_date") + : existing.get("start_date"))); + overlapParams.put("end_date", + params.get("end_date") != null ? params.get("end_date") + : existing.get("end_date")); + overlapParams.put("exclude_substitute_id", params.get("substitute_id")); + if (checkOverlap(overlapParams) > 0) { + throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다."); + } + } + + int updated = sqlSession.update(NS + "updateSubstitute", params); + if (updated == 0) { + throw new IllegalArgumentException("대무 설정 수정에 실패했습니다."); + } + return getSubstituteInfo(params); + } + + public void deleteSubstitute(Map params) { + requireAdmin(params); + getSubstituteInfo(params); // 존재 확인 + sqlSession.delete(NS + "deleteSubstitute", params); + } + + // ───────────────────────────────────────────────────────────── + // 검증 + // ───────────────────────────────────────────────────────────── + + private void validateInsertParams(Map params) { + String companyCode = (String) params.get("company_code"); + String original = (String) params.get("original_user_id"); + String proxy = (String) params.get("proxy_user_id"); + Object endDate = params.get("end_date"); + + if (companyCode == null || companyCode.isBlank()) { + throw new IllegalArgumentException("회사 코드가 필요합니다."); + } + if (original == null || original.isBlank()) { + throw new IllegalArgumentException("위임자(대상 사용자) 가 필요합니다."); + } + if (proxy == null || proxy.isBlank()) { + throw new IllegalArgumentException("대무자가 필요합니다."); + } + if (original.equals(proxy)) { + throw new IllegalArgumentException("본인을 자기 대무자로 지정할 수 없습니다."); + } + if (endDate == null || (endDate instanceof String && ((String) endDate).isBlank())) { + throw new IllegalArgumentException("종료일은 필수입니다 (무기한 대무 금지)."); + } + + // B3: 같은 회사 소속 검증 + validateUserInCompany(original, companyCode, "original"); + validateUserInCompany(proxy, companyCode, "proxy"); + + // SUPER_ADMIN 을 대무자로 지정 금지 + rejectSuperAdminAsProxy(proxy); + + // 사전 겹침 검증 + Map overlapParams = new HashMap<>(); + overlapParams.put("company_code", companyCode); + overlapParams.put("original_user_id", original); + overlapParams.put("proxy_user_id", proxy); + overlapParams.put("start_date", params.get("start_date")); + overlapParams.put("end_date", endDate); + if (checkOverlap(overlapParams) > 0) { + throw new IllegalArgumentException("같은 대상-대무자 쌍의 활성 기간이 겹칩니다."); + } + } + + private void validateUserInCompany(String userId, String companyCode, String which) { + Map p = new HashMap<>(); + p.put("user_id", userId); + p.put("company_code", companyCode); + Integer cnt = sqlSession.selectOne(NS + "countUserInCompany", p); + if (cnt == null || cnt == 0) { + throw new IllegalArgumentException( + "original".equals(which) + ? "대상 사용자가 회사에 존재하지 않습니다." + : "대무자가 회사에 존재하지 않습니다."); + } + } + + private void rejectSuperAdminAsProxy(String userId) { + Map p = new HashMap<>(); + p.put("user_id", userId); + Integer cnt = sqlSession.selectOne(NS + "countSuperAdmin", p); + if (cnt != null && cnt > 0) { + throw new IllegalArgumentException("SUPER_ADMIN 은 대무자로 지정할 수 없습니다."); + } + } + + private void requireAdmin(Map params) { + String role = (String) params.get("role"); + if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) { + throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다."); + } + } +} diff --git a/backend-spring/src/main/resources/mapper/approval.xml b/backend-spring/src/main/resources/mapper/approval.xml index 1259b5e9..91005bb9 100644 --- a/backend-spring/src/main/resources/mapper/approval.xml +++ b/backend-spring/src/main/resources/mapper/approval.xml @@ -214,7 +214,10 @@ AND EXISTS ( SELECT 1 FROM APPROVAL_LINES L WHERE L.REQUEST_ID = R.REQUEST_ID - AND L.APPROVER_ID = #{user_id} + AND L.APPROVER_ID IN + + #{uid} + AND L.STATUS = 'pending' AND L.COMPANY_CODE = R.COMPANY_CODE ) @@ -248,7 +251,10 @@ AND EXISTS ( SELECT 1 FROM APPROVAL_LINES L WHERE L.REQUEST_ID = R.REQUEST_ID - AND L.APPROVER_ID = #{user_id} + AND L.APPROVER_ID IN + + #{uid} + AND L.STATUS = 'pending' AND L.COMPANY_CODE = R.COMPANY_CODE ) @@ -463,7 +469,10 @@ FROM APPROVAL_LINES L JOIN APPROVAL_REQUESTS R ON L.REQUEST_ID = R.REQUEST_ID AND L.COMPANY_CODE = R.COMPANY_CODE - WHERE L.APPROVER_ID = #{user_id} + WHERE L.APPROVER_ID IN + + #{uid} + AND L.STATUS = 'pending' AND (L.COMPANY_CODE = #{company_code} OR L.COMPANY_CODE = '*') ORDER BY R.CREATED_DATE ASC @@ -536,12 +545,14 @@ AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') + - + INSERT INTO SYSTEM_AUDIT_LOG ( COMPANY_CODE, USER_ID, USER_NAME, ACTION, RESOURCE_TYPE, RESOURCE_ID, RESOURCE_NAME, TABLE_NAME, SUMMARY, CHANGES, - IP_ADDRESS, REQUEST_PATH + IP_ADDRESS, REQUEST_PATH, + PROCESSOR_ID, PROCESSOR_NAME ) VALUES ( #{company_code}, #{user_id}, #{user_name}, #{action}, #{resource_type}, #{resource_id}, #{resource_name}, #{table_name}, #{summary}, CAST(#{changes} AS JSONB), - #{ip_address}, #{request_path} + #{ip_address}, #{request_path}, + #{processor_id}, #{processor_name} ) + + + diff --git a/backend-spring/src/main/resources/mapper/substitute.xml b/backend-spring/src/main/resources/mapper/substitute.xml new file mode 100644 index 00000000..724c7ac1 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/substitute.xml @@ -0,0 +1,294 @@ + + + + + + + + + AND S.IS_ACTIVE = TRUE + AND (S.START_DATE IS NULL OR S.START_DATE <= CURRENT_DATE) + AND S.END_DATE >= CURRENT_DATE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO USER_SUBSTITUTES ( + COMPANY_CODE + , ORIGINAL_USER_ID + , PROXY_USER_ID + , START_DATE + , END_DATE + , REASON + , IS_ACTIVE + , CREATED_BY + , CREATED_DATE + , UPDATED_BY + , UPDATED_DATE + ) VALUES ( + #{company_code} + , #{original_user_id} + , #{proxy_user_id} + , #{start_date} + , #{end_date} + , #{reason} + , COALESCE(#{is_active}, TRUE) + , #{created_by} + , NOW() + , #{created_by} + , NOW() + ) + + + + UPDATE USER_SUBSTITUTES + + + PROXY_USER_ID = #{proxy_user_id}, + + + START_DATE = CAST(#{start_date} AS DATE), + + + START_DATE = NULL, + + + END_DATE = CAST(#{end_date} AS DATE), + + + REASON = #{reason}, + + + IS_ACTIVE = #{is_active}, + + UPDATED_BY = #{updated_by}, + UPDATED_DATE = NOW() + + WHERE SUBSTITUTE_ID = #{substitute_id} + AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') + + + + DELETE FROM USER_SUBSTITUTES + WHERE SUBSTITUTE_ID = #{substitute_id} + AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*') + + + diff --git a/db/migrations/RUN_086_MIGRATION.md b/db/migrations/RUN_086_MIGRATION.md new file mode 100644 index 00000000..82ec4545 --- /dev/null +++ b/db/migrations/RUN_086_MIGRATION.md @@ -0,0 +1,236 @@ +# 086 마이그레이션 — 대무자(代務者) 관리 테이블 + 감사 로그 처리자 추적 + +작성일: 2026-05-11 +작성자: johngreen +관련: `.omc/specs/deep-dive-user-substitute-management.md`, `.omc/plans/autopilot-impl.md` + +## 목적 + +- `USER_SUBSTITUTES` 테이블 신설 — 직원 부재 시 결재/일반 업무 대신 처리 권한 위임. +- `SYSTEM_AUDIT_LOG.PROCESSOR_ID/PROCESSOR_NAME` 컬럼 추가 — 대무 처리 시 "원본 사용자(A)" 와 "실제 처리자(B)" 분리 기록. +- `APPROVAL_PROXY_SETTINGS` 기존 운영 데이터를 `USER_SUBSTITUTES` 로 1회 복사 (어댑터 방향). + +## Phase 1 범위 (Non-Goal 명시) + +- **결재 대무 한정.** 메뉴/AUTHORITY_MASTER 기반 일반 권한 union 은 **Phase 2** 로 descope. +- `APPROVAL_PROXY_SETTINGS` 테이블 유지 (rollback safety). Phase 2 에서 폐기 검토. + +## 전제 조건 (Hard Prerequisite) + +PostgreSQL `btree_gist` 확장이 설치되어 있어야 합니다. EXCLUDE 제약이 이 확장에 의존합니다. + +```sql +-- 사전 점검 (이 SELECT 가 1 row 를 반환하면 진행 가능) +SELECT 1 FROM pg_extension WHERE extname = 'btree_gist'; + +-- 미설치 시 superuser 권한으로 활성화 +CREATE EXTENSION IF NOT EXISTS btree_gist; +``` + +`btree_gist` 미설치 + 활성화 권한 없음 → 운영팀에 확장 설치 요청 후 마이그레이션 재시도. **trigger 기반 fallback 은 race-unsafe 라 사용하지 않습니다.** + +## 스키마 + +### USER_SUBSTITUTES 테이블 (신설) + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `SUBSTITUTE_ID` | BIGSERIAL | PK | 자동 증가 | +| `COMPANY_CODE` | VARCHAR(50) | NOT NULL | 회사 코드 (테넌트 식별) | +| `ORIGINAL_USER_ID` | VARCHAR(50) | NOT NULL | 위임자 (A, 원본 사용자) | +| `PROXY_USER_ID` | VARCHAR(50) | NOT NULL | 대무자 (B) | +| `START_DATE` | DATE | NULL 가능 | 시작일 (NULL = 즉시) | +| `END_DATE` | DATE | NOT NULL | 종료일 (그 날 23:59:59 까지 유효) | +| `REASON` | VARCHAR(500) | NULL | 사유 | +| `IS_ACTIVE` | BOOLEAN | DEFAULT TRUE | 활성 플래그 | +| `CREATED_BY` | VARCHAR(50) | | 생성자 | +| `CREATED_DATE` | TIMESTAMP | DEFAULT NOW() | 생성 시각 | +| `UPDATED_BY` | VARCHAR(50) | | 수정자 | +| `UPDATED_DATE` | TIMESTAMP | DEFAULT NOW() | 수정 시각 | + +### 제약 / 인덱스 + +- `CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID)` — self-위임 DB-level 차단 +- `EXCLUDE USING gist (... WITH =, daterange WITH &&) WHERE (IS_ACTIVE)` — 같은 (COMPANY, ORIGINAL, PROXY) 쌍의 활성 기간 겹침 차단 +- 인덱스 2개: `(COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)`, `(COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)` + +### SYSTEM_AUDIT_LOG ALTER + +- `PROCESSOR_ID VARCHAR(50)` — 실제 처리자 (B). 평시 = USER_ID 와 동일. +- `PROCESSOR_NAME VARCHAR(100)` — 처리자 이름. + +## SQL + +```sql +-- ================================================================= +-- 086: USER_SUBSTITUTES + SYSTEM_AUDIT_LOG 처리자 추적 +-- ================================================================= + +-- 0. 전제 확장 +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- 1. USER_SUBSTITUTES 테이블 +CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES ( + SUBSTITUTE_ID BIGSERIAL PRIMARY KEY, + COMPANY_CODE VARCHAR(50) NOT NULL, + ORIGINAL_USER_ID VARCHAR(50) NOT NULL, + PROXY_USER_ID VARCHAR(50) NOT NULL, + START_DATE DATE NULL, + END_DATE DATE NOT NULL, + REASON VARCHAR(500), + IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE, + CREATED_BY VARCHAR(50), + CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), + UPDATED_BY VARCHAR(50), + UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT chk_user_substitutes_self + CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID), + CONSTRAINT chk_user_substitutes_date + CHECK (START_DATE IS NULL OR START_DATE <= END_DATE), + CONSTRAINT excl_user_substitutes_overlap + EXCLUDE USING gist ( + COMPANY_CODE WITH =, + ORIGINAL_USER_ID WITH =, + PROXY_USER_ID WITH =, + daterange(START_DATE, END_DATE, '[]') WITH && + ) WHERE (IS_ACTIVE = TRUE) + -- daterange 의 lower bound 가 NULL 이면 -infinity. EXCLUDE 인덱스는 + -- IMMUTABLE 함수만 허용하므로 COALESCE(.., CURRENT_DATE) 같은 STABLE 함수 사용 불가. + -- START_DATE NULL 인 같은 쌍 활성 row 들은 어쨌든 겹침으로 자연 차단됨. +); + +-- 2. 인덱스 +CREATE INDEX IF NOT EXISTS idx_user_substitutes_original + ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE); + +CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy + ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE); + +-- 3. SYSTEM_AUDIT_LOG 컬럼 추가 +ALTER TABLE SYSTEM_AUDIT_LOG + ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50), + ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100); + +-- 4. APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 복사 (idempotent) +-- 기존 운영 데이터 보존 + 어댑터 방향 read 경로가 즉시 동작하도록 +INSERT INTO USER_SUBSTITUTES ( + COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, + START_DATE, END_DATE, REASON, IS_ACTIVE, + CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE +) +SELECT + p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID, + p.START_DATE, p.END_DATE, p.REASON, + CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END, + 'migration_086', NOW(), + 'migration_086', NOW() +FROM APPROVAL_PROXY_SETTINGS p +WHERE NOT EXISTS ( + SELECT 1 FROM USER_SUBSTITUTES s + WHERE s.COMPANY_CODE = p.COMPANY_CODE + AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID + AND s.PROXY_USER_ID = p.PROXY_USER_ID + AND s.START_DATE IS NOT DISTINCT FROM p.START_DATE + AND s.END_DATE = p.END_DATE +); +``` + +## 사전 점검 + +```sql +-- A. btree_gist 확장 설치 여부 +SELECT extname, extversion FROM pg_extension WHERE extname = 'btree_gist'; +-- 기대: 1 row. 없으면 superuser 로 CREATE EXTENSION 후 진행. + +-- B. APPROVAL_PROXY_SETTINGS 에 EXCLUDE 제약을 위반하는 데이터 사전 탐지 +-- (같은 쌍 + 기간 겹침). 있으면 마이그레이션 4번 INSERT 가 일부 실패할 수 있음. +SELECT COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, COUNT(*) +FROM APPROVAL_PROXY_SETTINGS +WHERE IS_ACTIVE = 'Y' +GROUP BY COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID +HAVING COUNT(*) > 1; +-- 결과 있으면 운영팀과 상의 후 비활성화 처리. + +-- C. SYSTEM_AUDIT_LOG 컬럼 사전 상태 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'system_audit_log' + AND column_name IN ('processor_id', 'processor_name'); +-- 빈 결과여야 정상. 이미 있으면 ALTER 의 IF NOT EXISTS 가 안전. +``` + +## 사후 검증 + +```sql +-- D. 테이블 + 제약 + 인덱스 확인 +\d USER_SUBSTITUTES +-- 확인 항목: PK, CHECK 2개, EXCLUDE, 인덱스 2개 + +-- E. 컬럼 추가 확인 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'system_audit_log' + AND column_name IN ('processor_id', 'processor_name'); +-- 기대: 2 rows + +-- F. EXCLUDE 동작 확인 (테스트) +BEGIN; +INSERT INTO USER_SUBSTITUTES + (COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, END_DATE) + VALUES ('TEST_CO', 'A', 'B', CURRENT_DATE + 7); +INSERT INTO USER_SUBSTITUTES + (COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, END_DATE) + VALUES ('TEST_CO', 'A', 'B', CURRENT_DATE + 14); +-- 두 번째 INSERT 가 23P01 (exclusion_violation) 으로 거부되어야 정상 +ROLLBACK; + +-- G. self-위임 차단 확인 +BEGIN; +INSERT INTO USER_SUBSTITUTES + (COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, END_DATE) + VALUES ('TEST_CO', 'A', 'A', CURRENT_DATE + 7); +-- 23514 (check_violation) 거부되어야 정상 +ROLLBACK; + +-- H. 데이터 복사 검증 +SELECT + (SELECT COUNT(*) FROM APPROVAL_PROXY_SETTINGS) AS legacy_count, + (SELECT COUNT(*) FROM USER_SUBSTITUTES) AS new_count; +``` + +## 실행 + +```bash +# 1) 메타 DB +psql -h -U postgres -d invyone -f RUN_086.sql + +# 2) 각 테넌트 DB +for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do + echo "=== $db ===" + psql -h -U postgres -d "$db" -f RUN_086.sql +done +``` + +`RUN_086.sql` 은 위 "SQL" 섹션 0~4 블록을 그대로 담은 파일입니다. + +## 롤백 + +```sql +-- 4번 단계 데이터 복사로 발생한 row 만 표시적으로 제거 (선택) +DELETE FROM USER_SUBSTITUTES WHERE CREATED_BY = 'migration_086'; + +-- 컬럼 제거 +ALTER TABLE SYSTEM_AUDIT_LOG + DROP COLUMN IF EXISTS PROCESSOR_ID, + DROP COLUMN IF EXISTS PROCESSOR_NAME; + +-- 테이블 제거 +DROP TABLE IF EXISTS USER_SUBSTITUTES; + +-- btree_gist 확장은 보존 (다른 테이블/인덱스가 사용 중일 수 있음) +``` + +## 적용 환경 체크리스트 + +- [ ] 로컬 docker `naengangi-pg` (`btree_gist` 1.7 available, 활성화 후 확인) +- [ ] wace 개발서버 PostgreSQL (운영팀 확인 필요) +- [ ] 운영 메타 DB (`invyone`) +- [ ] 운영 각 테넌트 DB (loop) diff --git a/frontend/app/(main)/approval/page.tsx b/frontend/app/(main)/approval/page.tsx index 4b57371b..6b66c7a1 100644 --- a/frontend/app/(main)/approval/page.tsx +++ b/frontend/app/(main)/approval/page.tsx @@ -16,6 +16,7 @@ import { type ApprovalRequest, type ApprovalLine, } from "@/lib/api/approval"; +import { useAuth } from "@/hooks/useAuth"; // 상태 배지 색상 const statusConfig: Record = { @@ -204,12 +205,17 @@ function ApprovalDetailModal({ request, open, onClose, onRefresh, pendingLineId // 결재 대기 행 (ApprovalLine 기반) function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () => void }) { + const { user } = useAuth(); const statusInfo = lineStatusConfig[line.status] || { label: line.status, icon: null }; const createdAt = line.request_created_at || line.created_at; const formattedDate = createdAt ? new Date(createdAt).toLocaleDateString("ko-KR", { year: "2-digit", month: "2-digit", day: "2-digit" }) : "-"; + // 대무 받은 결재: 현재 사용자가 본인이 아닌 다른 사람(원본 결재자)에게 배정된 라인을 보고 있음 + const isProxy = user?.user_id != null && line.approver_id != null + && user.user_id !== line.approver_id; + return ( + + + {error && !dialogOpen && ( +
{error}
+ )} + + {loading ? ( +
불러오는 중...
+ ) : rows.length === 0 ? ( +
지정된 대무자가 없습니다.
+ ) : ( + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
대무자기간사유상태
+ {r.proxy_user_name || r.proxy_user_id} + {r.proxy_dept_name && ( + ({r.proxy_dept_name}) + )} + + {r.start_date ?? "즉시"} ~ {r.end_date} + {r.reason ?? "-"}{statusBadge(r)} + +
+ )} + + + + + 대무자 지정 + + +
+
+ + setForm({ ...form, proxy_user_id: e.target.value })} + placeholder="예: hjkim" + autoFocus + /> +

+ 같은 회사 사용자만 지정 가능. SUPER_ADMIN 은 지정 불가. +

+
+ +
+
+ + setForm({ ...form, start_date: e.target.value })} + /> +

비우면 즉시 시작

+
+
+ + setForm({ ...form, end_date: e.target.value })} + /> +

그 날까지 유효

+
+
+ +
+ +