feat: 추천 작업 5종 일괄 진행 (4 agents 병렬)

[A] 등록 모달 7페이지 (frontend)
- CompanyList / ProductList / Commission / Payout / Override / Chargeback / ExceptionCode
- ModalForm 580px, Switch Y/N 변환, 날짜 dayjs, PermissionButton
- api/company/product/rule.ts 에 SaveReq + create/update/remove 추가

[B] BatchRun + RoleList 보강 (frontend)
- BatchRun: 정산월/보험사 선택 → 실행, antd Steps 8단계 + 5초 폴링
  (refetchInterval 자동 정지), KPI 4 카드, 최근 이력
- RoleList: 좌(역할 목록) 우(메뉴×6권한 매트릭스 체크박스),
  DIRECTORY 들여쓰기, 전체 토글, Modal.confirm 후 저장

[C] 이체파일 + 은행 어댑터 (api)
- BankTransferFileGenerator 인터페이스 + GenericCsvTransferGenerator
  (UTF-8 BOM, 표준 CSV) + KbBankTransferGenerator (088, 고정폭 + 합계)
- BankTransferFactory (Spring Map 빈)
- TransferFileService: PENDING 조회 → 파일 생성 → file_storage 적재 →
  pay_file_ref 갱신 → status=SENT
- POST /api/payments/transfer-file (DataChangeLog + EXPORT 권한)
- account_no EncryptTypeHandler 자동 복호화 후 파일에 사용
- FileService.saveBytes(byte[]) 오버로드 추가
- PaymentList: 이체파일 생성 ModalForm + 다운로드 컬럼

[D] MockMvc Controller 테스트 (api)
- AbstractControllerTest (SpringBootTest + MockMvc, JwtFilter doAnswer
  로 chain 통과 처리), TestSecurityConfig (filter 빈 override)
- 5 클래스 36 테스트: Agent/Contract/Ledger/Settle/User Controller
- happy path + validation 400 + 권한 403 + 인증 401

