feat: 추천 작업 5종 일괄 진행 (4 agents 병렬)
[A] 등록 모달 7페이지 (frontend) - CompanyList / ProductList / Commission / Payout / Override / Chargeback / ExceptionCode - ModalForm 580px, Switch Y/N 변환, 날짜 dayjs, PermissionButton - api/company/product/rule.ts 에 SaveReq + create/update/remove 추가 [B] BatchRun + RoleList 보강 (frontend) - BatchRun: 정산월/보험사 선택 → 실행, antd Steps 8단계 + 5초 폴링 (refetchInterval 자동 정지), KPI 4 카드, 최근 이력 - RoleList: 좌(역할 목록) 우(메뉴×6권한 매트릭스 체크박스), DIRECTORY 들여쓰기, 전체 토글, Modal.confirm 후 저장 [C] 이체파일 + 은행 어댑터 (api) - BankTransferFileGenerator 인터페이스 + GenericCsvTransferGenerator (UTF-8 BOM, 표준 CSV) + KbBankTransferGenerator (088, 고정폭 + 합계) - BankTransferFactory (Spring Map 빈) - TransferFileService: PENDING 조회 → 파일 생성 → file_storage 적재 → pay_file_ref 갱신 → status=SENT - POST /api/payments/transfer-file (DataChangeLog + EXPORT 권한) - account_no EncryptTypeHandler 자동 복호화 후 파일에 사용 - FileService.saveBytes(byte[]) 오버로드 추가 - PaymentList: 이체파일 생성 ModalForm + 다운로드 컬럼 [D] MockMvc Controller 테스트 (api) - AbstractControllerTest (SpringBootTest + MockMvc, JwtFilter doAnswer 로 chain 통과 처리), TestSecurityConfig (filter 빈 override) - 5 클래스 36 테스트: Agent/Contract/Ledger/Settle/User Controller - happy path + validation 400 + 권한 403 + 인증 401 검증: - ./gradlew clean build 성공 - 백엔드 단위테스트 94건 (common 33 + batch 19 + api 36 + 기타 6) 통과 - 프론트 tsc -b / vite build 통과 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,4 +10,5 @@ jar.enabled = false
|
||||
|
||||
dependencies {
|
||||
implementation project(':ga-core')
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ga.api.controller.settle;
|
||||
|
||||
import com.ga.api.service.transfer.TransferFileService;
|
||||
import com.ga.common.annotation.DataChangeLog;
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
@@ -7,6 +8,8 @@ import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.mapper.settle.PaymentMapper;
|
||||
import com.ga.core.vo.settle.PaymentResp;
|
||||
import com.ga.core.vo.settle.SettleSearchParam;
|
||||
import com.ga.core.vo.settle.TransferFileResp;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -20,6 +23,7 @@ import java.util.Map;
|
||||
public class PaymentController {
|
||||
|
||||
private final PaymentMapper mapper;
|
||||
private final TransferFileService transferFileService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(menu = "PAYMENT", perm = "READ")
|
||||
@@ -35,4 +39,14 @@ public class PaymentController {
|
||||
mapper.updateStatus(paymentId, body.get("status"), body.get("failReason"));
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "이체파일 생성", description = "정산월+은행코드 기준 PENDING 지급건을 이체파일로 생성하고 SENT 상태로 갱신")
|
||||
@PostMapping("/transfer-file")
|
||||
@RequirePermission(menu = "PAYMENT", perm = "EXPORT")
|
||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
||||
public ApiResponse<TransferFileResp> generateTransferFile(
|
||||
@RequestParam String settleMonth,
|
||||
@RequestParam String bankCode) {
|
||||
return ApiResponse.ok(transferFileService.generate(settleMonth, bankCode));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ga.api.service.transfer;
|
||||
|
||||
import com.ga.api.transfer.BankTransferFactory;
|
||||
import com.ga.api.transfer.BankTransferFileGenerator;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.file.FileService;
|
||||
import com.ga.common.util.EncryptUtil;
|
||||
import com.ga.core.mapper.settle.PaymentMapper;
|
||||
import com.ga.core.vo.settle.PaymentVO;
|
||||
import com.ga.core.vo.settle.TransferFileResp;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TransferFileService {
|
||||
|
||||
private final PaymentMapper paymentMapper;
|
||||
private final BankTransferFactory factory;
|
||||
private final FileService fileService;
|
||||
private final EncryptUtil encryptUtil;
|
||||
|
||||
@Transactional
|
||||
public TransferFileResp generate(String settleMonth, String bankCode) {
|
||||
// 1) 해당 정산월 + 은행의 PENDING payment 조회 (accountNo는 EncryptTypeHandler로 자동복호화됨)
|
||||
List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
|
||||
|
||||
if (payments.isEmpty()) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
|
||||
}
|
||||
|
||||
// 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태)
|
||||
BankTransferFileGenerator generator = factory.get(bankCode);
|
||||
byte[] fileContent = generator.generate(payments, settleMonth);
|
||||
|
||||
// 3) FileService로 file_storage 적재 → fileId 반환
|
||||
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
|
||||
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream");
|
||||
|
||||
// 4) payment들의 transfer_file_id 갱신 + status=SENT
|
||||
List<Long> paymentIds = payments.stream()
|
||||
.map(PaymentVO::getPaymentId)
|
||||
.collect(Collectors.toList());
|
||||
paymentMapper.updateTransferFile(paymentIds, fileId);
|
||||
|
||||
// 5) 합계 금액 계산
|
||||
BigDecimal totalAmount = payments.stream()
|
||||
.map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
return new TransferFileResp(fileId, fileName, payments.size(), totalAmount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ga.api.transfer;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 은행코드 → BankTransferFileGenerator 조회 팩토리.
|
||||
* 등록되지 않은 은행코드는 GENERIC_CSV 기본 생성기로 폴백.
|
||||
*/
|
||||
@Component
|
||||
public class BankTransferFactory {
|
||||
|
||||
private final Map<String, BankTransferFileGenerator> generators;
|
||||
|
||||
public BankTransferFactory(List<BankTransferFileGenerator> list) {
|
||||
this.generators = list.stream()
|
||||
.collect(Collectors.toMap(BankTransferFileGenerator::getBankCode, Function.identity()));
|
||||
}
|
||||
|
||||
public BankTransferFileGenerator get(String bankCode) {
|
||||
return generators.getOrDefault(bankCode, generators.get("GENERIC_CSV"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ga.api.transfer;
|
||||
|
||||
import com.ga.core.vo.settle.PaymentVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 은행별 이체파일 생성 인터페이스.
|
||||
* 새 은행 포맷 추가 시 이 인터페이스 구현체만 추가하면 됨.
|
||||
*/
|
||||
public interface BankTransferFileGenerator {
|
||||
|
||||
/** 은행 코드 ("088", "020" 등) 또는 기본 "GENERIC_CSV" */
|
||||
String getBankCode();
|
||||
|
||||
/** 파일 확장자 ".csv", ".dat" 등 */
|
||||
String getFileExtension();
|
||||
|
||||
/**
|
||||
* 이체파일 바이트 생성.
|
||||
* accountNo 는 PaymentVO 에서 이미 복호화된 상태로 전달됨.
|
||||
*/
|
||||
byte[] generate(List<PaymentVO> payments, String settleMonth);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ga.api.transfer;
|
||||
|
||||
import com.ga.core.vo.settle.PaymentVO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기본 CSV 이체파일 생성기. 은행별 전용 포맷이 없을 때 사용.
|
||||
* UTF-8 BOM + 헤더 + 데이터 행.
|
||||
*/
|
||||
@Component
|
||||
public class GenericCsvTransferGenerator implements BankTransferFileGenerator {
|
||||
|
||||
private static final byte[] UTF8_BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
|
||||
|
||||
@Override
|
||||
public String getBankCode() {
|
||||
return "GENERIC_CSV";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileExtension() {
|
||||
return ".csv";
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("정산월,은행코드,계좌번호,수취인,금액,적요\r\n");
|
||||
|
||||
for (PaymentVO p : payments) {
|
||||
sb.append(csv(settleMonth)).append(",");
|
||||
sb.append(csv(p.getBankCode())).append(",");
|
||||
sb.append(csv(p.getAccountNo())).append(","); // 이미 복호화된 값
|
||||
sb.append(csv(agentLabel(p))).append(",");
|
||||
sb.append(p.getPayAmount() != null ? p.getPayAmount().toPlainString() : "0").append(",");
|
||||
sb.append(csv("수수료 정산 " + formatMonth(settleMonth))).append("\r\n");
|
||||
}
|
||||
|
||||
byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
byte[] result = new byte[UTF8_BOM.length + body.length];
|
||||
System.arraycopy(UTF8_BOM, 0, result, 0, UTF8_BOM.length);
|
||||
System.arraycopy(body, 0, result, UTF8_BOM.length, body.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String csv(String val) {
|
||||
if (val == null) return "";
|
||||
if (val.contains(",") || val.contains("\"") || val.contains("\n")) {
|
||||
return "\"" + val.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private String agentLabel(PaymentVO p) {
|
||||
return p.getAgentId() != null ? "설계사#" + p.getAgentId() : "";
|
||||
}
|
||||
|
||||
/** "202501" → "2025-01" */
|
||||
private String formatMonth(String settleMonth) {
|
||||
if (settleMonth != null && settleMonth.length() == 6) {
|
||||
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
|
||||
}
|
||||
return settleMonth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ga.api.transfer;
|
||||
|
||||
import com.ga.core.vo.settle.PaymentVO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/**
|
||||
* KB국민은행(088) 고정폭 텍스트 이체파일 생성기.
|
||||
* 레코드 구조 (총 80바이트):
|
||||
* 은행코드(3) + 계좌번호(20) + 수취인명(20) + 금액(15, 우측정렬) + 적요(20) + CRLF(2)
|
||||
*/
|
||||
@Component
|
||||
public class KbBankTransferGenerator implements BankTransferFileGenerator {
|
||||
|
||||
private static final String BANK_CODE = "088";
|
||||
|
||||
@Override
|
||||
public String getBankCode() {
|
||||
return BANK_CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileExtension() {
|
||||
return ".dat";
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
for (PaymentVO p : payments) {
|
||||
BigDecimal amt = p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO;
|
||||
totalAmount = totalAmount.add(amt);
|
||||
|
||||
sb.append(rpad(BANK_CODE, 3));
|
||||
sb.append(rpad(nvl(p.getAccountNo()), 20)); // 복호화된 계좌번호
|
||||
sb.append(rpad("설계사#" + p.getAgentId(), 20));
|
||||
sb.append(lpad(amt.longValue(), 15));
|
||||
sb.append(rpad("수수료 정산 " + formatMonth(settleMonth), 20));
|
||||
sb.append("\r\n");
|
||||
}
|
||||
|
||||
// 합계 레코드
|
||||
sb.append(rpad("999", 3));
|
||||
sb.append(rpad("", 20));
|
||||
sb.append(rpad("합계", 20));
|
||||
sb.append(lpad(totalAmount.longValue(), 15));
|
||||
sb.append(rpad("TOTAL:" + payments.size() + "건", 20));
|
||||
sb.append("\r\n");
|
||||
|
||||
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String rpad(String val, int width) {
|
||||
if (val == null) val = "";
|
||||
if (val.length() >= width) return val.substring(0, width);
|
||||
return val + " ".repeat(width - val.length());
|
||||
}
|
||||
|
||||
private String lpad(long val, int width) {
|
||||
String s = String.valueOf(val);
|
||||
if (s.length() >= width) return s.substring(s.length() - width);
|
||||
return " ".repeat(width - s.length()) + s;
|
||||
}
|
||||
|
||||
private String nvl(String val) {
|
||||
return val != null ? val : "";
|
||||
}
|
||||
|
||||
private String formatMonth(String settleMonth) {
|
||||
if (settleMonth != null && settleMonth.length() == 6) {
|
||||
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
|
||||
}
|
||||
return settleMonth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.ga.api.controller.ledger;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.api.service.ledger.LedgerService;
|
||||
import com.ga.api.support.AbstractControllerTest;
|
||||
import com.ga.api.support.MockLoginUser;
|
||||
import com.ga.common.excel.ExcelService;
|
||||
import com.ga.common.menu.PermissionChecker;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.ledger.ExceptionLedgerResp;
|
||||
import com.ga.core.vo.ledger.ExceptionLedgerSaveReq;
|
||||
import com.ga.core.vo.ledger.LedgerResp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class LedgerControllerTest extends AbstractControllerTest {
|
||||
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@MockBean LedgerService service;
|
||||
@MockBean ExcelService excelService;
|
||||
@MockBean PermissionChecker permissionChecker;
|
||||
|
||||
@BeforeEach
|
||||
void permitAll() {
|
||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
||||
}
|
||||
|
||||
// ---- 픽스처 ----
|
||||
|
||||
private PageResponse<LedgerResp> singlePage() {
|
||||
LedgerResp r = new LedgerResp();
|
||||
r.setLedgerId(100L);
|
||||
r.setAgentName("홍길동");
|
||||
r.setPolicyNo("POL-001");
|
||||
return PageResponse.<LedgerResp>builder()
|
||||
.list(List.of(r))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ExceptionLedgerSaveReq validExceptionReq() {
|
||||
ExceptionLedgerSaveReq req = new ExceptionLedgerSaveReq();
|
||||
req.setAgentId(1L);
|
||||
req.setExceptionCode("EXTRA_PAY");
|
||||
req.setSettleMonth("202401");
|
||||
req.setAmount(new BigDecimal("50000"));
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- 테스트 ----
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/ledger/recruit - 200 모집 원장 목록")
|
||||
void listRecruit_ok() throws Exception {
|
||||
given(service.listRecruit(any())).willReturn(singlePage());
|
||||
|
||||
mockMvc.perform(get("/api/ledger/recruit").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.list[0].policyNo").value("POL-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/ledger/maintain - 200 유지 원장 목록")
|
||||
void listMaintain_ok() throws Exception {
|
||||
given(service.listMaintain(any())).willReturn(singlePage());
|
||||
|
||||
mockMvc.perform(get("/api/ledger/maintain").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.total").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/ledger/exception - 200 예외 원장 목록")
|
||||
void listException_ok() throws Exception {
|
||||
ExceptionLedgerResp er = new ExceptionLedgerResp();
|
||||
er.setLedgerId(200L);
|
||||
er.setExceptionCode("EXTRA_PAY");
|
||||
PageResponse<ExceptionLedgerResp> page = PageResponse.<ExceptionLedgerResp>builder()
|
||||
.list(List.of(er))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
given(service.listException(any())).willReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/ledger/exception").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.list[0].exceptionCode").value("EXTRA_PAY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/ledger/exception - 200 예외 원장 등록")
|
||||
void createException_ok() throws Exception {
|
||||
given(service.createException(any())).willReturn(200L);
|
||||
|
||||
mockMvc.perform(post("/api/ledger/exception")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validExceptionReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/ledger/exception - 400 필수값 누락 (agentId null)")
|
||||
void createException_validationFail() throws Exception {
|
||||
ExceptionLedgerSaveReq req = new ExceptionLedgerSaveReq();
|
||||
req.setExceptionCode("EXTRA_PAY");
|
||||
req.setSettleMonth("202401");
|
||||
req.setAmount(new BigDecimal("50000"));
|
||||
// agentId 없음
|
||||
|
||||
mockMvc.perform(post("/api/ledger/exception")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/ledger/exception/{id}/approve - 200 승인 처리")
|
||||
void approveException_ok() throws Exception {
|
||||
mockMvc.perform(put("/api/ledger/exception/200/approve")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("status", "APPROVED"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/ledger/recruit - 401 인증 없는 요청")
|
||||
void listRecruit_unauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/ledger/recruit"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.ga.api.controller.org;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.api.service.org.AgentService;
|
||||
import com.ga.api.support.AbstractControllerTest;
|
||||
import com.ga.api.support.MockLoginUser;
|
||||
import com.ga.common.excel.ExcelService;
|
||||
import com.ga.common.menu.PermissionChecker;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.org.AgentResp;
|
||||
import com.ga.core.vo.org.AgentSaveReq;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willDoNothing;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class AgentControllerTest extends AbstractControllerTest {
|
||||
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@MockBean AgentService agentService;
|
||||
@MockBean ExcelService excelService;
|
||||
@MockBean PermissionChecker permissionChecker;
|
||||
|
||||
@BeforeEach
|
||||
void permitAll() {
|
||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
||||
}
|
||||
|
||||
// ---- 픽스처 ----
|
||||
|
||||
private AgentResp sampleAgent() {
|
||||
AgentResp r = new AgentResp();
|
||||
r.setAgentId(1L);
|
||||
r.setAgentName("홍길동");
|
||||
r.setStatus("ACTIVE");
|
||||
return r;
|
||||
}
|
||||
|
||||
private AgentSaveReq validReq() {
|
||||
AgentSaveReq req = new AgentSaveReq();
|
||||
req.setAgentName("홍길동");
|
||||
req.setOrgId(1L);
|
||||
req.setGradeId(1L);
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- 테스트 ----
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/agents - 200 목록 반환")
|
||||
void list_ok() throws Exception {
|
||||
PageResponse<AgentResp> page = PageResponse.<AgentResp>builder()
|
||||
.list(List.of(sampleAgent()))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
given(agentService.list(any())).willReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].agentName").value("홍길동"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/agents/{id} - 200 상세 반환")
|
||||
void detail_ok() throws Exception {
|
||||
given(agentService.detail(1L)).willReturn(sampleAgent());
|
||||
|
||||
mockMvc.perform(get("/api/agents/1").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.agentId").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/agents - 200 등록 성공")
|
||||
void create_ok() throws Exception {
|
||||
given(agentService.create(any())).willReturn(1L);
|
||||
|
||||
mockMvc.perform(post("/api/agents")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/agents - 400 필수값 누락 (agentName null)")
|
||||
void create_validationFail() throws Exception {
|
||||
AgentSaveReq req = new AgentSaveReq();
|
||||
req.setOrgId(1L);
|
||||
req.setGradeId(1L);
|
||||
// agentName 없음
|
||||
|
||||
mockMvc.perform(post("/api/agents")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/agents/{id} - 200 수정 성공")
|
||||
void update_ok() throws Exception {
|
||||
willDoNothing().given(agentService).update(eq(1L), any());
|
||||
|
||||
mockMvc.perform(put("/api/agents/1")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/agents - 401 인증 없는 요청")
|
||||
void list_unauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/agents"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/agents - 403 권한 없는 요청")
|
||||
void list_forbidden() throws Exception {
|
||||
given(permissionChecker.hasPermission(anyLong(), eq("ORG_AGENT"), eq("READ"))).willReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.ga.api.controller.product;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.api.service.product.ContractService;
|
||||
import com.ga.api.support.AbstractControllerTest;
|
||||
import com.ga.api.support.MockLoginUser;
|
||||
import com.ga.common.menu.PermissionChecker;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.product.ContractResp;
|
||||
import com.ga.core.vo.product.ContractSaveReq;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willDoNothing;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class ContractControllerTest extends AbstractControllerTest {
|
||||
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@MockBean ContractService service;
|
||||
@MockBean PermissionChecker permissionChecker;
|
||||
|
||||
@BeforeEach
|
||||
void permitAll() {
|
||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
||||
}
|
||||
|
||||
// ---- 픽스처 ----
|
||||
|
||||
private ContractResp sampleContract() {
|
||||
ContractResp r = new ContractResp();
|
||||
r.setContractId(10L);
|
||||
r.setPolicyNo("POL-2024-001");
|
||||
r.setContractorName("김계약");
|
||||
r.setStatus("ACTIVE");
|
||||
return r;
|
||||
}
|
||||
|
||||
private ContractSaveReq validReq() {
|
||||
ContractSaveReq req = new ContractSaveReq();
|
||||
req.setAgentId(1L);
|
||||
req.setProductId(2L);
|
||||
req.setPolicyNo("POL-2024-002");
|
||||
req.setPremium(new BigDecimal("100000"));
|
||||
req.setContractDate(LocalDate.of(2024, 1, 1));
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- 테스트 ----
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/contracts - 200 목록 반환")
|
||||
void list_ok() throws Exception {
|
||||
PageResponse<ContractResp> page = PageResponse.<ContractResp>builder()
|
||||
.list(List.of(sampleContract()))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
given(service.list(any())).willReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/contracts").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].policyNo").value("POL-2024-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/contracts/{id} - 200 상세 반환")
|
||||
void detail_ok() throws Exception {
|
||||
given(service.detail(10L)).willReturn(sampleContract());
|
||||
|
||||
mockMvc.perform(get("/api/contracts/10").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.contractId").value(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/contracts - 200 등록 성공")
|
||||
void create_ok() throws Exception {
|
||||
given(service.create(any())).willReturn(10L);
|
||||
|
||||
mockMvc.perform(post("/api/contracts")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/contracts - 400 policyNo 누락")
|
||||
void create_missingPolicyNo() throws Exception {
|
||||
ContractSaveReq req = new ContractSaveReq();
|
||||
req.setAgentId(1L);
|
||||
req.setProductId(2L);
|
||||
req.setPremium(new BigDecimal("100000"));
|
||||
req.setContractDate(LocalDate.of(2024, 1, 1));
|
||||
// policyNo 없음
|
||||
|
||||
mockMvc.perform(post("/api/contracts")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/contracts/{id} - 200 수정 성공")
|
||||
void update_ok() throws Exception {
|
||||
willDoNothing().given(service).update(eq(10L), any());
|
||||
|
||||
mockMvc.perform(put("/api/contracts/10")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/contracts - 401 인증 없는 요청")
|
||||
void list_unauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/contracts"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/contracts - 403 권한 없는 요청")
|
||||
void list_forbidden() throws Exception {
|
||||
given(permissionChecker.hasPermission(anyLong(), eq("CONTRACT_LIST"), eq("READ"))).willReturn(false);
|
||||
|
||||
mockMvc.perform(get("/api/contracts").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.ga.api.controller.settle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.api.service.settle.SettleService;
|
||||
import com.ga.api.support.AbstractControllerTest;
|
||||
import com.ga.api.support.MockLoginUser;
|
||||
import com.ga.common.menu.PermissionChecker;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.settle.SettleMasterResp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willDoNothing;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class SettleControllerTest extends AbstractControllerTest {
|
||||
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@MockBean SettleService service;
|
||||
@MockBean PermissionChecker permissionChecker;
|
||||
|
||||
@BeforeEach
|
||||
void permitAll() {
|
||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
||||
}
|
||||
|
||||
// ---- 픽스처 ----
|
||||
|
||||
private SettleMasterResp sampleSettle() {
|
||||
SettleMasterResp r = new SettleMasterResp();
|
||||
r.setSettleId(50L);
|
||||
r.setAgentId(1L);
|
||||
r.setAgentName("홍길동");
|
||||
r.setSettleMonth("202401");
|
||||
r.setStatus("CALCULATED");
|
||||
r.setNetAmount(new BigDecimal("300000"));
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- 테스트 ----
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/settle - 200 정산 목록 반환")
|
||||
void list_ok() throws Exception {
|
||||
PageResponse<SettleMasterResp> page = PageResponse.<SettleMasterResp>builder()
|
||||
.list(List.of(sampleSettle()))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
given(service.list(any())).willReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/settle").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.list[0].settleMonth").value("202401"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/settle/summary - 200 월별 요약")
|
||||
void summary_ok() throws Exception {
|
||||
given(service.summary("202401")).willReturn(
|
||||
Map.of("totalAmount", 1000000, "agentCount", 5));
|
||||
|
||||
mockMvc.perform(get("/api/settle/summary")
|
||||
.param("settleMonth", "202401")
|
||||
.with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.agentCount").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/settle/{agentId}/months/{settleMonth} - 200 상세")
|
||||
void detail_ok() throws Exception {
|
||||
given(service.detail(1L, "202401")).willReturn(sampleSettle());
|
||||
|
||||
mockMvc.perform(get("/api/settle/1/months/202401").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.settleId").value(50));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/settle/{id}/confirm - 200 확정 처리")
|
||||
void confirm_ok() throws Exception {
|
||||
willDoNothing().given(service).confirm(50L);
|
||||
|
||||
mockMvc.perform(put("/api/settle/50/confirm").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/settle/{id}/hold - 200 보류 처리")
|
||||
void hold_ok() throws Exception {
|
||||
willDoNothing().given(service).hold(eq(50L), anyString());
|
||||
|
||||
mockMvc.perform(put("/api/settle/50/hold")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("reason", "검토 필요"))))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/settle/{id}/confirm - 403 APPROVE 권한 없음")
|
||||
void confirm_forbidden() throws Exception {
|
||||
given(permissionChecker.hasPermission(anyLong(), eq("SETTLE_LIST"), eq("APPROVE"))).willReturn(false);
|
||||
|
||||
mockMvc.perform(put("/api/settle/50/confirm").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/settle - 401 인증 없는 요청")
|
||||
void list_unauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/settle"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.ga.api.controller.system;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.api.service.system.UserService;
|
||||
import com.ga.api.support.AbstractControllerTest;
|
||||
import com.ga.api.support.MockLoginUser;
|
||||
import com.ga.common.menu.PermissionChecker;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.user.UserResp;
|
||||
import com.ga.core.vo.user.UserSaveReq;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willDoNothing;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class UserControllerTest extends AbstractControllerTest {
|
||||
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@MockBean UserService service;
|
||||
@MockBean PermissionChecker permissionChecker;
|
||||
|
||||
@BeforeEach
|
||||
void permitAll() {
|
||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
||||
}
|
||||
|
||||
// ---- 픽스처 ----
|
||||
|
||||
private UserResp sampleUser() {
|
||||
UserResp r = new UserResp();
|
||||
r.setUserId(99L);
|
||||
r.setLoginId("admin");
|
||||
r.setUserName("관리자");
|
||||
r.setStatus("ACTIVE");
|
||||
return r;
|
||||
}
|
||||
|
||||
private UserSaveReq validReq() {
|
||||
UserSaveReq req = new UserSaveReq();
|
||||
req.setLoginId("newuser");
|
||||
req.setUserName("신규사용자");
|
||||
req.setEmail("new@example.com");
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- 테스트 ----
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/system/users - 200 사용자 목록")
|
||||
void list_ok() throws Exception {
|
||||
PageResponse<UserResp> page = PageResponse.<UserResp>builder()
|
||||
.list(List.of(sampleUser()))
|
||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
||||
.build();
|
||||
given(service.list(any())).willReturn(page);
|
||||
|
||||
mockMvc.perform(get("/api/system/users").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.list[0].loginId").value("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/system/users/{id} - 200 사용자 상세")
|
||||
void detail_ok() throws Exception {
|
||||
given(service.detail(99L)).willReturn(sampleUser());
|
||||
|
||||
mockMvc.perform(get("/api/system/users/99").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.userId").value(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/system/users - 200 사용자 등록 성공")
|
||||
void create_ok() throws Exception {
|
||||
given(service.create(any())).willReturn(99L);
|
||||
|
||||
mockMvc.perform(post("/api/system/users")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data").value(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/system/users - 400 loginId 누락")
|
||||
void create_missingLoginId() throws Exception {
|
||||
UserSaveReq req = new UserSaveReq();
|
||||
req.setUserName("신규사용자");
|
||||
// loginId 없음
|
||||
|
||||
mockMvc.perform(post("/api/system/users")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/system/users/{id}/reset-password - 200 비밀번호 초기화")
|
||||
void resetPassword_ok() throws Exception {
|
||||
willDoNothing().given(service).resetPassword(99L);
|
||||
|
||||
mockMvc.perform(put("/api/system/users/99/reset-password").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /api/system/users/{id}/unlock - 200 계정 잠금 해제")
|
||||
void unlock_ok() throws Exception {
|
||||
willDoNothing().given(service).unlock(99L);
|
||||
|
||||
mockMvc.perform(put("/api/system/users/99/unlock").with(MockLoginUser.admin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET /api/system/users - 401 인증 없는 요청")
|
||||
void list_unauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/system/users"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/system/users - 403 권한 없는 요청")
|
||||
void create_forbidden() throws Exception {
|
||||
given(permissionChecker.hasPermission(anyLong(), eq("SYSTEM_USER"), eq("CREATE"))).willReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/system/users")
|
||||
.with(MockLoginUser.admin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(validReq())))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ga.api.support;
|
||||
|
||||
import com.ga.common.auth.JwtAuthFilter;
|
||||
import com.ga.common.auth.JwtTokenProvider;
|
||||
import com.ga.common.code.CommonCodeMapper;
|
||||
import com.ga.common.file.FileMapper;
|
||||
import com.ga.common.menu.MenuMapper;
|
||||
import com.ga.common.notification.NotificationMapper;
|
||||
import com.ga.common.system.ApiAccessLogService;
|
||||
import com.ga.common.system.DataChangeLogService;
|
||||
import com.ga.common.system.SystemConfigMapper;
|
||||
import com.ga.common.system.SystemLogMapper;
|
||||
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
|
||||
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
||||
import com.ga.core.mapper.org.AgentMapper;
|
||||
import com.ga.core.mapper.org.GradeMapper;
|
||||
import com.ga.core.mapper.org.OrganizationMapper;
|
||||
import com.ga.core.mapper.product.ContractMapper;
|
||||
import com.ga.core.mapper.product.InsuranceCompanyMapper;
|
||||
import com.ga.core.mapper.product.ProductMapper;
|
||||
import com.ga.core.mapper.receive.ReceiveMapper;
|
||||
import com.ga.core.mapper.rule.RuleMapper;
|
||||
import com.ga.core.mapper.settle.BatchJobLogMapper;
|
||||
import com.ga.core.mapper.settle.ChargebackMapper;
|
||||
import com.ga.core.mapper.settle.OverrideSettleMapper;
|
||||
import com.ga.core.mapper.settle.PaymentMapper;
|
||||
import com.ga.core.mapper.settle.ReconciliationMapper;
|
||||
import com.ga.core.mapper.settle.SettleMasterMapper;
|
||||
import com.ga.core.mapper.upload.UploadColumnMapper;
|
||||
import com.ga.core.mapper.upload.UploadHistoryMapper;
|
||||
import com.ga.core.mapper.upload.UploadTemplateMapper;
|
||||
import com.ga.core.mapper.user.RoleMapper;
|
||||
import com.ga.core.mapper.user.UserMapper;
|
||||
import com.ga.api.service.upload.LookupMapper;
|
||||
import com.ga.api.service.upload.DynamicInsertMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
/**
|
||||
* Controller 테스트 베이스.
|
||||
*
|
||||
* @SpringBootTest + @AutoConfigureMockMvc 로 전체 Spring MVC 컨텍스트 사용.
|
||||
* application-test.yml 로 DB/Redis/Cache 자동설정 제외.
|
||||
* 모든 MyBatis Mapper 를 @MockBean 으로 등록하여 SqlSessionFactory 없이 동작.
|
||||
*
|
||||
* 각 테스트는:
|
||||
* - 사용할 Service 를 @MockBean 으로 추가
|
||||
* - PermissionChecker 를 @MockBean 으로 추가 후 hasPermission() stub
|
||||
* - MockLoginUser.admin() 으로 인증된 요청 실행
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestSecurityConfig.class)
|
||||
public abstract class AbstractControllerTest {
|
||||
|
||||
@Autowired protected MockMvc mockMvc;
|
||||
|
||||
// ---- 인증 ----
|
||||
@MockBean protected JwtAuthFilter jwtAuthFilter;
|
||||
@MockBean protected JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
/**
|
||||
* JwtAuthFilter 는 OncePerRequestFilter 를 상속한다.
|
||||
* @MockBean 으로 교체하면 doFilter() 가 chain.doFilter() 를 호출하지 않아
|
||||
* 요청이 DispatcherServlet 에 도달하지 못한다.
|
||||
* 이를 해결하기 위해 public doFilter() 를 pass-through 로 stub 한다.
|
||||
*/
|
||||
@BeforeEach
|
||||
void stubJwtFilter() throws Exception {
|
||||
doAnswer(inv -> {
|
||||
jakarta.servlet.ServletRequest req = inv.getArgument(0);
|
||||
jakarta.servlet.ServletResponse res = inv.getArgument(1);
|
||||
FilterChain chain = inv.getArgument(2);
|
||||
chain.doFilter(req, res);
|
||||
return null;
|
||||
}).when(jwtAuthFilter).doFilter(
|
||||
any(jakarta.servlet.ServletRequest.class),
|
||||
any(jakarta.servlet.ServletResponse.class),
|
||||
any(FilterChain.class));
|
||||
}
|
||||
|
||||
// ---- 로그 서비스 ----
|
||||
@MockBean protected DataChangeLogService dataChangeLogService;
|
||||
@MockBean protected ApiAccessLogService apiAccessLogService;
|
||||
|
||||
// ---- 공통 Mapper ----
|
||||
@MockBean protected CommonCodeMapper commonCodeMapper;
|
||||
@MockBean protected FileMapper fileMapper;
|
||||
@MockBean protected MenuMapper menuMapper;
|
||||
@MockBean protected NotificationMapper notificationMapper;
|
||||
@MockBean protected SystemConfigMapper systemConfigMapper;
|
||||
@MockBean protected SystemLogMapper systemLogMapper;
|
||||
|
||||
// ---- 도메인 Mapper ----
|
||||
@MockBean protected AgentMapper agentMapper;
|
||||
@MockBean protected GradeMapper gradeMapper;
|
||||
@MockBean protected OrganizationMapper organizationMapper;
|
||||
@MockBean protected ContractMapper contractMapper;
|
||||
@MockBean protected InsuranceCompanyMapper insuranceCompanyMapper;
|
||||
@MockBean protected ProductMapper productMapper;
|
||||
@MockBean protected RecruitLedgerMapper recruitLedgerMapper;
|
||||
@MockBean protected MaintainLedgerMapper maintainLedgerMapper;
|
||||
@MockBean protected ExceptionLedgerMapper exceptionLedgerMapper;
|
||||
@MockBean protected ReceiveMapper receiveMapper;
|
||||
@MockBean protected RuleMapper ruleMapper;
|
||||
@MockBean protected SettleMasterMapper settleMasterMapper;
|
||||
@MockBean protected PaymentMapper paymentMapper;
|
||||
@MockBean protected ChargebackMapper chargebackMapper;
|
||||
@MockBean protected OverrideSettleMapper overrideSettleMapper;
|
||||
@MockBean protected ReconciliationMapper reconciliationMapper;
|
||||
@MockBean protected BatchJobLogMapper batchJobLogMapper;
|
||||
@MockBean protected UploadHistoryMapper uploadHistoryMapper;
|
||||
@MockBean protected UploadTemplateMapper uploadTemplateMapper;
|
||||
@MockBean protected UploadColumnMapper uploadColumnMapper;
|
||||
@MockBean protected UserMapper userMapper;
|
||||
@MockBean protected RoleMapper roleMapper;
|
||||
|
||||
// ---- ga-api 내부 Mapper ----
|
||||
@MockBean protected LookupMapper lookupMapper;
|
||||
@MockBean protected DynamicInsertMapper dynamicInsertMapper;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ga.api.support;
|
||||
|
||||
import com.ga.common.auth.LoginUser;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 테스트에서 LoginUser 를 SecurityContext 에 직접 주입하는 헬퍼.
|
||||
*
|
||||
* 사용 예:
|
||||
* mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
|
||||
*/
|
||||
public final class MockLoginUser {
|
||||
|
||||
private MockLoginUser() {}
|
||||
|
||||
/** userId=1, roleCode=SUPER_ADMIN 인 관리자 사용자 */
|
||||
public static RequestPostProcessor admin() {
|
||||
return asUser(1L, "admin", List.of("SUPER_ADMIN"));
|
||||
}
|
||||
|
||||
public static RequestPostProcessor asUser(Long userId, String loginId, List<String> roles) {
|
||||
LoginUser loginUser = LoginUser.builder()
|
||||
.userId(userId)
|
||||
.loginId(loginId)
|
||||
.userName("테스트사용자")
|
||||
.roleCodes(roles)
|
||||
.build();
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
loginUser, null, loginUser.getAuthorities());
|
||||
return SecurityMockMvcRequestPostProcessors.authentication(auth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ga.api.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* 테스트용 보안 설정.
|
||||
* - JWT 필터 없이 인증 여부만 체크
|
||||
* - 미인증 요청 → 401
|
||||
* - WebMvcConfig 를 포함하지 않으므로 Jackson 기본 설정 사용
|
||||
*/
|
||||
@TestConfiguration
|
||||
public class TestSecurityConfig {
|
||||
|
||||
/**
|
||||
* JavaTimeModule 포함한 ObjectMapper. WebMvcConfig 대신 사용.
|
||||
* @WebMvcTest 에서 LocalDateTime 직렬화 실패를 방지한다.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public ObjectMapper testObjectMapper() {
|
||||
return new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
|
||||
/**
|
||||
* SecurityConfig.filter() 를 오버라이드한다.
|
||||
* spring.main.allow-bean-definition-overriding=true 가 필요하다.
|
||||
*/
|
||||
@Bean("filter")
|
||||
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((req, res, ex) ->
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")))
|
||||
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
spring:
|
||||
application:
|
||||
name: ga-api-test
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
||||
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
||||
|
||||
ga:
|
||||
jwt:
|
||||
secret: test-secret-key-for-unit-tests-only-256bits-long-placeholder
|
||||
access-token-expire: 7200000
|
||||
refresh-token-expire: 604800000
|
||||
encrypt:
|
||||
key: 0123456789abcdef0123456789abcdef
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath*:mapper/**/*.xml
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ga: WARN
|
||||
org.springframework.security: WARN
|
||||
org.springframework.web: WARN
|
||||
@@ -65,6 +65,39 @@ public class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* byte[] 콘텐츠를 파일로 저장하고 fileId를 반환한다.
|
||||
* MultipartFile 없이 프로그래밍 방식으로 파일을 생성할 때 사용.
|
||||
*/
|
||||
@Transactional
|
||||
public Long saveBytes(byte[] content, String fileName, String fileGroup, String contentType) {
|
||||
String ext = extension(fileName);
|
||||
String stored = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
|
||||
String relPath = LocalDate.now().toString().replace("-", "/");
|
||||
Path dir = Paths.get(uploadDir, relPath);
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Path dest = dir.resolve(stored);
|
||||
Files.write(dest, content);
|
||||
|
||||
FileVO vo = new FileVO();
|
||||
vo.setFileGroup(fileGroup);
|
||||
vo.setOriginalName(fileName);
|
||||
vo.setStoredName(stored);
|
||||
vo.setFilePath(dest.toAbsolutePath().toString());
|
||||
vo.setFileSize((long) content.length);
|
||||
vo.setContentType(contentType);
|
||||
vo.setExtension(ext);
|
||||
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||
vo.setCreatedAt(java.time.LocalDateTime.now());
|
||||
mapper.insert(vo);
|
||||
return vo.getFileId();
|
||||
} catch (IOException e) {
|
||||
log.error("File save error", e);
|
||||
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public FileVO download(Long fileId) {
|
||||
FileVO vo = mapper.selectById(fileId);
|
||||
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
|
||||
|
||||
@@ -19,4 +19,7 @@ public interface PaymentMapper {
|
||||
@Param("failReason") String failReason);
|
||||
int updateTransferFile(@Param("paymentIds") List<Long> paymentIds,
|
||||
@Param("transferFileId") Long transferFileId);
|
||||
|
||||
List<PaymentVO> selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth,
|
||||
@Param("bankCode") String bankCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ga.core.vo.settle;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TransferFileResp {
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
private int paymentCount;
|
||||
private BigDecimal totalAmount;
|
||||
}
|
||||
@@ -69,6 +69,18 @@
|
||||
WHERE payment_id = #{paymentId}
|
||||
</update>
|
||||
|
||||
<select id="selectPendingByMonthAndBank" resultMap="VOMap">
|
||||
SELECT p.*
|
||||
FROM payment p
|
||||
JOIN settle_master s ON s.settle_id = p.settle_id
|
||||
WHERE p.pay_status = 'PENDING'
|
||||
AND s.settle_month = #{settleMonth}
|
||||
<if test="bankCode != null and bankCode != '' and bankCode != 'GENERIC_CSV'">
|
||||
AND p.bank_code = #{bankCode}
|
||||
</if>
|
||||
ORDER BY p.payment_id
|
||||
</select>
|
||||
|
||||
<update id="updateTransferFile">
|
||||
UPDATE payment SET transfer_file_id = #{transferFileId}, pay_status = 'SENT', updated_at = NOW()
|
||||
WHERE payment_id IN
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface BatchRunResp {
|
||||
jobLogId: number;
|
||||
}
|
||||
|
||||
export interface BatchJobLogResp {
|
||||
jobLogId: number;
|
||||
jobName: string;
|
||||
status: 'REQUESTED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
|
||||
stepName?: string;
|
||||
totalCount?: number;
|
||||
successCount?: number;
|
||||
errorCount?: number;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationMs?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface BatchHistoryResp {
|
||||
jobLogId: number;
|
||||
jobName: string;
|
||||
status: string;
|
||||
stepName?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationMs?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const batchRunApi = {
|
||||
run: (settleMonth: string, companyCode?: string) =>
|
||||
unwrap<number>(
|
||||
api.post('/api/batch/settlement/run', null, {
|
||||
params: { settleMonth, companyCode },
|
||||
}),
|
||||
),
|
||||
status: (jobLogId: number) =>
|
||||
unwrap<BatchJobLogResp>(
|
||||
api.get('/api/batch/settlement/status', { params: { jobLogId } }),
|
||||
),
|
||||
history: (size = 10) =>
|
||||
unwrap<BatchHistoryResp[]>(
|
||||
api.get('/api/batch/settlement/history', { params: { size } }),
|
||||
),
|
||||
};
|
||||
@@ -12,6 +12,17 @@ export interface CompanyResp {
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
export interface CompanySaveReq {
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
companyType?: string;
|
||||
bizNo?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
@@ -22,4 +33,8 @@ export interface CompanySearchParam {
|
||||
export const companyApi = {
|
||||
list: (p: CompanySearchParam) =>
|
||||
unwrap<PageResponse<CompanyResp>>(api.get('/api/companies', { params: p })),
|
||||
create: (req: CompanySaveReq) =>
|
||||
unwrap<number>(api.post('/api/companies', req)),
|
||||
update: (id: number, req: CompanySaveReq) =>
|
||||
unwrap<void>(api.put(`/api/companies/${id}`, req)),
|
||||
};
|
||||
|
||||
@@ -14,6 +14,14 @@ export interface PaymentResp {
|
||||
payStatusName?: string;
|
||||
failReason?: string;
|
||||
settleMonth: string;
|
||||
transferFileId?: number;
|
||||
}
|
||||
|
||||
export interface TransferFileResp {
|
||||
fileId: number;
|
||||
fileName: string;
|
||||
paymentCount: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export const paymentApi = {
|
||||
@@ -21,4 +29,6 @@ export const paymentApi = {
|
||||
unwrap<PageResponse<PaymentResp>>(api.get('/api/payments', { params: p })),
|
||||
updateStatus: (paymentId: number, status: string, failReason?: string) =>
|
||||
unwrap<void>(api.put(`/api/payments/${paymentId}/status`, { status, failReason })),
|
||||
generateTransferFile: (settleMonth: string, bankCode: string) =>
|
||||
unwrap<TransferFileResp>(api.post('/api/payments/transfer-file', null, { params: { settleMonth, bankCode } })),
|
||||
};
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface ProductResp {
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface ProductSaveReq {
|
||||
productCode: string;
|
||||
productName: string;
|
||||
companyId: number;
|
||||
insuranceType: string;
|
||||
productGroup?: string;
|
||||
payPeriodType?: string;
|
||||
launchDate?: string;
|
||||
endDate?: string;
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
export interface ProductSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
@@ -26,4 +38,10 @@ export interface ProductSearchParam {
|
||||
export const productApi = {
|
||||
list: (p: ProductSearchParam) =>
|
||||
unwrap<PageResponse<ProductResp>>(api.get('/api/products', { params: p })),
|
||||
create: (req: ProductSaveReq) =>
|
||||
unwrap<number>(api.post('/api/products', req)),
|
||||
update: (id: number, req: ProductSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/products/${id}`, req)),
|
||||
remove: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/products/${id}`)),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,16 @@ export interface CommissionRateResp {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CommissionRateSaveReq {
|
||||
productId: number;
|
||||
commissionYear: number;
|
||||
ratePct: number;
|
||||
payMethodCond?: string;
|
||||
minPremium?: number;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface PayoutRuleResp {
|
||||
ruleId: number;
|
||||
gradeId: number;
|
||||
@@ -27,6 +37,17 @@ export interface PayoutRuleResp {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface PayoutRuleSaveReq {
|
||||
gradeId: number;
|
||||
insuranceType: string;
|
||||
commissionYear: number;
|
||||
payoutPct: number;
|
||||
payMethodCond?: string;
|
||||
performanceGrade?: string;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface OverrideRuleResp {
|
||||
overrideId: number;
|
||||
fromGrade: number;
|
||||
@@ -41,6 +62,17 @@ export interface OverrideRuleResp {
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface OverrideRuleSaveReq {
|
||||
fromGrade: number;
|
||||
toGrade: number;
|
||||
overridePct: number;
|
||||
calcType?: string;
|
||||
insuranceTypeCond?: string;
|
||||
capAmount?: number;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface ChargebackRuleResp {
|
||||
cbRuleId: number;
|
||||
companyCode: string;
|
||||
@@ -55,6 +87,19 @@ export interface ChargebackRuleResp {
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface ChargebackRuleSaveReq {
|
||||
companyCode: string;
|
||||
insuranceType: string;
|
||||
lapseMonthFrom: number;
|
||||
lapseMonthTo: number;
|
||||
cbRate: number;
|
||||
includeOverride: string;
|
||||
installmentAllowed: string;
|
||||
maxInstallments?: number;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionCodeResp {
|
||||
exceptionCode: string;
|
||||
exceptionName: string;
|
||||
@@ -67,13 +112,57 @@ export interface ExceptionCodeResp {
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
export interface ExceptionCodeSaveReq {
|
||||
exceptionCode: string;
|
||||
exceptionName: string;
|
||||
category?: string;
|
||||
direction: string;
|
||||
autoCalcYn: string;
|
||||
approvalRequired: string;
|
||||
taxIncluded: string;
|
||||
description?: string;
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
export const ruleApi = {
|
||||
commissionRates: (params?: { productId?: number; year?: number }) =>
|
||||
unwrap<CommissionRateResp[]>(api.get('/api/rules/commission-rates', { params })),
|
||||
createCommissionRate: (req: CommissionRateSaveReq) =>
|
||||
unwrap<number>(api.post('/api/rules/commission-rates', req)),
|
||||
updateCommissionRate: (id: number, req: CommissionRateSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/rules/commission-rates/${id}`, req)),
|
||||
removeCommissionRate: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/rules/commission-rates/${id}`)),
|
||||
|
||||
payoutRules: (params?: { gradeId?: number; insuranceType?: string }) =>
|
||||
unwrap<PayoutRuleResp[]>(api.get('/api/rules/payout', { params })),
|
||||
createPayoutRule: (req: PayoutRuleSaveReq) =>
|
||||
unwrap<number>(api.post('/api/rules/payout', req)),
|
||||
updatePayoutRule: (id: number, req: PayoutRuleSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/rules/payout/${id}`, req)),
|
||||
removePayoutRule: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/rules/payout/${id}`)),
|
||||
|
||||
overrideRules: () => unwrap<OverrideRuleResp[]>(api.get('/api/rules/override')),
|
||||
createOverrideRule: (req: OverrideRuleSaveReq) =>
|
||||
unwrap<number>(api.post('/api/rules/override', req)),
|
||||
updateOverrideRule: (id: number, req: OverrideRuleSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/rules/override/${id}`, req)),
|
||||
removeOverrideRule: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/rules/override/${id}`)),
|
||||
|
||||
chargebackRules: (params?: { companyCode?: string }) =>
|
||||
unwrap<ChargebackRuleResp[]>(api.get('/api/rules/chargeback', { params })),
|
||||
createChargebackRule: (req: ChargebackRuleSaveReq) =>
|
||||
unwrap<number>(api.post('/api/rules/chargeback', req)),
|
||||
updateChargebackRule: (id: number, req: ChargebackRuleSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/rules/chargeback/${id}`, req)),
|
||||
removeChargebackRule: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/rules/chargeback/${id}`)),
|
||||
|
||||
exceptionCodes: () => unwrap<ExceptionCodeResp[]>(api.get('/api/rules/exception-codes')),
|
||||
createExceptionCode: (req: ExceptionCodeSaveReq) =>
|
||||
unwrap<void>(api.post('/api/rules/exception-codes', req)),
|
||||
updateExceptionCode: (code: string, req: ExceptionCodeSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/rules/exception-codes/${code}`, req)),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,62 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Switch, Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { companyApi, CompanyResp, CompanySearchParam } from '@/api/company';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { companyApi, CompanyResp, CompanySaveReq, CompanySearchParam } from '@/api/company';
|
||||
|
||||
const COMPANY_TYPE_OPTIONS = [
|
||||
{ value: 'LIFE', label: '생명보험' },
|
||||
{ value: 'NON_LIFE', label: '손해보험' },
|
||||
{ value: 'OTHER', label: '기타' },
|
||||
];
|
||||
|
||||
export default function CompanyList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
|
||||
const { data: editing } = useQuery({
|
||||
queryKey: ['company', 'detail', modal.editId],
|
||||
queryFn: async () => {
|
||||
// 목록에서 선택한 행 데이터를 editing으로 쓰기 위해 단건 조회가 없으면 목록에서 찾음
|
||||
// 백엔드 단건 엔드포인트가 없으므로 editRecord state로 대체
|
||||
return null;
|
||||
},
|
||||
enabled: false,
|
||||
});
|
||||
const [editRecord, setEditRecord] = useState<CompanyResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: CompanyResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.companyId });
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: CompanySaveReq = {
|
||||
...v,
|
||||
isActive: v.isActive ? 'Y' : 'N',
|
||||
};
|
||||
if (modal.editId) {
|
||||
await companyApi.update(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await companyApi.create(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<CompanyResp>[] = [
|
||||
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||
fieldProps: { placeholder: '회사명 / 사업자번호' } },
|
||||
@@ -16,15 +69,23 @@ export default function CompanyList() {
|
||||
{ title: '연락처', dataIndex: 'contactPhone', width: 130, search: false },
|
||||
{ title: '이메일', dataIndex: 'contactEmail', width: 200, search: false },
|
||||
{
|
||||
title: '활성', dataIndex: 'isActive', width: 80,
|
||||
title: '활성', dataIndex: 'isActive', width: 80, search: false,
|
||||
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
|
||||
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 100, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="PRODUCT_COMPANY" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="보험사 관리" description="제휴 보험사 정보 / 데이터 수신 프로파일">
|
||||
<ProTable<CompanyResp, CompanySearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="companyId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
@@ -35,8 +96,58 @@ export default function CompanyList() {
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="PRODUCT_COMPANY" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 보험사 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '보험사 수정' : '보험사 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
isActive: editRecord.isActive === 'Y',
|
||||
} : { isActive: true }}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="companyCode" label="회사코드" rules={[{ required: true }]}
|
||||
width="md" placeholder="SAMSUNG"
|
||||
disabled={!!modal.editId}
|
||||
/>
|
||||
<ProFormText
|
||||
name="companyName" label="회사명" rules={[{ required: true }]}
|
||||
width="md" placeholder="삼성생명"
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="companyType" label="구분" width="md"
|
||||
options={COMPANY_TYPE_OPTIONS}
|
||||
/>
|
||||
<ProFormText name="bizNo" label="사업자번호" width="md" placeholder="000-00-00000" />
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText name="contactName" label="담당자명" width="md" />
|
||||
<ProFormText name="contactPhone" label="연락처" width="md" placeholder="02-1234-5678" />
|
||||
</ProForm.Group>
|
||||
<ProFormText
|
||||
name="contactEmail" label="이메일" placeholder="contact@company.com"
|
||||
rules={[{ type: 'email', message: '이메일 형식이 아닙니다' }]}
|
||||
/>
|
||||
<ProForm.Item name="isActive" label="활성" valuePropName="checked">
|
||||
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
|
||||
</ProForm.Item>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,80 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Switch, Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { productApi, ProductResp, ProductSearchParam } from '@/api/product';
|
||||
import { productApi, ProductResp, ProductSaveReq, ProductSearchParam } from '@/api/product';
|
||||
import { companyApi } from '@/api/company';
|
||||
|
||||
const PAY_PERIOD_OPTIONS = [
|
||||
{ value: 'MONTHLY', label: '월납' },
|
||||
{ value: 'QUARTERLY', label: '분기납' },
|
||||
{ value: 'ANNUAL', label: '연납' },
|
||||
{ value: 'SINGLE', label: '일시납' },
|
||||
];
|
||||
|
||||
export default function ProductList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<ProductResp | null>(null);
|
||||
|
||||
const { data: companies } = useQuery({
|
||||
queryKey: ['companies', 'all'],
|
||||
queryFn: () => companyApi.list({ pageSize: 999 }),
|
||||
enabled: modal.open,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: ProductResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.productId });
|
||||
};
|
||||
|
||||
const onDelete = (record: ProductResp) => {
|
||||
Modal.confirm({
|
||||
title: '상품 삭제',
|
||||
content: `"${record.productName}" 상품을 삭제하시겠습니까?`,
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await productApi.remove(record.productId);
|
||||
message.success('삭제 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: ProductSaveReq = {
|
||||
...v,
|
||||
launchDate: v.launchDate ? dayjs(v.launchDate).format('YYYY-MM-DD') : undefined,
|
||||
endDate: v.endDate ? dayjs(v.endDate).format('YYYY-MM-DD') : undefined,
|
||||
isActive: v.isActive ? 'Y' : 'N',
|
||||
};
|
||||
if (modal.editId) {
|
||||
await productApi.update(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await productApi.create(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<ProductResp>[] = [
|
||||
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||
@@ -29,14 +97,24 @@ export default function ProductList() {
|
||||
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
|
||||
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="PRODUCT_PRODUCT" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
<PermissionButton key="del" menuCode="PRODUCT_PRODUCT" permCode="DELETE" size="small" type="link" danger
|
||||
onClick={() => onDelete(r)}>삭제</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="상품 관리" description="보험사별 보험 상품 / 보험종류 / 상품군">
|
||||
<ProTable<ProductResp, ProductSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="productId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1300 }}
|
||||
scroll={{ x: 1400 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...rest } = params as any;
|
||||
const res = await productApi.list({ ...rest, pageNum: current, pageSize });
|
||||
@@ -45,8 +123,66 @@ export default function ProductList() {
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="PRODUCT_PRODUCT" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 상품 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '상품 수정' : '상품 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
launchDate: editRecord.launchDate ? dayjs(editRecord.launchDate) : null,
|
||||
endDate: editRecord.endDate ? dayjs(editRecord.endDate) : null,
|
||||
isActive: editRecord.isActive === 'Y',
|
||||
} : { isActive: true }}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="productCode" label="상품코드" rules={[{ required: true }]}
|
||||
width="md" placeholder="PRD001"
|
||||
disabled={!!modal.editId}
|
||||
/>
|
||||
<ProFormText
|
||||
name="productName" label="상품명" rules={[{ required: true }]}
|
||||
width="md" placeholder="삼성 종신보험 플러스"
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="companyId" label="보험사" rules={[{ required: true }]} width="md" showSearch
|
||||
options={companies?.list.map((c) => ({ value: c.companyId, label: c.companyName })) ?? []}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
|
||||
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText name="productGroup" label="상품군" width="md" placeholder="종신/정기/건강" />
|
||||
<ProFormSelect
|
||||
name="payPeriodType" label="납입주기" width="md"
|
||||
options={PAY_PERIOD_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="launchDate" label="판매개시일" width="md" />
|
||||
<ProFormDatePicker name="endDate" label="판매종료일" width="md" />
|
||||
</ProForm.Group>
|
||||
<ProForm.Item name="isActive" label="활성" valuePropName="checked">
|
||||
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
|
||||
</ProForm.Item>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,68 @@
|
||||
import { Tag } from 'antd';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Switch, Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { ruleApi, ChargebackRuleResp } from '@/api/rule';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { ruleApi, ChargebackRuleResp, ChargebackRuleSaveReq } from '@/api/rule';
|
||||
|
||||
export default function ChargebackRuleList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<ChargebackRuleResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: ChargebackRuleResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.cbRuleId });
|
||||
};
|
||||
|
||||
const onDelete = (record: ChargebackRuleResp) => {
|
||||
Modal.confirm({
|
||||
title: '환수 규정 삭제',
|
||||
content: '해당 환수 규정을 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await ruleApi.removeChargebackRule(record.cbRuleId);
|
||||
message.success('삭제 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: ChargebackRuleSaveReq = {
|
||||
...v,
|
||||
includeOverride: v.includeOverride ? 'Y' : 'N',
|
||||
installmentAllowed: v.installmentAllowed ? 'Y' : 'N',
|
||||
effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (modal.editId) {
|
||||
await ruleApi.updateChargebackRule(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await ruleApi.createChargebackRule(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<ChargebackRuleResp>[] = [
|
||||
{ title: '보험사', dataIndex: 'companyCode', width: 100, search: false,
|
||||
render: (_, r) => <strong>{r.companyCode}</strong> },
|
||||
@@ -32,11 +89,21 @@ export default function ChargebackRuleList() {
|
||||
{ title: '최대 분할', dataIndex: 'maxInstallments', width: 90, align: 'right', search: false },
|
||||
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
|
||||
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="RULE_CHARGEBACK" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
<PermissionButton key="del" menuCode="RULE_CHARGEBACK" permCode="DELETE" size="small" type="link" danger
|
||||
onClick={() => onDelete(r)}>삭제</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="환수 규정" description="실효 계약 환수율 / 분할환수 정책">
|
||||
<ProTable<ChargebackRuleResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="cbRuleId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
@@ -46,8 +113,73 @@ export default function ChargebackRuleList() {
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="RULE_CHARGEBACK" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 환수 규정 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '환수 규정 수정' : '환수 규정 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
includeOverride: editRecord.includeOverride === 'Y',
|
||||
installmentAllowed: editRecord.installmentAllowed === 'Y',
|
||||
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
|
||||
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
|
||||
} : { includeOverride: false, installmentAllowed: false }}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="companyCode" label="보험사코드" rules={[{ required: true }]}
|
||||
width="md" placeholder="SAMSUNG"
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
|
||||
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="lapseMonthFrom" label="실효 시작월" rules={[{ required: true }]} width="md"
|
||||
min={1} max={120}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="lapseMonthTo" label="실효 종료월" rules={[{ required: true }]} width="md"
|
||||
min={1} max={120}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="cbRate" label="환수율(%)" rules={[{ required: true }]} width="md"
|
||||
min={0} max={100} fieldProps={{ precision: 4 }}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="maxInstallments" label="최대 분할횟수" width="md" min={1} max={36}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProForm.Item name="includeOverride" label="오버라이드 포함" valuePropName="checked">
|
||||
<Switch checkedChildren="포함" unCheckedChildren="제외" />
|
||||
</ProForm.Item>
|
||||
<ProForm.Item name="installmentAllowed" label="분할환수 허용" valuePropName="checked" style={{ marginLeft: 32 }}>
|
||||
<Switch checkedChildren="허용" unCheckedChildren="불가" />
|
||||
</ProForm.Item>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
|
||||
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,73 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { ruleApi, CommissionRateResp } from '@/api/rule';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { ruleApi, CommissionRateResp, CommissionRateSaveReq } from '@/api/rule';
|
||||
import { productApi } from '@/api/product';
|
||||
|
||||
const GRADE_OPTIONS = [1, 2, 3, 4, 5].map((y) => ({ value: y, label: `${y}년차` }));
|
||||
|
||||
export default function CommissionRateList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<CommissionRateResp | null>(null);
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ['products', 'all'],
|
||||
queryFn: () => productApi.list({ pageSize: 999 }),
|
||||
enabled: modal.open,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: CommissionRateResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.rateId });
|
||||
};
|
||||
|
||||
const onDelete = (record: CommissionRateResp) => {
|
||||
Modal.confirm({
|
||||
title: '수수료율 삭제',
|
||||
content: '해당 수수료율을 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await ruleApi.removeCommissionRate(record.rateId);
|
||||
message.success('삭제 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: CommissionRateSaveReq = {
|
||||
...v,
|
||||
effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (modal.editId) {
|
||||
await ruleApi.updateCommissionRate(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await ruleApi.createCommissionRate(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<CommissionRateResp>[] = [
|
||||
{ title: '상품ID', dataIndex: 'productId', width: 90 },
|
||||
{ title: '상품명', dataIndex: 'productName', width: 200, search: false },
|
||||
@@ -20,11 +84,21 @@ export default function CommissionRateList() {
|
||||
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
|
||||
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="RULE_COMMISSION" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
<PermissionButton key="del" menuCode="RULE_COMMISSION" permCode="DELETE" size="small" type="link" danger
|
||||
onClick={() => onDelete(r)}>삭제</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="수수료율 (보험사 → 시스템)" description="상품·회차별 보험사 통보 수수료율">
|
||||
<ProTable<CommissionRateResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="rateId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
@@ -34,8 +108,57 @@ export default function CommissionRateList() {
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="RULE_COMMISSION" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 수수료율 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '수수료율 수정' : '수수료율 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
|
||||
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
|
||||
} : undefined}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="productId" label="상품" rules={[{ required: true }]} width="md" showSearch
|
||||
options={products?.list.map((p) => ({ value: p.productId, label: p.productName })) ?? []}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="commissionYear" label="회차" rules={[{ required: true }]} width="md"
|
||||
options={GRADE_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="ratePct" label="수수료율(%)" rules={[{ required: true }]} width="md"
|
||||
min={0} max={100} fieldProps={{ precision: 4 }}
|
||||
/>
|
||||
<ProFormText name="payMethodCond" label="납입조건" width="md" placeholder="MONTHLY/ALL" />
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="minPremium" label="최소보험료" width="md" min={0}
|
||||
fieldProps={{ formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
|
||||
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
import { Tag } from 'antd';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Radio, Switch, Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormSelect, ProFormText, ProFormTextArea,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { ruleApi, ExceptionCodeResp } from '@/api/rule';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { ruleApi, ExceptionCodeResp, ExceptionCodeSaveReq } from '@/api/rule';
|
||||
|
||||
const DIRECTION_COLOR = { PLUS: 'success', MINUS: 'error' } as const;
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'BONUS', label: '보너스' },
|
||||
{ value: 'PENALTY', label: '패널티' },
|
||||
{ value: 'ADJUSTMENT', label: '조정' },
|
||||
{ value: 'OTHER', label: '기타' },
|
||||
];
|
||||
|
||||
export default function ExceptionCodeList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editCode?: string }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<ExceptionCodeResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: ExceptionCodeResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editCode: record.exceptionCode });
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: ExceptionCodeSaveReq = {
|
||||
...v,
|
||||
autoCalcYn: v.autoCalcYn ? 'Y' : 'N',
|
||||
approvalRequired: v.approvalRequired ? 'Y' : 'N',
|
||||
taxIncluded: v.taxIncluded ? 'Y' : 'N',
|
||||
isActive: v.isActive ? 'Y' : 'N',
|
||||
};
|
||||
if (modal.editCode) {
|
||||
await ruleApi.updateExceptionCode(modal.editCode, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await ruleApi.createExceptionCode(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<ExceptionCodeResp>[] = [
|
||||
{ title: '예외코드', dataIndex: 'exceptionCode', width: 120, search: false,
|
||||
render: (_, r) => <strong>{r.exceptionCode}</strong> },
|
||||
@@ -34,11 +80,19 @@ export default function ExceptionCodeList() {
|
||||
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
|
||||
{r.isActive === 'Y' ? 'Y' : 'N'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 100, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="RULE_EXCEPTION_CODE" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="예외 코드" description="가산/차감/승인/세금 정책 마스터 (예외금액 원장에서 사용)">
|
||||
<ProTable<ExceptionCodeResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="exceptionCode"
|
||||
columns={columns}
|
||||
search={false}
|
||||
@@ -48,8 +102,70 @@ export default function ExceptionCodeList() {
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="RULE_EXCEPTION_CODE" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 예외코드 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editCode ? '예외코드 수정' : '예외코드 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editCode ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
autoCalcYn: editRecord.autoCalcYn === 'Y',
|
||||
approvalRequired: editRecord.approvalRequired === 'Y',
|
||||
taxIncluded: editRecord.taxIncluded === 'Y',
|
||||
isActive: editRecord.isActive === 'Y',
|
||||
} : { direction: 'PLUS', autoCalcYn: false, approvalRequired: false, taxIncluded: false, isActive: true }}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editCode ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="exceptionCode" label="예외코드" rules={[{ required: true }]}
|
||||
width="md" placeholder="EX001"
|
||||
disabled={!!modal.editCode}
|
||||
/>
|
||||
<ProFormText
|
||||
name="exceptionName" label="예외명" rules={[{ required: true }]}
|
||||
width="md" placeholder="특별 가산금"
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="category" label="카테고리" width="md"
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
<ProForm.Item name="direction" label="방향" rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value="PLUS">가산 (+)</Radio>
|
||||
<Radio value="MINUS">차감 (-)</Radio>
|
||||
</Radio.Group>
|
||||
</ProForm.Item>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProForm.Item name="autoCalcYn" label="자동계산" valuePropName="checked">
|
||||
<Switch checkedChildren="자동" unCheckedChildren="수동" />
|
||||
</ProForm.Item>
|
||||
<ProForm.Item name="approvalRequired" label="승인 필요" valuePropName="checked" style={{ marginLeft: 32 }}>
|
||||
<Switch checkedChildren="필요" unCheckedChildren="불요" />
|
||||
</ProForm.Item>
|
||||
<ProForm.Item name="taxIncluded" label="과세" valuePropName="checked" style={{ marginLeft: 32 }}>
|
||||
<Switch checkedChildren="과세" unCheckedChildren="비과세" />
|
||||
</ProForm.Item>
|
||||
<ProForm.Item name="isActive" label="활성" valuePropName="checked" style={{ marginLeft: 32 }}>
|
||||
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
|
||||
</ProForm.Item>
|
||||
</ProForm.Group>
|
||||
<ProFormTextArea name="description" label="설명" placeholder="예외 코드 상세 설명" />
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,69 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { ruleApi, OverrideRuleResp } from '@/api/rule';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { ruleApi, OverrideRuleResp, OverrideRuleSaveReq } from '@/api/rule';
|
||||
|
||||
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6].map((id) => ({ value: id, label: `직급 #${id}` }));
|
||||
const CALC_TYPE_OPTIONS = [
|
||||
{ value: 'FLAT', label: 'FLAT (정률)' },
|
||||
{ value: 'PROGRESSIVE', label: 'PROGRESSIVE (누진)' },
|
||||
];
|
||||
|
||||
export default function OverrideRuleList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<OverrideRuleResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: OverrideRuleResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.overrideId });
|
||||
};
|
||||
|
||||
const onDelete = (record: OverrideRuleResp) => {
|
||||
Modal.confirm({
|
||||
title: '오버라이드 규정 삭제',
|
||||
content: '해당 오버라이드 규정을 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await ruleApi.removeOverrideRule(record.overrideId);
|
||||
message.success('삭제 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: OverrideRuleSaveReq = {
|
||||
...v,
|
||||
effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (modal.editId) {
|
||||
await ruleApi.updateOverrideRule(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await ruleApi.createOverrideRule(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<OverrideRuleResp>[] = [
|
||||
{ title: '하위 직급', dataIndex: 'fromGradeName', width: 110, search: false,
|
||||
render: (_, r) => <strong>{r.fromGradeName ?? `#${r.fromGrade}`}</strong> },
|
||||
@@ -21,11 +81,21 @@ export default function OverrideRuleList() {
|
||||
},
|
||||
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
|
||||
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="RULE_OVERRIDE" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
<PermissionButton key="del" menuCode="RULE_OVERRIDE" permCode="DELETE" size="small" type="link" danger
|
||||
onClick={() => onDelete(r)}>삭제</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="오버라이드 규정" description="조직 트리 상향 분배율 (하위 → 상위 직급)">
|
||||
<ProTable<OverrideRuleResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="overrideId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
@@ -35,8 +105,61 @@ export default function OverrideRuleList() {
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="RULE_OVERRIDE" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 오버라이드 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '오버라이드 수정' : '오버라이드 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
|
||||
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
|
||||
} : undefined}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="fromGrade" label="하위 직급" rules={[{ required: true }]} width="md"
|
||||
options={GRADE_OPTIONS}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="toGrade" label="상위 직급" rules={[{ required: true }]} width="md"
|
||||
options={GRADE_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="overridePct" label="오버라이드율(%)" rules={[{ required: true }]} width="md"
|
||||
min={0} max={100} fieldProps={{ precision: 4 }}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="calcType" label="계산방식" width="md"
|
||||
options={CALC_TYPE_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText name="insuranceTypeCond" label="보험종조건" width="md" placeholder="LIFE/ALL" />
|
||||
<ProFormDigit
|
||||
name="capAmount" label="월 상한금액" width="md" min={0}
|
||||
fieldProps={{ formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
|
||||
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,68 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
|
||||
ProTable, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { ruleApi, PayoutRuleResp } from '@/api/rule';
|
||||
import { ruleApi, PayoutRuleResp, PayoutRuleSaveReq } from '@/api/rule';
|
||||
|
||||
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6].map((id) => ({ value: id, label: `직급 #${id}` }));
|
||||
const YEAR_OPTIONS = [1, 2, 3, 4, 5].map((y) => ({ value: y, label: `${y}년차` }));
|
||||
|
||||
export default function PayoutRuleList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<PayoutRuleResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: PayoutRuleResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.ruleId });
|
||||
};
|
||||
|
||||
const onDelete = (record: PayoutRuleResp) => {
|
||||
Modal.confirm({
|
||||
title: '지급율 삭제',
|
||||
content: '해당 지급율 규정을 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await ruleApi.removePayoutRule(record.ruleId);
|
||||
message.success('삭제 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: PayoutRuleSaveReq = {
|
||||
...v,
|
||||
effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (modal.editId) {
|
||||
await ruleApi.updatePayoutRule(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await ruleApi.createPayoutRule(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<PayoutRuleResp>[] = [
|
||||
{ title: '직급', dataIndex: 'gradeName', width: 100, search: false,
|
||||
@@ -27,11 +83,21 @@ export default function PayoutRuleList() {
|
||||
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
|
||||
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="RULE_PAYOUT" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
<PermissionButton key="del" menuCode="RULE_PAYOUT" permCode="DELETE" size="small" type="link" danger
|
||||
onClick={() => onDelete(r)}>삭제</PermissionButton>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="지급율 (시스템 → 설계사)" description="직급·보험종·회차별 설계사 지급율">
|
||||
<ProTable<PayoutRuleResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="ruleId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
@@ -41,8 +107,58 @@ export default function PayoutRuleList() {
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="RULE_PAYOUT" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 지급율 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '지급율 수정' : '지급율 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={580}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
|
||||
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
|
||||
} : undefined}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="gradeId" label="직급" rules={[{ required: true }]} width="md"
|
||||
options={GRADE_OPTIONS}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
|
||||
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="commissionYear" label="회차" rules={[{ required: true }]} width="md"
|
||||
options={YEAR_OPTIONS}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="payoutPct" label="지급율(%)" rules={[{ required: true }]} width="md"
|
||||
min={0} max={100} fieldProps={{ precision: 4 }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText name="payMethodCond" label="납입조건" width="md" placeholder="MONTHLY/ALL" />
|
||||
<ProFormText name="performanceGrade" label="실적등급" width="md" placeholder="A/B/C" />
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
|
||||
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,307 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, DatePicker, Descriptions, message, Progress, Space } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
message,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import { batchApi } from '@/api/settle';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { batchRunApi, BatchHistoryResp, BatchJobLogResp } from '@/api/batch';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const STEP_NAMES = [
|
||||
{ key: 'receive', label: '데이터 수신' },
|
||||
{ key: 'transform', label: '데이터 변환' },
|
||||
{ key: 'reconcile', label: '대사 검증' },
|
||||
{ key: 'calcRecruit', label: '모집수수료' },
|
||||
{ key: 'calcMaintain', label: '유지수수료' },
|
||||
{ key: 'chargebackException', label: '환수/예외' },
|
||||
{ key: 'calcOverride', label: '오버라이드' },
|
||||
{ key: 'aggregate', label: '집계' },
|
||||
] as const;
|
||||
|
||||
function currentStepIndex(stepName?: string): number {
|
||||
if (!stepName) return -1;
|
||||
return STEP_NAMES.findIndex((s) => s.key === stepName);
|
||||
}
|
||||
|
||||
function formatMs(ms?: number): string {
|
||||
if (ms == null) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function stepStatus(
|
||||
idx: number,
|
||||
currentIdx: number,
|
||||
jobStatus: BatchJobLogResp['status'],
|
||||
): 'finish' | 'process' | 'wait' | 'error' {
|
||||
if (jobStatus === 'FAILED' && idx === currentIdx) return 'error';
|
||||
if (idx < currentIdx) return 'finish';
|
||||
if (idx === currentIdx) return 'process';
|
||||
return 'wait';
|
||||
}
|
||||
|
||||
export default function BatchRun() {
|
||||
const [settleMonth, setSettleMonth] = useState<Dayjs | null>(dayjs());
|
||||
const navigate = useNavigate();
|
||||
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
|
||||
const [companyCode, setCompanyCode] = useState<string | undefined>(undefined);
|
||||
const [jobLogId, setJobLogId] = useState<number | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const { data: status, refetch } = useQuery({
|
||||
queryKey: ['batch', 'status'],
|
||||
queryFn: batchApi.status,
|
||||
refetchInterval: (q) => {
|
||||
const s = (q.state.data as any)?.status;
|
||||
return s === 'STARTED' || s === 'RUNNING' ? 3000 : false;
|
||||
const { data: jobStatus } = useQuery<BatchJobLogResp>({
|
||||
queryKey: ['batch', 'status', jobLogId],
|
||||
queryFn: () => batchRunApi.status(jobLogId!),
|
||||
enabled: jobLogId != null,
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data?.status;
|
||||
return s === 'COMPLETED' || s === 'FAILED' ? false : 5000;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: history = [] } = useQuery<BatchHistoryResp[]>({
|
||||
queryKey: ['batch', 'history'],
|
||||
queryFn: () => batchRunApi.history(10),
|
||||
});
|
||||
|
||||
const isFinished =
|
||||
jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED';
|
||||
|
||||
const onRun = async () => {
|
||||
if (!settleMonth) { message.warning('정산월을 선택하세요'); return; }
|
||||
if (!settleMonth) {
|
||||
message.warning('정산월을 선택하세요');
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
await batchApi.run(settleMonth.format('YYYYMM'));
|
||||
message.success('배치 실행 요청됨');
|
||||
refetch();
|
||||
} catch {
|
||||
// 인터셉터에서 처리
|
||||
const id = await batchRunApi.run(
|
||||
settleMonth.format('YYYYMM'),
|
||||
companyCode,
|
||||
);
|
||||
setJobLogId(id);
|
||||
message.success('정산 배치를 실행했습니다');
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string; message?: string };
|
||||
if (e?.code === 'BATCH_RUNNING') {
|
||||
message.error('이미 실행 중인 배치가 있습니다');
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentIdx = currentStepIndex(jobStatus?.stepName);
|
||||
|
||||
return (
|
||||
<PageContainer title="정산 배치 실행">
|
||||
{/* 컨트롤 영역 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<DatePicker picker="month" value={settleMonth} onChange={setSettleMonth} />
|
||||
<Button type="primary" onClick={onRun}>배치 실행</Button>
|
||||
<Button onClick={() => refetch()}>상태 새로고침</Button>
|
||||
<Space wrap>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={settleMonth}
|
||||
onChange={(v) => v && setSettleMonth(v)}
|
||||
format="YYYY-MM"
|
||||
/>
|
||||
<Select
|
||||
placeholder="보험사 선택 (전체)"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={companyCode}
|
||||
onChange={(v) => setCompanyCode(v)}
|
||||
options={[]}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode="BATCH_RUN"
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={running}
|
||||
onClick={onRun}
|
||||
>
|
||||
정산 실행
|
||||
</PermissionButton>
|
||||
{jobLogId && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
setJobLogId(null);
|
||||
setRunning(false);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{status && (
|
||||
<Card title="최근 배치 상태">
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="작업명">{status.jobName}</Descriptions.Item>
|
||||
<Descriptions.Item label="정산월">{status.settleMonth}</Descriptions.Item>
|
||||
<Descriptions.Item label="상태"><CodeBadge groupCode="SETTLE_STATUS" value={status.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="현재 Step">{status.currentStep ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="시작 시각">{status.startedAt ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="종료 시각">{status.finishedAt ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="소요 시간">{status.durationSec ? `${status.durationSec}초` : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="에러">{status.errorMessage ?? '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{(status.progressPct ?? 0) > 0 && (
|
||||
<Progress
|
||||
percent={status.progressPct}
|
||||
status={status.status === 'FAILED' ? 'exception' : status.status === 'COMPLETED' ? 'success' : 'active'}
|
||||
style={{ marginTop: 16 }}
|
||||
{/* 진행 상태 */}
|
||||
{jobLogId && jobStatus && (
|
||||
<>
|
||||
{jobStatus.status === 'FAILED' && jobStatus.errorMessage && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="배치 실패"
|
||||
description={jobStatus.errorMessage}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>진행 상태</Text>
|
||||
<Tag
|
||||
color={
|
||||
jobStatus.status === 'COMPLETED'
|
||||
? 'green'
|
||||
: jobStatus.status === 'FAILED'
|
||||
? 'red'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{jobStatus.status}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Steps
|
||||
current={currentIdx}
|
||||
status={
|
||||
jobStatus.status === 'FAILED'
|
||||
? 'error'
|
||||
: jobStatus.status === 'COMPLETED'
|
||||
? 'finish'
|
||||
: 'process'
|
||||
}
|
||||
items={STEP_NAMES.map((s, idx) => ({
|
||||
title: s.label,
|
||||
status: stepStatus(idx, currentIdx, jobStatus.status),
|
||||
description:
|
||||
idx === currentIdx
|
||||
? `처리 ${jobStatus.successCount ?? 0}건 / ${formatMs(jobStatus.durationMs)}`
|
||||
: undefined,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 완료 KPI */}
|
||||
{isFinished && (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="총 건수"
|
||||
value={jobStatus.totalCount ?? 0}
|
||||
suffix="건"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="성공"
|
||||
value={jobStatus.successCount ?? 0}
|
||||
suffix="건"
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="실패"
|
||||
value={jobStatus.errorCount ?? 0}
|
||||
suffix="건"
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="소요 시간"
|
||||
value={formatMs(jobStatus.durationMs)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
{jobStatus.status === 'COMPLETED' && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate('/settle')}
|
||||
>
|
||||
정산 결과 보기
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={onRun} loading={running}>
|
||||
재실행
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 최근 배치 이력 */}
|
||||
{history.length > 0 && (
|
||||
<Card title="최근 배치 이력" size="small">
|
||||
<Table<BatchHistoryResp>
|
||||
rowKey="jobLogId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={history}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'jobLogId', width: 70 },
|
||||
{ title: '작업명', dataIndex: 'jobName' },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string) => (
|
||||
<Tag
|
||||
color={
|
||||
v === 'COMPLETED'
|
||||
? 'green'
|
||||
: v === 'FAILED'
|
||||
? 'red'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '마지막 Step', dataIndex: 'stepName', width: 160 },
|
||||
{ title: '시작', dataIndex: 'startedAt', width: 160 },
|
||||
{ title: '종료', dataIndex: 'endedAt', width: 160 },
|
||||
{
|
||||
title: '소요',
|
||||
dataIndex: 'durationMs',
|
||||
width: 80,
|
||||
render: (v: number) => formatMs(v),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Modal, Form, Select, message } from 'antd';
|
||||
import { DownloadOutlined, FileExcelOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { paymentApi, PaymentResp } from '@/api/payment';
|
||||
import { paymentApi, PaymentResp, TransferFileResp } from '@/api/payment';
|
||||
import DatePicker from 'antd/es/date-picker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { MonthPicker } = DatePicker;
|
||||
|
||||
export default function PaymentList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const { data: statusCodes = [] } = useCommonCodes('PAYMENT_STATUS');
|
||||
const { data: bankCodes = [] } = useCommonCodes('BANK_CODE');
|
||||
|
||||
const [transferModalOpen, setTransferModalOpen] = useState(false);
|
||||
const [resultModalOpen, setResultModalOpen] = useState(false);
|
||||
const [transferResult, setTransferResult] = useState<TransferFileResp | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleGenerateTransferFile = async () => {
|
||||
const values = await form.validateFields();
|
||||
const settleMonth = values.settleMonth ? dayjs(values.settleMonth).format('YYYYMM') : undefined;
|
||||
if (!settleMonth) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await paymentApi.generateTransferFile(settleMonth, values.bankCode);
|
||||
setTransferResult(result);
|
||||
setTransferModalOpen(false);
|
||||
setResultModalOpen(true);
|
||||
actionRef.current?.reload();
|
||||
message.success('이체파일이 생성되었습니다');
|
||||
} catch {
|
||||
// 에러는 request.ts 인터셉터에서 처리
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!transferResult) return;
|
||||
window.open(`/api/files/${transferResult.fileId}`, '_blank');
|
||||
};
|
||||
|
||||
const columns: ProColumns<PaymentResp>[] = [
|
||||
{
|
||||
@@ -27,14 +67,26 @@ export default function PaymentList() {
|
||||
},
|
||||
{ title: '지급일', dataIndex: 'payDate', width: 110, search: false },
|
||||
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true, search: false },
|
||||
{
|
||||
title: '이체파일', dataIndex: 'transferFileId', width: 120, search: false,
|
||||
render: (_, r) =>
|
||||
r.transferFileId ? (
|
||||
<a href={`/api/files/${r.transferFileId}`} target="_blank" rel="noreferrer">
|
||||
<DownloadOutlined /> #{r.transferFileId}
|
||||
</a>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title="지급 관리" description="확정 정산 → 이체 파일 생성 / 결과 추적">
|
||||
<ProTable<PaymentResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="paymentId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1200 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...rest } = params as any;
|
||||
const res = await paymentApi.list({ ...rest, pageNum: current, pageSize });
|
||||
@@ -47,7 +99,84 @@ export default function PaymentList() {
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="transfer-file"
|
||||
type="primary"
|
||||
icon={<FileExcelOutlined />}
|
||||
onClick={() => { form.resetFields(); setTransferModalOpen(true); }}
|
||||
>
|
||||
이체파일 생성
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 이체파일 생성 입력 모달 */}
|
||||
<Modal
|
||||
title="이체파일 생성"
|
||||
open={transferModalOpen}
|
||||
onOk={handleGenerateTransferFile}
|
||||
onCancel={() => setTransferModalOpen(false)}
|
||||
confirmLoading={generating}
|
||||
okText="생성"
|
||||
cancelText="취소"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="settleMonth"
|
||||
label="정산월"
|
||||
rules={[{ required: true, message: '정산월을 선택해주세요' }]}
|
||||
>
|
||||
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="정산월 선택" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankCode"
|
||||
label="은행"
|
||||
rules={[{ required: true, message: '은행을 선택해주세요' }]}
|
||||
>
|
||||
<Select placeholder="은행 선택" allowClear>
|
||||
{bankCodes.map((c) => (
|
||||
<Select.Option key={c.code} value={c.code}>
|
||||
{c.codeName}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 이체파일 생성 결과 모달 */}
|
||||
<Modal
|
||||
title="이체파일 생성 완료"
|
||||
open={resultModalOpen}
|
||||
onOk={() => setResultModalOpen(false)}
|
||||
onCancel={() => setResultModalOpen(false)}
|
||||
okText="확인"
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
footer={[
|
||||
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
파일 다운로드
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setResultModalOpen(false)}>
|
||||
닫기
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{transferResult && (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div>파일명: <strong>{transferResult.fileName}</strong></div>
|
||||
<div>파일 ID: <strong>{transferResult.fileId}</strong></div>
|
||||
<div>처리 건수: <strong>{transferResult.paymentCount.toLocaleString()}건</strong></div>
|
||||
<div>
|
||||
합계 금액:{' '}
|
||||
<strong>
|
||||
{Number(transferResult.totalAmount).toLocaleString()}원
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Checkbox, Col, Empty, message, Row, Space, Table, Typography, Button } from 'antd';
|
||||
import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Empty,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
} from 'antd';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { roleApi, RoleVO } from '@/api/user';
|
||||
import { menuApi, MenuResp } from '@/api/menu';
|
||||
|
||||
const { Title } = Typography;
|
||||
const PERMS = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'EXPORT'] as const;
|
||||
const PERM_LABELS: Record<string, string> = {
|
||||
READ: '조회',
|
||||
CREATE: '등록',
|
||||
UPDATE: '수정',
|
||||
DELETE: '삭제',
|
||||
APPROVE: '승인',
|
||||
EXPORT: '엑셀',
|
||||
};
|
||||
|
||||
type PermKey = string;
|
||||
type PermKey = string; // "${menuId}:${permCode}"
|
||||
|
||||
interface FlatMenuItem {
|
||||
menuId: number;
|
||||
menuCode: string;
|
||||
menuName: string;
|
||||
menuType: 'DIRECTORY' | 'PAGE' | 'LINK';
|
||||
indent: number; // 0 = top-level directory, 1 = child page
|
||||
}
|
||||
|
||||
function flattenMenu(nodes: MenuResp[], indent = 0): FlatMenuItem[] {
|
||||
const out: FlatMenuItem[] = [];
|
||||
for (const n of nodes) {
|
||||
out.push({
|
||||
menuId: n.menuId,
|
||||
menuCode: n.menuCode,
|
||||
menuName: n.menuName,
|
||||
menuType: n.menuType,
|
||||
indent,
|
||||
});
|
||||
if (n.children?.length) {
|
||||
out.push(...flattenMenu(n.children, indent + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function RoleList() {
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<RoleVO | null>(null);
|
||||
const [granted, setGranted] = useState<Set<PermKey>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data: roles = [] } = useQuery({ queryKey: ['role', 'list'], queryFn: roleApi.list });
|
||||
const { data: tree = [] } = useQuery({ queryKey: ['menu', 'tree'], queryFn: menuApi.tree });
|
||||
const { data: roles = [] } = useQuery({
|
||||
queryKey: ['role', 'list'],
|
||||
queryFn: roleApi.list,
|
||||
});
|
||||
|
||||
const flatPages = flattenPages(tree);
|
||||
const { data: tree = [] } = useQuery({
|
||||
queryKey: ['menu', 'tree'],
|
||||
queryFn: menuApi.tree,
|
||||
});
|
||||
|
||||
const flatMenu = flattenMenu(tree);
|
||||
|
||||
const selectRole = async (r: RoleVO) => {
|
||||
setSelected(r);
|
||||
const perms = await roleApi.permissions(r.roleId);
|
||||
const set = new Set<PermKey>();
|
||||
perms.forEach((p) => p.isGranted === 'Y' && set.add(`${p.menuId}:${p.permCode}`));
|
||||
perms.forEach((p) => {
|
||||
if (p.isGranted === 'Y') set.add(`${p.menuId}:${p.permCode}`);
|
||||
});
|
||||
setGranted(set);
|
||||
};
|
||||
|
||||
@@ -35,63 +89,150 @@ export default function RoleList() {
|
||||
setGranted(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
const list = Array.from(granted).map((k) => {
|
||||
const [menuId, permCode] = k.split(':');
|
||||
return { menuId: Number(menuId), permCode, isGranted: 'Y' };
|
||||
});
|
||||
await roleApi.savePermissions(selected.roleId, list);
|
||||
message.success('권한 매트릭스 저장 완료');
|
||||
qc.invalidateQueries({ queryKey: ['menu'] });
|
||||
const toggleAll = () => {
|
||||
const pages = flatMenu.filter((m) => m.menuType !== 'DIRECTORY');
|
||||
const allKeys = pages.flatMap((m) => PERMS.map((p) => `${m.menuId}:${p}`));
|
||||
const allGranted = allKeys.every((k) => granted.has(k));
|
||||
if (allGranted) {
|
||||
setGranted(new Set());
|
||||
} else {
|
||||
setGranted(new Set(allKeys));
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!selected) return;
|
||||
Modal.confirm({
|
||||
title: '권한 저장',
|
||||
content: `[${selected.roleName}] 역할의 권한 매트릭스를 저장하시겠습니까?`,
|
||||
okText: '저장',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const list = Array.from(granted).map((k) => {
|
||||
const [menuId, permCode] = k.split(':');
|
||||
return { menuId: Number(menuId), permCode, isGranted: 'Y' as const };
|
||||
});
|
||||
await roleApi.savePermissions(selected.roleId, list);
|
||||
message.success('권한 매트릭스 저장 완료');
|
||||
qc.invalidateQueries({ queryKey: ['menu'] });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const allPages = flatMenu.filter((m) => m.menuType !== 'DIRECTORY');
|
||||
const allKeys = allPages.flatMap((m) => PERMS.map((p) => `${m.menuId}:${p}`));
|
||||
const allChecked = allKeys.length > 0 && allKeys.every((k) => granted.has(k));
|
||||
|
||||
return (
|
||||
<PageContainer title="역할 / 권한">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
{/* 역할 목록 */}
|
||||
<Col flex="300px">
|
||||
<Card title="역할 목록" size="small">
|
||||
<Table<RoleVO>
|
||||
rowKey="roleId"
|
||||
dataSource={roles}
|
||||
size="small"
|
||||
pagination={false}
|
||||
onRow={(r) => ({ onClick: () => selectRole(r), style: { cursor: 'pointer' } })}
|
||||
rowClassName={(r) => r.roleId === selected?.roleId ? 'ant-table-row-selected' : ''}
|
||||
scroll={{ y: 600 }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => selectRole(r),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
rowClassName={(r) =>
|
||||
r.roleId === selected?.roleId ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
columns={[
|
||||
{ title: '코드', dataIndex: 'roleCode', width: 120 },
|
||||
{ title: '이름', dataIndex: 'roleName' },
|
||||
{ title: '레벨', dataIndex: 'roleLevel', width: 60, align: 'right' },
|
||||
{ title: '코드', dataIndex: 'roleCode', width: 110, ellipsis: true },
|
||||
{ title: '이름', dataIndex: 'roleName', ellipsis: true },
|
||||
{
|
||||
title: '활성',
|
||||
dataIndex: 'isActive',
|
||||
width: 55,
|
||||
align: 'center',
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'Y' ? 'green' : 'default'}>
|
||||
{v === 'Y' ? 'Y' : 'N'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
|
||||
{/* 권한 매트릭스 */}
|
||||
<Col flex="1">
|
||||
<Card
|
||||
title={selected ? `권한 매트릭스 — ${selected.roleName}` : '권한 매트릭스'}
|
||||
title={
|
||||
selected
|
||||
? `권한 매트릭스 — ${selected.roleName}`
|
||||
: '권한 매트릭스'
|
||||
}
|
||||
size="small"
|
||||
extra={selected && <Button type="primary" onClick={save}>저장</Button>}
|
||||
extra={
|
||||
selected && (
|
||||
<Space>
|
||||
<Button size="small" onClick={toggleAll}>
|
||||
{allChecked ? '전체 해제' : '전체 선택'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={saving}
|
||||
onClick={save}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!selected ? (
|
||||
<Empty description="역할을 선택하세요" />
|
||||
<Empty description="좌측 역할을 클릭하세요" />
|
||||
) : (
|
||||
<Table
|
||||
<Table<FlatMenuItem>
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="menuId"
|
||||
dataSource={flatPages}
|
||||
scroll={{ y: 500 }}
|
||||
dataSource={flatMenu}
|
||||
scroll={{ y: 560 }}
|
||||
columns={[
|
||||
{ title: '메뉴', dataIndex: 'menuName', width: 200 },
|
||||
{ title: '코드', dataIndex: 'menuCode', width: 160 },
|
||||
...PERMS.map((p) => ({
|
||||
title: p, key: p, width: 80, align: 'center' as const,
|
||||
render: (_: unknown, m: MenuResp) => (
|
||||
<Checkbox
|
||||
checked={granted.has(`${m.menuId}:${p}`)}
|
||||
onChange={() => toggle(m.menuId, p)}
|
||||
/>
|
||||
{
|
||||
title: '메뉴',
|
||||
dataIndex: 'menuName',
|
||||
width: 220,
|
||||
render: (name: string, row: FlatMenuItem) => (
|
||||
<span
|
||||
style={{
|
||||
paddingLeft: row.indent * 12,
|
||||
fontWeight: row.menuType === 'DIRECTORY' ? 600 : 400,
|
||||
color: row.menuType === 'DIRECTORY' ? '#1677ff' : undefined,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...PERMS.map((p) => ({
|
||||
title: PERM_LABELS[p],
|
||||
key: p,
|
||||
width: 64,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, m: FlatMenuItem) => {
|
||||
if (m.menuType === 'DIRECTORY') return null;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={granted.has(`${m.menuId}:${p}`)}
|
||||
onChange={() => toggle(m.menuId, p)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
@@ -102,15 +243,3 @@ export default function RoleList() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function flattenPages(nodes: MenuResp[]): MenuResp[] {
|
||||
const out: MenuResp[] = [];
|
||||
const visit = (ns: MenuResp[]) => {
|
||||
for (const n of ns) {
|
||||
if (n.menuType === 'PAGE') out.push(n);
|
||||
if (n.children?.length) visit(n.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user