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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user