Merge branch 'main' into hjjeong

# Conflicts:
#	backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
This commit is contained in:
hjjeong
2026-05-12 11:42:17 +09:00
19 changed files with 1855 additions and 16 deletions
@@ -190,9 +190,11 @@ public class ApprovalController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getRequests(
@RequestParam Map<String, Object> params,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
@RequestAttribute("user_id") String userId,
@RequestAttribute(name = "effective_user_ids", required = false) List<String> 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<ApiResponse<List<Map<String, Object>>>> getMyPendingLines(
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("user_id") String userId) {
@RequestAttribute("user_id") String userId,
@RequestAttribute(name = "effective_user_ids", required = false) List<String> effectiveUserIds) {
Map<String, Object> 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)));
}
@@ -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<ApiResponse<Map<String, Object>>> getList(
@RequestParam Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<String, Object> 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<ApiResponse<Map<String, Object>>> getMine(
@RequestAttribute("user_id") String userId,
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(substituteService.getMySubstitutes(params)));
}
// ─────────────────────────────────────────────────────────────
// 변경 — 관리자
// ─────────────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> create(
@RequestBody Map<String, Object> 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<String, Object> 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<ApiResponse<Map<String, Object>>> update(
@PathVariable("id") Long substituteId,
@RequestBody Map<String, Object> 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<ApiResponse<Void>> delete(
@PathVariable("id") Long substituteId,
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role) {
Map<String, Object> 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<ApiResponse<Map<String, Object>>> checkOverlap(
@RequestBody Map<String, Object> body,
@RequestAttribute("company_code") String companyCode) {
body.put("company_code", companyCode);
int cnt = substituteService.checkOverlap(body);
Map<String, Object> result = new HashMap<>();
result.put("overlap", cnt > 0);
result.put("count", cnt);
return ResponseEntity.ok(ApiResponse.success(result));
}
}
@@ -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)
@@ -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();
}
@@ -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<String> effectiveIds = new ArrayList<>();
effectiveIds.add(userId);
try {
List<String> 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);
}
}
@@ -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<String, Object> 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<String, Object> getRequests(Map<String, Object> 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<Map<String, Object>> getMyPendingLines(Map<String, Object> 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<String, Object> 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());
}
}
// ═══════════════════════════════════════════════════════════════
@@ -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<String, Object> 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<String, Object> 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);
}
@@ -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<String, Object> getSubstituteList(Map<String, Object> params) {
requireAdmin(params);
List<Map<String, Object>> list = sqlSession.selectList(NS + "selectSubstituteList", params);
Integer total = sqlSession.selectOne(NS + "selectSubstituteListCnt", params);
Map<String, Object> 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<String, Object> getMySubstitutes(Map<String, Object> params) {
if (params.get("user_id") == null) {
throw new IllegalArgumentException("user_id 가 필요합니다.");
}
List<Map<String, Object>> rows = sqlSession.selectList(NS + "selectMySubstitutes", params);
List<Map<String, Object>> proxyingForMe = new ArrayList<>();
List<Map<String, Object>> myProxies = new ArrayList<>();
for (Map<String, Object> 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<String, Object> result = new HashMap<>();
result.put("proxying_for_me", proxyingForMe);
result.put("my_proxies", myProxies);
return result;
}
/**
* SubstituteContextFilter 핫 패스. 트랜잭션 없이 가볍게.
* 반환: B 가 현재 대무 중인 A 의 ID 목록 (없으면 빈 리스트).
*/
public List<String> getActiveOriginalUserIds(String proxyUserId, String companyCode) {
if (proxyUserId == null || companyCode == null || "*".equals(companyCode)) {
return List.of();
}
Map<String, Object> p = new HashMap<>();
p.put("proxy_user_id", proxyUserId);
p.put("company_code", companyCode);
List<String> ids = sqlSession.selectList(NS + "selectActiveOriginalUserIds", p);
return ids == null ? List.of() : ids;
}
public Map<String, Object> getSubstituteInfo(Map<String, Object> params) {
Map<String, Object> row = sqlSession.selectOne(NS + "selectSubstituteInfo", params);
if (row == null) {
throw new IllegalArgumentException("대무 설정을 찾을 수 없습니다.");
}
return row;
}
/**
* ApprovalService 어댑터: B 가 A 의 대무자로 활성 상태인지 검증.
* 결재 처리 중 호출.
*/
public Map<String, Object> getActiveProxyForLine(Map<String, Object> params) {
return sqlSession.selectOne(NS + "selectActiveProxyForLine", params);
}
public int checkOverlap(Map<String, Object> params) {
Integer cnt = sqlSession.selectOne(NS + "countOverlap", params);
return cnt == null ? 0 : cnt;
}
// ─────────────────────────────────────────────────────────────
// 변경
// ─────────────────────────────────────────────────────────────
public Map<String, Object> insertSubstitute(Map<String, Object> params) {
requireAdmin(params);
validateInsertParams(params);
sqlSession.insert(NS + "insertSubstitute", params);
Map<String, Object> info = new HashMap<>();
info.put("substitute_id", params.get("substitute_id"));
info.put("company_code", params.get("company_code"));
return getSubstituteInfo(info);
}
public Map<String, Object> updateSubstitute(Map<String, Object> params) {
requireAdmin(params);
Map<String, Object> 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<String, Object> 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<String, Object> params) {
requireAdmin(params);
getSubstituteInfo(params); // 존재 확인
sqlSession.delete(NS + "deleteSubstitute", params);
}
// ─────────────────────────────────────────────────────────────
// 검증
// ─────────────────────────────────────────────────────────────
private void validateInsertParams(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> params) {
String role = (String) params.get("role");
if (!"ADMIN".equals(role) && !"SUPER_ADMIN".equals(role)) {
throw new AccessDeniedException("관리자만 대무자를 지정/수정/해지할 수 있습니다.");
}
}
}
@@ -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
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
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
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
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
<foreach collection="effective_user_ids" item="uid" open="(" separator="," close=")">
#{uid}
</foreach>
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 = '*')
</delete>
<!-- 어댑터: USER_SUBSTITUTES 참조 (T7, 086 마이그레이션 이후). -->
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
SELECT * FROM APPROVAL_PROXY_SETTINGS
SELECT *
FROM USER_SUBSTITUTES
WHERE ORIGINAL_USER_ID = #{original_user_id}
AND PROXY_USER_ID = #{proxy_user_id}
AND IS_ACTIVE = 'Y'
AND START_DATE &lt;= CURRENT_DATE
AND IS_ACTIVE = TRUE
AND (START_DATE IS NULL OR START_DATE &lt;= CURRENT_DATE)
AND END_DATE &gt;= CURRENT_DATE
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
LIMIT 1
@@ -143,18 +143,31 @@
ORDER BY COUNT DESC, U.USER_NAME ASC
</select>
<!-- 감사 로그 INSERT -->
<!-- 감사 로그 INSERT.
PROCESSOR_ID/PROCESSOR_NAME 은 대무(代務) 처리 추적용 (086 마이그레이션 이후).
평시는 USER_ID == PROCESSOR_ID. -->
<insert id="insertAuditLog" parameterType="map">
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}
)
</insert>
<!-- 처리자 이름 lookup (대무 시 USER_INFO 에서 1회 조회). -->
<select id="selectUserNameById" parameterType="map" resultType="string">
SELECT USER_NAME
FROM USER_INFO
WHERE USER_ID = #{user_id}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
LIMIT 1
</select>
</mapper>
@@ -0,0 +1,294 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="substitute">
<!-- ================================================================
USER_SUBSTITUTES — 대무자(代務者) 관리
Spec: .omc/specs/deep-dive-user-substitute-management.md
Plan: .omc/plans/autopilot-impl.md (T2)
핵심:
- 종료일 NOT NULL (무기한 금지), 시작일 NULL = 즉시
- 활성 판정: IS_ACTIVE = TRUE AND (START_DATE IS NULL OR
START_DATE <= CURRENT_DATE) AND END_DATE >= CURRENT_DATE
- 같은 쌍 + 활성 기간 겹침은 EXCLUDE 제약으로 DB 차단
================================================================ -->
<!-- 활성 판정 공통 조건 -->
<sql id="activeWindow">
AND S.IS_ACTIVE = TRUE
AND (S.START_DATE IS NULL OR S.START_DATE &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE
</sql>
<!-- ================================================================
조회
================================================================ -->
<select id="selectSubstituteList" parameterType="map" resultType="map">
SELECT S.SUBSTITUTE_ID
, S.COMPANY_CODE
, S.ORIGINAL_USER_ID
, U1.USER_NAME AS ORIGINAL_USER_NAME
, U1.DEPT_NAME AS ORIGINAL_DEPT_NAME
, S.PROXY_USER_ID
, U2.USER_NAME AS PROXY_USER_NAME
, U2.DEPT_NAME AS PROXY_DEPT_NAME
, S.START_DATE
, S.END_DATE
, S.REASON
, S.IS_ACTIVE
, CASE
WHEN S.IS_ACTIVE = FALSE THEN 'inactive'
WHEN S.END_DATE &lt; CURRENT_DATE THEN 'expired'
WHEN S.START_DATE IS NOT NULL AND S.START_DATE &gt; CURRENT_DATE THEN 'upcoming'
ELSE 'active'
END AS STATUS
, S.CREATED_BY
, S.CREATED_DATE
, S.UPDATED_BY
, S.UPDATED_DATE
FROM USER_SUBSTITUTES S
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
WHERE 1=1
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
<if test="original_user_id != null and original_user_id != ''">
AND S.ORIGINAL_USER_ID = #{original_user_id}
</if>
<if test="proxy_user_id != null and proxy_user_id != ''">
AND S.PROXY_USER_ID = #{proxy_user_id}
</if>
<choose>
<when test='status == "active"'>
AND S.IS_ACTIVE = TRUE
AND (S.START_DATE IS NULL OR S.START_DATE &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE
</when>
<when test='status == "upcoming"'>
AND S.IS_ACTIVE = TRUE
AND S.START_DATE IS NOT NULL
AND S.START_DATE &gt; CURRENT_DATE
</when>
<when test='status == "expired"'>
AND S.END_DATE &lt; CURRENT_DATE
</when>
<when test='status == "inactive"'>
AND S.IS_ACTIVE = FALSE
</when>
</choose>
ORDER BY
CASE
WHEN S.IS_ACTIVE = TRUE
AND (S.START_DATE IS NULL OR S.START_DATE &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE THEN 0
WHEN S.IS_ACTIVE = TRUE AND S.START_DATE &gt; CURRENT_DATE THEN 1
ELSE 2
END
, S.END_DATE DESC
, S.CREATED_DATE DESC
<include refid="common.pagination"/>
</select>
<select id="selectSubstituteListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM USER_SUBSTITUTES S
WHERE 1=1
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
<if test="original_user_id != null and original_user_id != ''">
AND S.ORIGINAL_USER_ID = #{original_user_id}
</if>
<if test="proxy_user_id != null and proxy_user_id != ''">
AND S.PROXY_USER_ID = #{proxy_user_id}
</if>
<choose>
<when test='status == "active"'>
AND S.IS_ACTIVE = TRUE
AND (S.START_DATE IS NULL OR S.START_DATE &lt;= CURRENT_DATE)
AND S.END_DATE &gt;= CURRENT_DATE
</when>
<when test='status == "upcoming"'>
AND S.IS_ACTIVE = TRUE
AND S.START_DATE IS NOT NULL
AND S.START_DATE &gt; CURRENT_DATE
</when>
<when test='status == "expired"'>
AND S.END_DATE &lt; CURRENT_DATE
</when>
<when test='status == "inactive"'>
AND S.IS_ACTIVE = FALSE
</when>
</choose>
</select>
<!-- ProfileModal 본인 조회: 내가 위임한 + 나를 대무 중인 -->
<select id="selectMySubstitutes" parameterType="map" resultType="map">
SELECT S.SUBSTITUTE_ID
, S.COMPANY_CODE
, S.ORIGINAL_USER_ID
, U1.USER_NAME AS ORIGINAL_USER_NAME
, U1.DEPT_NAME AS ORIGINAL_DEPT_NAME
, S.PROXY_USER_ID
, U2.USER_NAME AS PROXY_USER_NAME
, U2.DEPT_NAME AS PROXY_DEPT_NAME
, S.START_DATE
, S.END_DATE
, S.REASON
, S.IS_ACTIVE
, CASE
WHEN S.ORIGINAL_USER_ID = #{user_id} THEN 'proxying_for_me'
WHEN S.PROXY_USER_ID = #{user_id} THEN 'my_proxies'
END AS RELATION
, CASE
WHEN S.IS_ACTIVE = FALSE THEN 'inactive'
WHEN S.END_DATE &lt; CURRENT_DATE THEN 'expired'
WHEN S.START_DATE IS NOT NULL AND S.START_DATE &gt; CURRENT_DATE THEN 'upcoming'
ELSE 'active'
END AS STATUS
, (S.END_DATE - CURRENT_DATE) AS DAYS_REMAINING
FROM USER_SUBSTITUTES S
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
WHERE (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
AND (S.ORIGINAL_USER_ID = #{user_id} OR S.PROXY_USER_ID = #{user_id})
AND S.END_DATE &gt;= CURRENT_DATE
ORDER BY S.END_DATE ASC
</select>
<!-- Filter hot path: B 가 현재 대무 중인 모든 A 의 ID -->
<select id="selectActiveOriginalUserIds" parameterType="map" resultType="string">
SELECT S.ORIGINAL_USER_ID
FROM USER_SUBSTITUTES S
WHERE S.PROXY_USER_ID = #{proxy_user_id}
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
<include refid="activeWindow"/>
</select>
<select id="selectSubstituteInfo" parameterType="map" resultType="map">
SELECT S.*
, U1.USER_NAME AS ORIGINAL_USER_NAME
, U2.USER_NAME AS PROXY_USER_NAME
FROM USER_SUBSTITUTES S
LEFT JOIN USER_INFO U1 ON S.ORIGINAL_USER_ID = U1.USER_ID AND S.COMPANY_CODE = U1.COMPANY_CODE
LEFT JOIN USER_INFO U2 ON S.PROXY_USER_ID = U2.USER_ID AND S.COMPANY_CODE = U2.COMPANY_CODE
WHERE S.SUBSTITUTE_ID = #{substitute_id}
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
</select>
<!-- 결재 어댑터: B 가 A 의 대무자로 활성 상태인지 (ApprovalService 가 호출) -->
<select id="selectActiveProxyForLine" parameterType="map" resultType="map">
SELECT S.*
FROM USER_SUBSTITUTES S
WHERE S.ORIGINAL_USER_ID = #{original_user_id}
AND S.PROXY_USER_ID = #{proxy_user_id}
AND (S.COMPANY_CODE = #{company_code} OR S.COMPANY_CODE = '*')
<include refid="activeWindow"/>
LIMIT 1
</select>
<!-- ================================================================
사전 검증
================================================================ -->
<!-- 같은 쌍의 기간 겹침 사전 카운트 (EXCLUDE 제약 사전 우회용 UX) -->
<select id="countOverlap" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM USER_SUBSTITUTES S
WHERE S.COMPANY_CODE = #{company_code}
AND S.ORIGINAL_USER_ID = #{original_user_id}
AND S.PROXY_USER_ID = #{proxy_user_id}
AND S.IS_ACTIVE = TRUE
AND DATERANGE(COALESCE(S.START_DATE, CURRENT_DATE), S.END_DATE, '[]')
&amp;&amp; DATERANGE(COALESCE(CAST(#{start_date} AS DATE), CURRENT_DATE),
CAST(#{end_date} AS DATE), '[]')
<if test="exclude_substitute_id != null">
AND S.SUBSTITUTE_ID &lt;&gt; #{exclude_substitute_id}
</if>
</select>
<!-- 같은 회사에 속한 사용자인지 검증 (B3: cross-company 차단) -->
<select id="countUserInCompany" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM USER_INFO U
WHERE U.USER_ID = #{user_id}
AND U.COMPANY_CODE = #{company_code}
</select>
<!-- 사용자가 SUPER_ADMIN 인지 확인 (proxy_user_id 로 지정 거부 용도) -->
<select id="countSuperAdmin" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM USER_INFO U
WHERE U.USER_ID = #{user_id}
AND U.USER_TYPE = 'SUPER_ADMIN'
</select>
<!-- ================================================================
변경
================================================================ -->
<insert id="insertSubstitute" parameterType="map"
useGeneratedKeys="true" keyProperty="substitute_id" keyColumn="substitute_id">
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()
)
</insert>
<update id="updateSubstitute" parameterType="map">
UPDATE USER_SUBSTITUTES
<set>
<if test="proxy_user_id != null">
PROXY_USER_ID = #{proxy_user_id},
</if>
<if test="start_date != null">
START_DATE = CAST(#{start_date} AS DATE),
</if>
<if test="clear_start_date == true">
START_DATE = NULL,
</if>
<if test="end_date != null">
END_DATE = CAST(#{end_date} AS DATE),
</if>
<if test="reason != null">
REASON = #{reason},
</if>
<if test="is_active != null">
IS_ACTIVE = #{is_active},
</if>
UPDATED_BY = #{updated_by},
UPDATED_DATE = NOW()
</set>
WHERE SUBSTITUTE_ID = #{substitute_id}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</update>
<delete id="deleteSubstitute" parameterType="map">
DELETE FROM USER_SUBSTITUTES
WHERE SUBSTITUTE_ID = #{substitute_id}
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</delete>
</mapper>
+236
View File
@@ -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 <host> -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 <host> -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)
+17
View File
@@ -16,6 +16,7 @@ import {
type ApprovalRequest,
type ApprovalLine,
} from "@/lib/api/approval";
import { useAuth } from "@/hooks/useAuth";
// 상태 배지 색상
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
@@ -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 (
<button
className="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted/50 sm:p-4"
@@ -217,7 +223,18 @@ function ApprovalLineRow({ line, onClick }: { line: ApprovalLine; onClick: () =>
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-xs font-medium sm:text-sm">{line.title || "제목 없음"}</p>
{isProxy && (
<span
className="shrink-0 rounded border border-primary/60 px-1.5 py-0.5 text-[0.6rem] font-medium text-primary"
style={{ boxShadow: "var(--v5-glow-sm)" }}
title={`${line.approver_name || line.approver_id} 의 대무 처리`}
>
{line.approver_name || line.approver_id}
</span>
)}
</div>
{line.requester_name && (
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
: {line.requester_name}
@@ -0,0 +1,282 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
getSubstituteList,
createSubstitute,
deleteSubstitute,
checkSubstituteOverlap,
} from "@/lib/api/substitute";
/**
* 사용자별 대무자(代務者) 관리 섹션.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T11)
*
* 사용 위치: UserFormModal — 관리자만 보이며, editingUser (수정 모드) 일 때만 렌더링.
* 신규 사용자 등록 모드에서는 user_id 가 없어 의미가 없으므로 표시하지 않음.
*
* v5 디자인: --v5-surface-solid, var(--v5-glow-sm). blur 금지.
*/
interface SubstituteSectionProps {
/** 대상 원본 사용자 ID (이 사람의 대무자를 관리) */
originalUserId: string;
/** 대상 사용자 이름 (헤더 표시용) */
originalUserName?: string;
}
export function SubstituteSection({ originalUserId, originalUserName }: SubstituteSectionProps) {
const [rows, setRows] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
proxy_user_id: "",
start_date: "",
end_date: "",
reason: "",
});
const load = useCallback(async () => {
if (!originalUserId) return;
setLoading(true);
setError(null);
const res = await getSubstituteList({ original_user_id: originalUserId });
if (res.success && res.data) {
setRows(res.data.list ?? []);
} else {
setError(res.error || "대무자 목록 조회 실패");
}
setLoading(false);
}, [originalUserId]);
useEffect(() => {
load();
}, [load]);
const openDialog = () => {
setForm({ proxy_user_id: "", start_date: "", end_date: "", reason: "" });
setError(null);
setDialogOpen(true);
};
const submit = async () => {
setError(null);
if (!form.proxy_user_id.trim()) {
setError("대무자 ID 를 입력하세요.");
return;
}
if (!form.end_date) {
setError("종료일은 필수입니다.");
return;
}
if (form.proxy_user_id.trim() === originalUserId) {
setError("본인을 자기 대무자로 지정할 수 없습니다.");
return;
}
setSubmitting(true);
// 사전 겹침 검증 (UX) — 실패하면 백엔드 EXCLUDE 제약이 최종 방어
const overlap = await checkSubstituteOverlap({
original_user_id: originalUserId,
proxy_user_id: form.proxy_user_id.trim(),
start_date: form.start_date || null,
end_date: form.end_date,
});
if (overlap.success && overlap.data?.overlap) {
setError("같은 대상-대무자 쌍의 활성 기간이 겹칩니다.");
setSubmitting(false);
return;
}
const res = await createSubstitute({
original_user_id: originalUserId,
proxy_user_id: form.proxy_user_id.trim(),
end_date: form.end_date,
start_date: form.start_date || undefined,
reason: form.reason || undefined,
});
setSubmitting(false);
if (res.success) {
setDialogOpen(false);
load();
} else {
setError(res.error || res.message || "대무자 등록 실패");
}
};
const release = async (substituteId: number) => {
if (!confirm("이 대무 설정을 해지하시겠습니까?")) return;
const res = await deleteSubstitute(substituteId);
if (res.success) {
load();
} else {
alert(res.error || res.message || "해지 실패");
}
};
const statusBadge = (row: Record<string, any>) => {
const status = row.status as string | undefined;
const baseClass =
"inline-block rounded px-1.5 py-0.5 text-[0.65rem] font-medium border";
if (status === "active") return <span className={`${baseClass} border-emerald-500 text-emerald-600`}></span>;
if (status === "upcoming") return <span className={`${baseClass} border-blue-500 text-blue-600`}></span>;
if (status === "expired") return <span className={`${baseClass} border-muted-foreground text-muted-foreground`}></span>;
if (status === "inactive") return <span className={`${baseClass} border-destructive text-destructive`}></span>;
return null;
};
return (
<div
className="rounded-lg border border-border p-3"
style={{
background: "var(--v5-surface-solid, hsl(var(--background)))",
boxShadow: "var(--v5-glow-sm)",
}}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
{originalUserName && (
<span className="text-xs text-muted-foreground">{originalUserName} </span>
)}
</div>
<Button type="button" size="sm" onClick={openDialog}>
</Button>
</div>
{error && !dialogOpen && (
<div className="mb-2 text-xs text-destructive">{error}</div>
)}
{loading ? (
<div className="text-xs text-muted-foreground"> ...</div>
) : rows.length === 0 ? (
<div className="text-xs text-muted-foreground"> .</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal"></th>
<th className="py-1.5 font-normal text-right"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.substitute_id} className="border-b border-border/40">
<td className="py-1.5">
<span className="font-medium">{r.proxy_user_name || r.proxy_user_id}</span>
{r.proxy_dept_name && (
<span className="ml-1 text-muted-foreground">({r.proxy_dept_name})</span>
)}
</td>
<td className="py-1.5 text-muted-foreground">
{r.start_date ?? "즉시"} ~ {r.end_date}
</td>
<td className="py-1.5 text-muted-foreground">{r.reason ?? "-"}</td>
<td className="py-1.5">{statusBadge(r)}</td>
<td className="py-1.5 text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => release(r.substitute_id)}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2 text-sm">
<div>
<Label htmlFor="proxy_user_id"> ID</Label>
<Input
id="proxy_user_id"
value={form.proxy_user_id}
onChange={(e) => setForm({ ...form, proxy_user_id: e.target.value })}
placeholder="예: hjkim"
autoFocus
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground">
. SUPER_ADMIN .
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="start_date"> ()</Label>
<Input
id="start_date"
type="date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground"> </p>
</div>
<div>
<Label htmlFor="end_date">
<span className="text-destructive">*</span>
</Label>
<Input
id="end_date"
type="date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/>
<p className="mt-1 text-[0.7rem] text-muted-foreground"> </p>
</div>
</div>
<div>
<Label htmlFor="reason"></Label>
<Textarea
id="reason"
rows={2}
value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
placeholder="예: 연차 휴가"
/>
</div>
{error && <div className="text-xs text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="button" onClick={submit} disabled={submitting}>
{submitting ? "등록 중..." : "지정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Eye, EyeOff } from "lucide-react";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { SubstituteSection } from "@/components/admin/SubstituteSection";
// 알림 모달 컴포넌트
interface AlertModalProps {
@@ -683,6 +684,14 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
</div>
</div>
{/* 대무자(代務者) 관리 — 수정 모드에서만 노출 */}
{isEditMode && editingUser?.user_id && (
<SubstituteSection
originalUserId={editingUser.user_id}
originalUserName={editingUser.user_name}
/>
)}
{/* 버튼 영역 */}
<div className="flex justify-end gap-3 border-t pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={isLoading}>
@@ -0,0 +1,115 @@
"use client";
import React, { useEffect, useState } from "react";
import { getMySubstitutes } from "@/lib/api/substitute";
/**
* ProfileModal 내부 read-only 섹션: 내가 위임한 / 나를 대무 중인 관계.
*
* Spec: .omc/specs/deep-dive-user-substitute-management.md
* Plan: .omc/plans/autopilot-impl.md (T12)
*
* 지정/수정/해지 버튼 없음 — 관리자가 admin/userMng 에서만 변경 가능 (B4).
*/
export function MySubstituteView({ isVisible }: { isVisible: boolean }) {
const [proxyingForMe, setProxyingForMe] = useState<Record<string, any>[]>([]);
const [myProxies, setMyProxies] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
(async () => {
setLoading(true);
const res = await getMySubstitutes();
if (cancelled) return;
if (res.success && res.data) {
setProxyingForMe(res.data.proxying_for_me ?? []);
setMyProxies(res.data.my_proxies ?? []);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [isVisible]);
const statusBadge = (row: Record<string, any>) => {
const status = row.status as string | undefined;
const baseClass =
"ml-1 inline-block rounded px-1.5 py-0.5 text-[0.65rem] font-medium border";
if (status === "active") return <span className={`${baseClass} border-emerald-500 text-emerald-600`}></span>;
if (status === "upcoming") return <span className={`${baseClass} border-blue-500 text-blue-600`}></span>;
return null;
};
const renderRow = (r: Record<string, any>, target: "proxy" | "original") => {
const personName = target === "proxy"
? r.proxy_user_name || r.proxy_user_id
: r.original_user_name || r.original_user_id;
const deptName = target === "proxy" ? r.proxy_dept_name : r.original_dept_name;
const days = r.days_remaining;
return (
<div
key={r.substitute_id}
className="flex items-center justify-between border-b border-border/40 py-1.5 text-xs"
>
<div>
<span className="font-medium">{personName}</span>
{deptName && <span className="ml-1 text-muted-foreground">({deptName})</span>}
{statusBadge(r)}
</div>
<div className="text-muted-foreground">
{r.start_date ?? "즉시"} ~ {r.end_date}
{typeof days === "number" && days >= 0 && (
<span className="ml-2 text-foreground">D-{days}</span>
)}
</div>
</div>
);
};
return (
<div
className="rounded-lg border border-border p-3"
style={{
background: "var(--v5-surface-solid, hsl(var(--background)))",
boxShadow: "var(--v5-glow-sm)",
}}
>
<div className="mb-2 text-sm font-semibold"> </div>
{loading ? (
<div className="text-xs text-muted-foreground"> ...</div>
) : (
<>
<div className="mb-3">
<div className="mb-1 text-[0.7rem] font-medium text-muted-foreground">
( )
</div>
{proxyingForMe.length === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : (
proxyingForMe.map((r) => renderRow(r, "proxy"))
)}
</div>
<div>
<div className="mb-1 text-[0.7rem] font-medium text-muted-foreground">
( )
</div>
{myProxies.length === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : (
myProxies.map((r) => renderRow(r, "original"))
)}
</div>
<p className="mt-2 text-[0.65rem] text-muted-foreground">
/ .
</p>
</>
)}
</div>
);
}
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Camera, X } from "lucide-react";
import { ProfileFormData } from "@/types/profile";
import { apiClient } from "@/lib/api/client";
import { MySubstituteView } from "@/components/layout/MySubstituteView";
// 언어 정보 타입
interface LanguageInfo {
@@ -289,6 +290,9 @@ export function ProfileModal({
</div>
{/* 나의 대무 관계 — read-only */}
<MySubstituteView isVisible={isOpen} />
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
+115
View File
@@ -0,0 +1,115 @@
/**
* 대무자(代務者) 관리 API 클라이언트
* 엔드포인트: /api/substitutes/*
*
* 데이터 타입은 CLAUDE.md 컨벤션에 따라 Record<string, any>.
* 백엔드가 Map<String, Object> 로 응답.
*/
import { apiClient } from "@/lib/api/client";
import type { ApiResponse } from "./approval";
/**
* 회사 전체 대무 관계 조회 (관리자).
* @param params - status, original_user_id, proxy_user_id, limit, offset 등 옵션
*/
export async function getSubstituteList(
params: Record<string, any> = {}
): Promise<ApiResponse<{ list: Record<string, any>[]; total: number }>> {
try {
const response = await apiClient.get("/substitutes", { params });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* 본인 대무 관계 조회 (ProfileModal read-only).
* 반환: { proxying_for_me: [...], my_proxies: [...] }
*/
export async function getMySubstitutes(): Promise<
ApiResponse<{
proxying_for_me: Record<string, any>[];
my_proxies: Record<string, any>[];
}>
> {
try {
const response = await apiClient.get("/substitutes/mine");
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getSubstituteInfo(
substituteId: number
): Promise<ApiResponse<Record<string, any>>> {
try {
const response = await apiClient.get(`/substitutes/${substituteId}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* 대무자 신규 지정 (관리자).
* end_date 필수, start_date 옵션(비우면 즉시).
*/
export async function createSubstitute(data: {
original_user_id: string;
proxy_user_id: string;
end_date: string;
start_date?: string;
reason?: string;
}): Promise<ApiResponse<Record<string, any>>> {
try {
const response = await apiClient.post("/substitutes", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateSubstitute(
substituteId: number,
data: Record<string, any>
): Promise<ApiResponse<Record<string, any>>> {
try {
const response = await apiClient.put(`/substitutes/${substituteId}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteSubstitute(
substituteId: number
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/substitutes/${substituteId}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* 같은 (original_user_id, proxy_user_id) 쌍의 기간 겹침 사전 검증.
* UI 등록 직전 호출 (백엔드 EXCLUDE 제약이 최종 방어).
*/
export async function checkSubstituteOverlap(data: {
original_user_id: string;
proxy_user_id: string;
start_date?: string | null;
end_date: string;
exclude_substitute_id?: number;
}): Promise<ApiResponse<{ overlap: boolean; count: number }>> {
try {
const response = await apiClient.post("/substitutes/check-overlap", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
@@ -0,0 +1,87 @@
# 대무자(代務者) Phase 1 — 도메인 테이블 CREATED_BY/UPDATED_BY broad scan
작성일: 2026-05-11
작성자: johngreen
관련: `.omc/specs/deep-dive-user-substitute-management.md`, `.omc/plans/autopilot-impl.md` (T15)
## 목적
Phase 1 의 spec 결정 "일반 업무 도메인 테이블의 `created_by/updated_by` 는 actual processor(B) 로 통일" 이 현재 코드에서 자연 만족하는지 검증.
## scan 범위
`backend-spring/src/main/resources/mapper/*.xml``CREATED_BY` / `UPDATED_BY` 컬럼 사용처 전수.
## 결과 요약
| 항목 | 값 |
|---|---|
| 컬럼 사용 mapper 파일 | **33개** |
| 매치 occurrences | **206개** |
| 호출자(controller) 의 `user_id` 출처 | **모두 `@RequestAttribute("user_id")` = JWT user_id** |
| 대무 컨텍스트 자동 적용 여부 | **✅ 자동 만족 — 추가 작업 불필요** |
## 핵심 발견
### 1. 도메인 테이블의 `CREATED_BY/UPDATED_BY` 는 자동으로 actual processor(B)
INVYONE 의 모든 컨트롤러가 `@RequestAttribute("user_id") String userId` 패턴으로 JWT user_id 를 받아 params.put("user_id", userId) 후 service 호출.
`JwtAuthenticationFilter.java:38-41`:
```java
request.setAttribute("user_id", userId); // JWT 의 user_id = 실제 로그인한 사용자 = actual processor B
```
`SubstituteContextFilter` (T5) 는 JWT user_id 를 그대로 두고 `effective_user_ids` 만 별도 attribute 로 추가 주입. 즉 `request.getAttribute("user_id")` 는 평시와 대무 시 동일하게 B (로그인한 사용자).
→ 도메인 테이블 INSERT/UPDATE 시 컨트롤러가 user_id 를 params 에 넣으면 그 값은 자연스럽게 B. spec 결정과 일치.
### 2. effective_user_ids 를 user_id 로 잘못 쓰는 패턴 없음
`effective_user_ids` 는 본 작업(T8) 이전에는 존재하지 않던 attribute. 기존 코드 어디도 이걸 user_id 로 끌어쓰지 않음.
T8 이후 effective_user_ids 를 사용하는 곳:
- `approval.xml` selectRequests/countRequests my_approvals 분기 (L213-220, 247-254)
- `approval.xml` selectMyPendingLines (L458-)
이 3곳은 **WHERE 조건의 권한 union 용도** 로만 사용하고, 어디서도 `CREATED_BY = #{effective_user_ids[0]}` 같은 패턴 없음.
### 3. 결재 audit log 만 user_id ≠ processor_id 분리 기록
`ApprovalService.processApproval` (T9) → `AuditLogService.insertAuditLog` 호출:
- `user_id` = A (위임자, APPROVER_ID)
- `processor_id` = B (실제 처리자)
- `SYSTEM_AUDIT_LOG` 에만 분리. 도메인 테이블 (APPROVAL_LINES 등) 의 `CREATED_BY/UPDATED_BY` 는 그대로 B.
## 33개 파일 목록 (참고)
```
admin-cross-tenant.xml (2) approval.xml (4) barcodeLabel.xml (8)
batch.xml (2) businessRule.xml (8) cascadingHierarchy.xml (2)
cascadingRelation.xml (5) categoryValueCascading.xml (3) collection.xml (7)
common.xml (2) dashboard.xml (2) dataflow.xml (6)
dataflowDiagram.xml (3) digitalTwin.xml (9) dynamicForm.xml (4)
externalCall.xml (1) externalCallConfig.xml (8) externalDbConnection.xml (9)
externalRestApiConnection.xml (4) flow.xml (2) flowExternalDbConnection.xml (7)
layout.xml (3) multilang.xml (25) production.xml (4)
report.xml (22) schedule.xml (2) screenEmbedding.xml (2)
screenManagement.xml (21) substitute.xml (5) systemNotice.xml (3)
tableCategoryValue.xml (9) template.xml (9) webTypeStandard.xml (3)
```
## Phase 1 결론
**도메인 테이블의 `CREATED_BY/UPDATED_BY` 에 대해 추가 작업 없음.**
- spec 의 "actual processor(B) 통일" 결정이 기존 컨트롤러 패턴으로 자연 만족.
- 대무 추적은 `SYSTEM_AUDIT_LOG.PROCESSOR_ID` 컬럼으로 분리 기록 (T1 + T14).
- 회귀 위험 영역 없음.
## Phase 2 (메뉴/일반 권한 union) 으로 미룬 영역
spec 의 "B 로그인 시 A 의 권한이 자동 합쳐서 적용" 중 **메뉴/AUTHORITY_MASTER 룩업** 영역은 Phase 1 Non-Goal:
- `admin.xml`, `auth.xml`, `role.xml`, `screenGroup.xml` 의 사용자 권한/메뉴 쿼리에 `IN (effective_user_ids)` 적용 필요
- Phase 2 작업 — 별도 plan + critic 검증 후 진행
scan 진행: ✅ 완료