From 87e4e3d1da6021da3512a7c1545b6014940a52dc Mon Sep 17 00:00:00 2001 From: GA Pro Date: Mon, 11 May 2026 00:08:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=205=EC=A2=85=20=EC=9D=BC=EA=B4=84=20=EC=A7=84=ED=96=89=20(4=20?= =?UTF-8?q?agents=20=EB=B3=91=EB=A0=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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) --- ga-api/build.gradle | 1 + .../controller/settle/PaymentController.java | 14 + .../service/transfer/TransferFileService.java | 59 ++++ .../ga/api/transfer/BankTransferFactory.java | 27 ++ .../transfer/BankTransferFileGenerator.java | 24 ++ .../transfer/GenericCsvTransferGenerator.java | 69 ++++ .../api/transfer/KbBankTransferGenerator.java | 82 +++++ .../ledger/LedgerControllerTest.java | 150 +++++++++ .../controller/org/AgentControllerTest.java | 143 ++++++++ .../product/ContractControllerTest.java | 147 ++++++++ .../settle/SettleControllerTest.java | 129 +++++++ .../controller/system/UserControllerTest.java | 149 ++++++++ .../api/support/AbstractControllerTest.java | 133 ++++++++ .../com/ga/api/support/MockLoginUser.java | 37 ++ .../ga/api/support/TestSecurityConfig.java | 53 +++ .../src/test/resources/application-test.yml | 32 ++ .../java/com/ga/common/file/FileService.java | 33 ++ .../ga/core/mapper/settle/PaymentMapper.java | 3 + .../ga/core/vo/settle/TransferFileResp.java | 17 + .../resources/mapper/settle/PaymentMapper.xml | 12 + ga-frontend/src/api/batch.ts | 47 +++ ga-frontend/src/api/company.ts | 15 + ga-frontend/src/api/payment.ts | 10 + ga-frontend/src/api/product.ts | 18 + ga-frontend/src/api/rule.ts | 89 +++++ ga-frontend/src/pages/product/CompanyList.tsx | 119 ++++++- ga-frontend/src/pages/product/ProductList.tsx | 144 +++++++- .../src/pages/rule/ChargebackRuleList.tsx | 138 +++++++- .../src/pages/rule/CommissionRateList.tsx | 127 ++++++- .../src/pages/rule/ExceptionCodeList.tsx | 122 ++++++- .../src/pages/rule/OverrideRuleList.tsx | 127 ++++++- ga-frontend/src/pages/rule/PayoutRuleList.tsx | 120 ++++++- ga-frontend/src/pages/settle/BatchRun.tsx | 318 ++++++++++++++++-- ga-frontend/src/pages/settle/PaymentList.tsx | 135 +++++++- ga-frontend/src/pages/system/RoleList.tsx | 229 ++++++++++--- 35 files changed, 2962 insertions(+), 110 deletions(-) create mode 100644 ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java create mode 100644 ga-api/src/main/java/com/ga/api/transfer/BankTransferFactory.java create mode 100644 ga-api/src/main/java/com/ga/api/transfer/BankTransferFileGenerator.java create mode 100644 ga-api/src/main/java/com/ga/api/transfer/GenericCsvTransferGenerator.java create mode 100644 ga-api/src/main/java/com/ga/api/transfer/KbBankTransferGenerator.java create mode 100644 ga-api/src/test/java/com/ga/api/controller/ledger/LedgerControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/controller/org/AgentControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/controller/product/ContractControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/controller/settle/SettleControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/controller/system/UserControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/support/AbstractControllerTest.java create mode 100644 ga-api/src/test/java/com/ga/api/support/MockLoginUser.java create mode 100644 ga-api/src/test/java/com/ga/api/support/TestSecurityConfig.java create mode 100644 ga-api/src/test/resources/application-test.yml create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/TransferFileResp.java create mode 100644 ga-frontend/src/api/batch.ts diff --git a/ga-api/build.gradle b/ga-api/build.gradle index d8cb1b5..55a231d 100644 --- a/ga-api/build.gradle +++ b/ga-api/build.gradle @@ -10,4 +10,5 @@ jar.enabled = false dependencies { implementation project(':ga-core') + testImplementation 'org.springframework.security:spring-security-test' } diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java index e24a441..af9ce8f 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java @@ -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 generateTransferFile( + @RequestParam String settleMonth, + @RequestParam String bankCode) { + return ApiResponse.ok(transferFileService.generate(settleMonth, bankCode)); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java new file mode 100644 index 0000000..fe9ba35 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java @@ -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 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 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/transfer/BankTransferFactory.java b/ga-api/src/main/java/com/ga/api/transfer/BankTransferFactory.java new file mode 100644 index 0000000..06a185a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/transfer/BankTransferFactory.java @@ -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 generators; + + public BankTransferFactory(List 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")); + } +} diff --git a/ga-api/src/main/java/com/ga/api/transfer/BankTransferFileGenerator.java b/ga-api/src/main/java/com/ga/api/transfer/BankTransferFileGenerator.java new file mode 100644 index 0000000..8e3f675 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/transfer/BankTransferFileGenerator.java @@ -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 payments, String settleMonth); +} diff --git a/ga-api/src/main/java/com/ga/api/transfer/GenericCsvTransferGenerator.java b/ga-api/src/main/java/com/ga/api/transfer/GenericCsvTransferGenerator.java new file mode 100644 index 0000000..2cc8492 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/transfer/GenericCsvTransferGenerator.java @@ -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 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; + } +} diff --git a/ga-api/src/main/java/com/ga/api/transfer/KbBankTransferGenerator.java b/ga-api/src/main/java/com/ga/api/transfer/KbBankTransferGenerator.java new file mode 100644 index 0000000..d2add6d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/transfer/KbBankTransferGenerator.java @@ -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 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; + } +} diff --git a/ga-api/src/test/java/com/ga/api/controller/ledger/LedgerControllerTest.java b/ga-api/src/test/java/com/ga/api/controller/ledger/LedgerControllerTest.java new file mode 100644 index 0000000..8498883 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/controller/ledger/LedgerControllerTest.java @@ -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 singlePage() { + LedgerResp r = new LedgerResp(); + r.setLedgerId(100L); + r.setAgentName("홍길동"); + r.setPolicyNo("POL-001"); + return PageResponse.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 page = PageResponse.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()); + } +} diff --git a/ga-api/src/test/java/com/ga/api/controller/org/AgentControllerTest.java b/ga-api/src/test/java/com/ga/api/controller/org/AgentControllerTest.java new file mode 100644 index 0000000..65865bf --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/controller/org/AgentControllerTest.java @@ -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 page = PageResponse.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()); + } +} diff --git a/ga-api/src/test/java/com/ga/api/controller/product/ContractControllerTest.java b/ga-api/src/test/java/com/ga/api/controller/product/ContractControllerTest.java new file mode 100644 index 0000000..0d1f045 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/controller/product/ContractControllerTest.java @@ -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 page = PageResponse.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()); + } +} diff --git a/ga-api/src/test/java/com/ga/api/controller/settle/SettleControllerTest.java b/ga-api/src/test/java/com/ga/api/controller/settle/SettleControllerTest.java new file mode 100644 index 0000000..1e97964 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/controller/settle/SettleControllerTest.java @@ -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 page = PageResponse.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()); + } +} diff --git a/ga-api/src/test/java/com/ga/api/controller/system/UserControllerTest.java b/ga-api/src/test/java/com/ga/api/controller/system/UserControllerTest.java new file mode 100644 index 0000000..b46aff1 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/controller/system/UserControllerTest.java @@ -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 page = PageResponse.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()); + } +} diff --git a/ga-api/src/test/java/com/ga/api/support/AbstractControllerTest.java b/ga-api/src/test/java/com/ga/api/support/AbstractControllerTest.java new file mode 100644 index 0000000..0eb87a8 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/support/AbstractControllerTest.java @@ -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; +} diff --git a/ga-api/src/test/java/com/ga/api/support/MockLoginUser.java b/ga-api/src/test/java/com/ga/api/support/MockLoginUser.java new file mode 100644 index 0000000..587655e --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/support/MockLoginUser.java @@ -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 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); + } +} diff --git a/ga-api/src/test/java/com/ga/api/support/TestSecurityConfig.java b/ga-api/src/test/java/com/ga/api/support/TestSecurityConfig.java new file mode 100644 index 0000000..6ea759a --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/support/TestSecurityConfig.java @@ -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(); + } +} diff --git a/ga-api/src/test/resources/application-test.yml b/ga-api/src/test/resources/application-test.yml new file mode 100644 index 0000000..c53365e --- /dev/null +++ b/ga-api/src/test/resources/application-test.yml @@ -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 diff --git a/ga-common/src/main/java/com/ga/common/file/FileService.java b/ga-common/src/main/java/com/ga/common/file/FileService.java index 86374fe..c8551c0 100644 --- a/ga-common/src/main/java/com/ga/common/file/FileService.java +++ b/ga-common/src/main/java/com/ga/common/file/FileService.java @@ -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); diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java index 5f20a5f..9406cd8 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java @@ -19,4 +19,7 @@ public interface PaymentMapper { @Param("failReason") String failReason); int updateTransferFile(@Param("paymentIds") List paymentIds, @Param("transferFileId") Long transferFileId); + + List selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth, + @Param("bankCode") String bankCode); } diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/TransferFileResp.java b/ga-core/src/main/java/com/ga/core/vo/settle/TransferFileResp.java new file mode 100644 index 0000000..606f399 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/TransferFileResp.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml b/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml index 8f72eb1..fd153ef 100644 --- a/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml @@ -69,6 +69,18 @@ WHERE payment_id = #{paymentId} + + UPDATE payment SET transfer_file_id = #{transferFileId}, pay_status = 'SENT', updated_at = NOW() WHERE payment_id IN diff --git a/ga-frontend/src/api/batch.ts b/ga-frontend/src/api/batch.ts new file mode 100644 index 0000000..7f0b9d6 --- /dev/null +++ b/ga-frontend/src/api/batch.ts @@ -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( + api.post('/api/batch/settlement/run', null, { + params: { settleMonth, companyCode }, + }), + ), + status: (jobLogId: number) => + unwrap( + api.get('/api/batch/settlement/status', { params: { jobLogId } }), + ), + history: (size = 10) => + unwrap( + api.get('/api/batch/settlement/history', { params: { size } }), + ), +}; diff --git a/ga-frontend/src/api/company.ts b/ga-frontend/src/api/company.ts index 0d16213..dcdeebf 100644 --- a/ga-frontend/src/api/company.ts +++ b/ga-frontend/src/api/company.ts @@ -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>(api.get('/api/companies', { params: p })), + create: (req: CompanySaveReq) => + unwrap(api.post('/api/companies', req)), + update: (id: number, req: CompanySaveReq) => + unwrap(api.put(`/api/companies/${id}`, req)), }; diff --git a/ga-frontend/src/api/payment.ts b/ga-frontend/src/api/payment.ts index 103ece1..9b7f182 100644 --- a/ga-frontend/src/api/payment.ts +++ b/ga-frontend/src/api/payment.ts @@ -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>(api.get('/api/payments', { params: p })), updateStatus: (paymentId: number, status: string, failReason?: string) => unwrap(api.put(`/api/payments/${paymentId}/status`, { status, failReason })), + generateTransferFile: (settleMonth: string, bankCode: string) => + unwrap(api.post('/api/payments/transfer-file', null, { params: { settleMonth, bankCode } })), }; diff --git a/ga-frontend/src/api/product.ts b/ga-frontend/src/api/product.ts index 61fb026..add5194 100644 --- a/ga-frontend/src/api/product.ts +++ b/ga-frontend/src/api/product.ts @@ -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>(api.get('/api/products', { params: p })), + create: (req: ProductSaveReq) => + unwrap(api.post('/api/products', req)), + update: (id: number, req: ProductSaveReq) => + unwrap(api.put(`/api/products/${id}`, req)), + remove: (id: number) => + unwrap(api.delete(`/api/products/${id}`)), }; diff --git a/ga-frontend/src/api/rule.ts b/ga-frontend/src/api/rule.ts index 4cc3216..ef43a86 100644 --- a/ga-frontend/src/api/rule.ts +++ b/ga-frontend/src/api/rule.ts @@ -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(api.get('/api/rules/commission-rates', { params })), + createCommissionRate: (req: CommissionRateSaveReq) => + unwrap(api.post('/api/rules/commission-rates', req)), + updateCommissionRate: (id: number, req: CommissionRateSaveReq) => + unwrap(api.put(`/api/rules/commission-rates/${id}`, req)), + removeCommissionRate: (id: number) => + unwrap(api.delete(`/api/rules/commission-rates/${id}`)), + payoutRules: (params?: { gradeId?: number; insuranceType?: string }) => unwrap(api.get('/api/rules/payout', { params })), + createPayoutRule: (req: PayoutRuleSaveReq) => + unwrap(api.post('/api/rules/payout', req)), + updatePayoutRule: (id: number, req: PayoutRuleSaveReq) => + unwrap(api.put(`/api/rules/payout/${id}`, req)), + removePayoutRule: (id: number) => + unwrap(api.delete(`/api/rules/payout/${id}`)), + overrideRules: () => unwrap(api.get('/api/rules/override')), + createOverrideRule: (req: OverrideRuleSaveReq) => + unwrap(api.post('/api/rules/override', req)), + updateOverrideRule: (id: number, req: OverrideRuleSaveReq) => + unwrap(api.put(`/api/rules/override/${id}`, req)), + removeOverrideRule: (id: number) => + unwrap(api.delete(`/api/rules/override/${id}`)), + chargebackRules: (params?: { companyCode?: string }) => unwrap(api.get('/api/rules/chargeback', { params })), + createChargebackRule: (req: ChargebackRuleSaveReq) => + unwrap(api.post('/api/rules/chargeback', req)), + updateChargebackRule: (id: number, req: ChargebackRuleSaveReq) => + unwrap(api.put(`/api/rules/chargeback/${id}`, req)), + removeChargebackRule: (id: number) => + unwrap(api.delete(`/api/rules/chargeback/${id}`)), + exceptionCodes: () => unwrap(api.get('/api/rules/exception-codes')), + createExceptionCode: (req: ExceptionCodeSaveReq) => + unwrap(api.post('/api/rules/exception-codes', req)), + updateExceptionCode: (code: string, req: ExceptionCodeSaveReq) => + unwrap(api.put(`/api/rules/exception-codes/${code}`, req)), }; diff --git a/ga-frontend/src/pages/product/CompanyList.tsx b/ga-frontend/src/pages/product/CompanyList.tsx index dc11397..824df52 100644 --- a/ga-frontend/src/pages/product/CompanyList.tsx +++ b/ga-frontend/src/pages/product/CompanyList.tsx @@ -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(); + 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(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[] = [ { 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) => {r.isActive === 'Y' ? '활성' : '비활성'}, }, + { + title: '액션', valueType: 'option', width: 100, fixed: 'right', + render: (_, r) => [ + openEdit(r)}>수정, + ], + }, ]; return ( + 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={() => [ + + 보험사 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + + + + + + + + + + + + + + + ); } diff --git a/ga-frontend/src/pages/product/ProductList.tsx b/ga-frontend/src/pages/product/ProductList.tsx index 55fbb12..1750c7b 100644 --- a/ga-frontend/src/pages/product/ProductList.tsx +++ b/ga-frontend/src/pages/product/ProductList.tsx @@ -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(); const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, @@ -29,14 +97,24 @@ export default function ProductList() { render: (_, r) => {r.isActive === 'Y' ? '활성' : '비활성'}, }, + { + title: '액션', valueType: 'option', width: 130, fixed: 'right', + render: (_, r) => [ + openEdit(r)}>수정, + onDelete(r)}>삭제, + ], + }, ]; return ( + 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={() => [ + + 상품 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + + + + ({ value: c.companyId, label: c.companyName })) ?? []} + /> + ({ value: c.code, label: c.codeName }))} + /> + + + + + + + + + + + + + ); } diff --git a/ga-frontend/src/pages/rule/ChargebackRuleList.tsx b/ga-frontend/src/pages/rule/ChargebackRuleList.tsx index c8ecf9b..be82fa7 100644 --- a/ga-frontend/src/pages/rule/ChargebackRuleList.tsx +++ b/ga-frontend/src/pages/rule/ChargebackRuleList.tsx @@ -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(); + const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { title: '보험사', dataIndex: 'companyCode', width: 100, search: false, render: (_, r) => {r.companyCode} }, @@ -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) => [ + openEdit(r)}>수정, + onDelete(r)}>삭제, + ], + }, ]; return ( + 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={() => [ + + 환수 규정 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + ({ value: c.code, label: c.codeName }))} + /> + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/ga-frontend/src/pages/rule/CommissionRateList.tsx b/ga-frontend/src/pages/rule/CommissionRateList.tsx index c652ba3..8819713 100644 --- a/ga-frontend/src/pages/rule/CommissionRateList.tsx +++ b/ga-frontend/src/pages/rule/CommissionRateList.tsx @@ -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(); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { 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) => [ + openEdit(r)}>수정, + onDelete(r)}>삭제, + ], + }, ]; return ( + 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={() => [ + + 수수료율 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + ({ value: p.productId, label: p.productName })) ?? []} + /> + + + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }} + /> + + + + + + ); } diff --git a/ga-frontend/src/pages/rule/ExceptionCodeList.tsx b/ga-frontend/src/pages/rule/ExceptionCodeList.tsx index e75b0cf..9c7c1f1 100644 --- a/ga-frontend/src/pages/rule/ExceptionCodeList.tsx +++ b/ga-frontend/src/pages/rule/ExceptionCodeList.tsx @@ -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(); + const [modal, setModal] = useState<{ open: boolean; editCode?: string }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { title: '예외코드', dataIndex: 'exceptionCode', width: 120, search: false, render: (_, r) => {r.exceptionCode} }, @@ -34,11 +80,19 @@ export default function ExceptionCodeList() { render: (_, r) => {r.isActive === 'Y' ? 'Y' : 'N'}, }, + { + title: '액션', valueType: 'option', width: 100, fixed: 'right', + render: (_, r) => [ + openEdit(r)}>수정, + ], + }, ]; return ( + 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={() => [ + + 예외코드 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + + + + + + + 가산 (+) + 차감 (-) + + + + + + + + + + + + + + + + + + + ); } diff --git a/ga-frontend/src/pages/rule/OverrideRuleList.tsx b/ga-frontend/src/pages/rule/OverrideRuleList.tsx index c1b9f4c..af44c3d 100644 --- a/ga-frontend/src/pages/rule/OverrideRuleList.tsx +++ b/ga-frontend/src/pages/rule/OverrideRuleList.tsx @@ -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(); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { title: '하위 직급', dataIndex: 'fromGradeName', width: 110, search: false, render: (_, r) => {r.fromGradeName ?? `#${r.fromGrade}`} }, @@ -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) => [ + openEdit(r)}>수정, + onDelete(r)}>삭제, + ], + }, ]; return ( + 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={() => [ + + 오버라이드 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + + + + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }} + /> + + + + + + ); } diff --git a/ga-frontend/src/pages/rule/PayoutRuleList.tsx b/ga-frontend/src/pages/rule/PayoutRuleList.tsx index 7904268..f52b976 100644 --- a/ga-frontend/src/pages/rule/PayoutRuleList.tsx +++ b/ga-frontend/src/pages/rule/PayoutRuleList.tsx @@ -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(); const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(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[] = [ { 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) => [ + openEdit(r)}>수정, + onDelete(r)}>삭제, + ], + }, ]; return ( + 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={() => [ + + 지급율 등록, + ]} dateFormatter="string" /> + + !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 }} + > + + + ({ value: c.code, label: c.codeName }))} + /> + + + + + + + + + + + + + + ); } diff --git a/ga-frontend/src/pages/settle/BatchRun.tsx b/ga-frontend/src/pages/settle/BatchRun.tsx index 5f798e5..a29e3c4 100644 --- a/ga-frontend/src/pages/settle/BatchRun.tsx +++ b/ga-frontend/src/pages/settle/BatchRun.tsx @@ -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()); + const navigate = useNavigate(); + const [settleMonth, setSettleMonth] = useState(dayjs()); + const [companyCode, setCompanyCode] = useState(undefined); + const [jobLogId, setJobLogId] = useState(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({ + 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({ + 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 ( + {/* 컨트롤 영역 */} - - - - + + v && setSettleMonth(v)} + format="YYYY-MM" + /> + + {bankCodes.map((c) => ( + + {c.codeName} + + ))} + + + + + + {/* 이체파일 생성 결과 모달 */} + setResultModalOpen(false)} + onCancel={() => setResultModalOpen(false)} + okText="확인" + cancelButtonProps={{ style: { display: 'none' } }} + footer={[ + , + , + ]} + > + {transferResult && ( +
+
파일명: {transferResult.fileName}
+
파일 ID: {transferResult.fileId}
+
처리 건수: {transferResult.paymentCount.toLocaleString()}건
+
+ 합계 금액:{' '} + + {Number(transferResult.totalAmount).toLocaleString()}원 + +
+
+ )} +
); } diff --git a/ga-frontend/src/pages/system/RoleList.tsx b/ga-frontend/src/pages/system/RoleList.tsx index 71afd65..04ff371 100644 --- a/ga-frontend/src/pages/system/RoleList.tsx +++ b/ga-frontend/src/pages/system/RoleList.tsx @@ -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 = { + 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(null); const [granted, setGranted] = useState>(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(); - 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 ( - + {/* 역할 목록 */} + 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) => ( + + {v === 'Y' ? 'Y' : 'N'} + + ), + }, ]} /> - + + {/* 권한 매트릭스 */} + 저장} + extra={ + selected && ( + + + + + ) + } > {!selected ? ( - + ) : ( - 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) => ( - toggle(m.menuId, p)} - /> + { + title: '메뉴', + dataIndex: 'menuName', + width: 220, + render: (name: string, row: FlatMenuItem) => ( + + {name} + ), + }, + ...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 ( + toggle(m.menuId, p)} + /> + ); + }, })), ]} /> @@ -102,15 +243,3 @@ export default function RoleList() { ); } - -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; -}