검증:
- ./gradlew clean build 성공
- 백엔드 단위테스트 94건 (common 33 + batch 19 + api 36 + 기타 6) 통과
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:08:32 +09:00
parent a314a95ecf
commit 87e4e3d1da
35 changed files with 2962 additions and 110 deletions
@@ -1,5 +1,6 @@
package com.ga.api.controller.settle;
import com.ga.api.service.transfer.TransferFileService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
@@ -7,6 +8,8 @@ import com.ga.common.model.PageResponse;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.settle.PaymentResp;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.settle.TransferFileResp;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -20,6 +23,7 @@ import java.util.Map;
public class PaymentController {
private final PaymentMapper mapper;
private final TransferFileService transferFileService;
@GetMapping
@RequirePermission(menu = "PAYMENT", perm = "READ")
@@ -35,4 +39,14 @@ public class PaymentController {
mapper.updateStatus(paymentId, body.get("status"), body.get("failReason"));
return ApiResponse.ok();
}
@Operation(summary = "이체파일 생성", description = "정산월+은행코드 기준 PENDING 지급건을 이체파일로 생성하고 SENT 상태로 갱신")
@PostMapping("/transfer-file")
@RequirePermission(menu = "PAYMENT", perm = "EXPORT")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<TransferFileResp> generateTransferFile(
@RequestParam String settleMonth,
@RequestParam String bankCode) {
return ApiResponse.ok(transferFileService.generate(settleMonth, bankCode));
}
}
@@ -0,0 +1,59 @@
package com.ga.api.service.transfer;
import com.ga.api.transfer.BankTransferFactory;
import com.ga.api.transfer.BankTransferFileGenerator;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.file.FileService;
import com.ga.common.util.EncryptUtil;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.settle.PaymentVO;
import com.ga.core.vo.settle.TransferFileResp;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class TransferFileService {
private final PaymentMapper paymentMapper;
private final BankTransferFactory factory;
private final FileService fileService;
private final EncryptUtil encryptUtil;
@Transactional
public TransferFileResp generate(String settleMonth, String bankCode) {
// 1) 해당 정산월 + 은행의 PENDING payment 조회 (accountNo는 EncryptTypeHandler로 자동복호화됨)
List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
if (payments.isEmpty()) {
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
}
// 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태)
BankTransferFileGenerator generator = factory.get(bankCode);
byte[] fileContent = generator.generate(payments, settleMonth);
// 3) FileService로 file_storage 적재 → fileId 반환
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream");
// 4) payment들의 transfer_file_id 갱신 + status=SENT
List<Long> paymentIds = payments.stream()
.map(PaymentVO::getPaymentId)
.collect(Collectors.toList());
paymentMapper.updateTransferFile(paymentIds, fileId);
// 5) 합계 금액 계산
BigDecimal totalAmount = payments.stream()
.map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new TransferFileResp(fileId, fileName, payments.size(), totalAmount);
}
}
@@ -0,0 +1,27 @@
package com.ga.api.transfer;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 은행코드 → BankTransferFileGenerator 조회 팩토리.
* 등록되지 않은 은행코드는 GENERIC_CSV 기본 생성기로 폴백.
*/
@Component
public class BankTransferFactory {
private final Map<String, BankTransferFileGenerator> generators;
public BankTransferFactory(List<BankTransferFileGenerator> list) {
this.generators = list.stream()
.collect(Collectors.toMap(BankTransferFileGenerator::getBankCode, Function.identity()));
}
public BankTransferFileGenerator get(String bankCode) {
return generators.getOrDefault(bankCode, generators.get("GENERIC_CSV"));
}
}
@@ -0,0 +1,24 @@
package com.ga.api.transfer;
import com.ga.core.vo.settle.PaymentVO;
import java.util.List;
/**
* 은행별 이체파일 생성 인터페이스.
* 새 은행 포맷 추가 시 이 인터페이스 구현체만 추가하면 됨.
*/
public interface BankTransferFileGenerator {
/** 은행 코드 ("088", "020" 등) 또는 기본 "GENERIC_CSV" */
String getBankCode();
/** 파일 확장자 ".csv", ".dat" 등 */
String getFileExtension();
/**
* 이체파일 바이트 생성.
* accountNo 는 PaymentVO 에서 이미 복호화된 상태로 전달됨.
*/
byte[] generate(List<PaymentVO> payments, String settleMonth);
}
@@ -0,0 +1,69 @@
package com.ga.api.transfer;
import com.ga.core.vo.settle.PaymentVO;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 기본 CSV 이체파일 생성기. 은행별 전용 포맷이 없을 때 사용.
* UTF-8 BOM + 헤더 + 데이터 행.
*/
@Component
public class GenericCsvTransferGenerator implements BankTransferFileGenerator {
private static final byte[] UTF8_BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
@Override
public String getBankCode() {
return "GENERIC_CSV";
}
@Override
public String getFileExtension() {
return ".csv";
}
@Override
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
StringBuilder sb = new StringBuilder();
sb.append("정산월,은행코드,계좌번호,수취인,금액,적요\r\n");
for (PaymentVO p : payments) {
sb.append(csv(settleMonth)).append(",");
sb.append(csv(p.getBankCode())).append(",");
sb.append(csv(p.getAccountNo())).append(","); // 이미 복호화된 값
sb.append(csv(agentLabel(p))).append(",");
sb.append(p.getPayAmount() != null ? p.getPayAmount().toPlainString() : "0").append(",");
sb.append(csv("수수료 정산 " + formatMonth(settleMonth))).append("\r\n");
}
byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8);
byte[] result = new byte[UTF8_BOM.length + body.length];
System.arraycopy(UTF8_BOM, 0, result, 0, UTF8_BOM.length);
System.arraycopy(body, 0, result, UTF8_BOM.length, body.length);
return result;
}
private String csv(String val) {
if (val == null) return "";
if (val.contains(",") || val.contains("\"") || val.contains("\n")) {
return "\"" + val.replace("\"", "\"\"") + "\"";
}
return val;
}
private String agentLabel(PaymentVO p) {
return p.getAgentId() != null ? "설계사#" + p.getAgentId() : "";
}
/** "202501" → "2025-01" */
private String formatMonth(String settleMonth) {
if (settleMonth != null && settleMonth.length() == 6) {
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
}
return settleMonth;
}
}
@@ -0,0 +1,82 @@
package com.ga.api.transfer;
import com.ga.core.vo.settle.PaymentVO;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* KB국민은행(088) 고정폭 텍스트 이체파일 생성기.
* 레코드 구조 (총 80바이트):
* 은행코드(3) + 계좌번호(20) + 수취인명(20) + 금액(15, 우측정렬) + 적요(20) + CRLF(2)
*/
@Component
public class KbBankTransferGenerator implements BankTransferFileGenerator {
private static final String BANK_CODE = "088";
@Override
public String getBankCode() {
return BANK_CODE;
}
@Override
public String getFileExtension() {
return ".dat";
}
@Override
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
StringBuilder sb = new StringBuilder();
BigDecimal totalAmount = BigDecimal.ZERO;
for (PaymentVO p : payments) {
BigDecimal amt = p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO;
totalAmount = totalAmount.add(amt);
sb.append(rpad(BANK_CODE, 3));
sb.append(rpad(nvl(p.getAccountNo()), 20)); // 복호화된 계좌번호
sb.append(rpad("설계사#" + p.getAgentId(), 20));
sb.append(lpad(amt.longValue(), 15));
sb.append(rpad("수수료 정산 " + formatMonth(settleMonth), 20));
sb.append("\r\n");
}
// 합계 레코드
sb.append(rpad("999", 3));
sb.append(rpad("", 20));
sb.append(rpad("합계", 20));
sb.append(lpad(totalAmount.longValue(), 15));
sb.append(rpad("TOTAL:" + payments.size() + "", 20));
sb.append("\r\n");
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
private String rpad(String val, int width) {
if (val == null) val = "";
if (val.length() >= width) return val.substring(0, width);
return val + " ".repeat(width - val.length());
}
private String lpad(long val, int width) {
String s = String.valueOf(val);
if (s.length() >= width) return s.substring(s.length() - width);
return " ".repeat(width - s.length()) + s;
}
private String nvl(String val) {
return val != null ? val : "";
}
private String formatMonth(String settleMonth) {
if (settleMonth != null && settleMonth.length() == 6) {
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
}
return settleMonth;
}
}