Merge branch 'main' into hjjeong
# Conflicts: # backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
This commit is contained in:
@@ -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 <= CURRENT_DATE
|
||||
AND IS_ACTIVE = TRUE
|
||||
AND (START_DATE IS NULL OR START_DATE <= CURRENT_DATE)
|
||||
AND END_DATE >= 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 <= CURRENT_DATE)
|
||||
AND S.END_DATE >= 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 < CURRENT_DATE THEN 'expired'
|
||||
WHEN S.START_DATE IS NOT NULL AND S.START_DATE > 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 <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "upcoming"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND S.START_DATE IS NOT NULL
|
||||
AND S.START_DATE > CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "expired"'>
|
||||
AND S.END_DATE < 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 <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE THEN 0
|
||||
WHEN S.IS_ACTIVE = TRUE AND S.START_DATE > 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 <= CURRENT_DATE)
|
||||
AND S.END_DATE >= CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "upcoming"'>
|
||||
AND S.IS_ACTIVE = TRUE
|
||||
AND S.START_DATE IS NOT NULL
|
||||
AND S.START_DATE > CURRENT_DATE
|
||||
</when>
|
||||
<when test='status == "expired"'>
|
||||
AND S.END_DATE < 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 < CURRENT_DATE THEN 'expired'
|
||||
WHEN S.START_DATE IS NOT NULL AND S.START_DATE > 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 >= 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, '[]')
|
||||
&& DATERANGE(COALESCE(CAST(#{start_date} AS DATE), CURRENT_DATE),
|
||||
CAST(#{end_date} AS DATE), '[]')
|
||||
<if test="exclude_substitute_id != null">
|
||||
AND S.SUBSTITUTE_ID <> #{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>
|
||||
@@ -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)
|
||||
@@ -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}>
|
||||
취소
|
||||
|
||||
@@ -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 진행: ✅ 완료
|
||||
Reference in New Issue
Block a user