feat(upload): 매핑 UI 4가지 보강 — 자동 채우기 + Select 옵션 + 미리보기 + 좌우 정렬

엑셀 컬럼 매핑이 더 직관적이도록:

Backend:
- TargetFieldMetaService 신규: 9개 target_table 의 사용 가능 필드 메타데이터
  (recruit_ledger 15 / maintain_ledger 11 / contract 10 / agent 10 /
   commission_rate 7 / payout_rule 7 / chargeback_rule 6 / exception_ledger 6 /
   payment 6) — field/label/type/required
- UploadService.extractHeaders(file, headerRow, sheetIndex, previewRows):
  StreamingReader 로 첫 행 헤더 + N행 미리보기 추출, A/B/C 컬럼 라벨 생성
- UploadController 에 3개 엔드포인트 추가:
  · POST /api/upload-templates/excel-headers (multipart) → ExcelHeaderResp
  · GET  /api/upload-templates/target-fields/{targetTable}
  · GET  /api/upload-templates/target-tables

Frontend:
- api/uploadTemplate.ts: extractHeaders / targetFields / targetTables 함수 추가
  + ExcelHeaderResp / TargetFieldMeta 타입
- ColumnEditor 보강:
  1) "샘플 엑셀로 자동 채우기" 버튼 — 사용자가 샘플 파일 올리면 헤더 추출 후
     sourceColumn(A,B,C) + sourceHeader(헤더명) 자동 채움 (기존 매핑 있으면
     Modal.confirm 으로 덮어쓰기 확인)
  2) 대상 필드 Input → Select (target_table 별 메타 옵션) — showSearch +
     선택 시 targetType 자동 설정 (필드 메타의 type 으로)
  3) 헤더에 target_table tag 표시 (→ recruit_ledger)
  4) 자동 채우기 후 첫 5행 샘플 데이터 테이블이 매핑 편집기 위에 표시됨

검증: tsc -b 통과, ga-api 재기동 후 /target-fields 200 (15필드 응답)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:14:45 +09:00
parent 87e4e3d1da
commit 2136b38e1b
5 changed files with 411 additions and 7 deletions
@@ -1,7 +1,10 @@
package com.ga.api.controller.upload;
import com.ga.api.service.upload.TargetFieldMetaService;
import com.ga.api.service.upload.TargetFieldMetaService.TargetFieldMeta;
import com.ga.api.service.upload.UploadResultResp;
import com.ga.api.service.upload.UploadService;
import com.ga.api.service.upload.UploadService.ExcelHeaderResp;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -19,6 +22,7 @@ import java.util.Map;
public class UploadController {
private final UploadService uploadService;
private final TargetFieldMetaService metaService;
@Operation(summary = "엑셀 업로드 처리",
description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.")
@@ -31,7 +35,7 @@ public class UploadController {
}
@Operation(summary = "업로드 미리보기 (상위 5행)",
description = "DB 에 저장하지 않고 변환 결과만 반환한다. limit 파라미터로 행 수 조정 가능.")
description = "DB 에 저장하지 않고 변환 결과만 반환한다.")
@PostMapping("/api/upload-templates/{templateCode}/preview")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<List<Map<String, Object>>> preview(
@@ -41,4 +45,32 @@ public class UploadController {
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
return ApiResponse.ok(rows);
}
@Operation(summary = "샘플 엑셀 헤더 추출",
description = "템플릿 등록 전에 샘플 파일 첫 행 헤더 + 미리보기 행을 추출한다. " +
"프론트는 이 결과로 sourceColumn(A,B,C..) + sourceHeader 를 자동 채운다.")
@PostMapping("/api/upload-templates/excel-headers")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<ExcelHeaderResp> extractHeaders(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "headerRow", defaultValue = "1") int headerRow,
@RequestParam(value = "sheetIndex", defaultValue = "0") int sheetIndex,
@RequestParam(value = "previewRows", defaultValue = "5") int previewRows) {
return ApiResponse.ok(uploadService.extractHeaders(file, headerRow, sheetIndex, previewRows));
}
@Operation(summary = "target_table 별 사용 가능한 컬럼 메타",
description = "프론트의 target_field Select 옵션 + 타입 자동 채우기에 사용한다.")
@GetMapping("/api/upload-templates/target-fields/{targetTable}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<List<TargetFieldMeta>> targetFields(@PathVariable String targetTable) {
return ApiResponse.ok(metaService.getFields(targetTable));
}
@Operation(summary = "사용 가능한 target_table 목록")
@GetMapping("/api/upload-templates/target-tables")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<List<String>> targetTables() {
return ApiResponse.ok(metaService.getAvailableTables());
}
}
@@ -0,0 +1,137 @@
package com.ga.api.service.upload;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 업로드 템플릿의 target_table 별로 사용 가능한 컬럼(target_field) 메타데이터.
* 화이트리스트 역할 + 프론트 Select 옵션 제공.
*/
@Service
public class TargetFieldMetaService {
public record TargetFieldMeta(
String field, // DB 컬럼명 (e.g. policy_no)
String label, // 한글 표시명
String type, // STRING / NUMBER / DECIMAL / DATE / INTEGER
boolean required) {}
private static final Map<String, List<TargetFieldMeta>> META = Map.of(
"recruit_ledger", List.of(
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
new TargetFieldMeta("commission_year", "회차", "INTEGER", false),
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
new TargetFieldMeta("pay_method", "납입주기", "STRING", false),
new TargetFieldMeta("company_rate", "회사율(%)", "DECIMAL", false),
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
new TargetFieldMeta("payout_rate", "지급율(%)", "DECIMAL", false),
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
new TargetFieldMeta("status", "상태", "STRING", false)
),
"maintain_ledger", List.of(
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
new TargetFieldMeta("settle_month", "정산월", "STRING", true)
),
"contract", List.of(
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
new TargetFieldMeta("contractor_name", "계약자", "STRING", false),
new TargetFieldMeta("insured_name", "피보험자", "STRING", false),
new TargetFieldMeta("premium", "보험료", "DECIMAL", false),
new TargetFieldMeta("pay_cycle", "납입주기", "STRING", false),
new TargetFieldMeta("contract_date", "계약일", "DATE", true),
new TargetFieldMeta("effective_date", "효력발생일", "DATE", false),
new TargetFieldMeta("status", "상태", "STRING", false)
),
"agent", List.of(
new TargetFieldMeta("agent_name", "설계사명", "STRING", true),
new TargetFieldMeta("license_no", "사번", "STRING", false),
new TargetFieldMeta("org_id", "조직ID", "INTEGER", true),
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
new TargetFieldMeta("phone", "전화번호", "STRING", false),
new TargetFieldMeta("email", "이메일", "STRING", false),
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
new TargetFieldMeta("account_no", "계좌번호", "STRING", false),
new TargetFieldMeta("status", "상태", "STRING", false),
new TargetFieldMeta("join_date", "입사일", "DATE", false)
),
"commission_rate", List.of(
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
new TargetFieldMeta("rate_pct", "수수료율(%)", "DECIMAL", true),
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
new TargetFieldMeta("min_premium", "최소보험료", "DECIMAL", false),
new TargetFieldMeta("effective_from", "적용시작", "DATE", true),
new TargetFieldMeta("effective_to", "적용종료", "DATE", false)
),
"payout_rule", List.of(
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
new TargetFieldMeta("payout_pct", "지급율(%)", "DECIMAL", true),
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
new TargetFieldMeta("performance_grade","실적등급", "STRING", false),
new TargetFieldMeta("effective_from", "적용시작", "DATE", true)
),
"chargeback_rule", List.of(
new TargetFieldMeta("company_code", "보험사코드", "STRING", true),
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
new TargetFieldMeta("lapse_month_from","실효시작월", "INTEGER", true),
new TargetFieldMeta("lapse_month_to", "실효종료월", "INTEGER", true),
new TargetFieldMeta("cb_rate", "환수율(%)", "DECIMAL", true),
new TargetFieldMeta("include_override","오버라이드포함", "STRING", false)
),
"exception_ledger", List.of(
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
new TargetFieldMeta("exception_code", "예외코드", "STRING", true),
new TargetFieldMeta("amount", "금액", "DECIMAL", true),
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
new TargetFieldMeta("policy_no", "증권번호", "STRING", false),
new TargetFieldMeta("description", "내용", "STRING", false)
),
"payment", List.of(
new TargetFieldMeta("settle_id", "정산ID", "INTEGER", true),
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
new TargetFieldMeta("pay_month", "지급월", "STRING", true),
new TargetFieldMeta("net_amount", "지급액", "DECIMAL", true),
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
new TargetFieldMeta("account_no", "계좌번호", "STRING", false)
)
);
public List<TargetFieldMeta> getFields(String targetTable) {
if (targetTable == null) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "target_table 누락");
}
List<TargetFieldMeta> fields = META.get(targetTable);
if (fields == null) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"허용되지 않은 target_table: " + targetTable + " (가능: " + META.keySet() + ")");
}
return fields;
}
public List<String> getAvailableTables() {
return List.copyOf(META.keySet());
}
}
@@ -223,6 +223,86 @@ public class UploadService {
return result;
}
/**
* 샘플 엑셀에서 헤더(첫 행) + 미리보기 행 추출. 템플릿 등록 전에 사용.
* @param file 엑셀 파일
* @param headerRow 헤더가 있는 행 번호 (1-base, 보통 1)
* @param sheetIndex 시트 인덱스 (0-base)
* @param previewRows 헤더 다음 N행을 함께 반환
*/
public ExcelHeaderResp extractHeaders(MultipartFile file, int headerRow, int sheetIndex, int previewRows) {
List<String> headers = new ArrayList<>();
List<List<String>> samples = new ArrayList<>();
try (InputStream is = file.getInputStream();
Workbook wb = StreamingReader.builder()
.rowCacheSize(50).bufferSize(4096).open(is)) {
Sheet sheet = wb.getSheetAt(sheetIndex);
int rowNum = 0;
for (Row row : sheet) {
rowNum++;
if (rowNum == headerRow) {
short last = row.getLastCellNum();
for (int i = 0; i < last; i++) {
var cell = row.getCell(i);
headers.add(cell != null ? safeStringValue(cell) : "");
}
} else if (rowNum > headerRow && samples.size() < previewRows) {
List<String> rowVals = new ArrayList<>();
for (int i = 0; i < headers.size(); i++) {
var cell = row.getCell(i);
rowVals.add(cell != null ? safeStringValue(cell) : "");
}
samples.add(rowVals);
}
if (rowNum > headerRow + previewRows) break;
}
} catch (Exception e) {
log.error("extractHeaders error", e);
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
}
// 엑셀 컬럼 라벨 (A, B, C, ... AA, AB ...)
List<String> columnLabels = new ArrayList<>();
for (int i = 0; i < headers.size(); i++) columnLabels.add(toColumnLabel(i));
return new ExcelHeaderResp(columnLabels, headers, samples);
}
private String safeStringValue(org.apache.poi.ss.usermodel.Cell cell) {
try {
CellType type = cell.getCellType();
return switch (type) {
case NUMERIC -> cell.getNumericCellValue() == Math.floor(cell.getNumericCellValue())
? String.valueOf((long) cell.getNumericCellValue())
: String.valueOf(cell.getNumericCellValue());
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> cell.getCellFormula();
case BLANK -> "";
default -> cell.getStringCellValue();
};
} catch (Exception e) {
return "";
}
}
/** 0 → A, 25 → Z, 26 → AA */
private String toColumnLabel(int index) {
StringBuilder sb = new StringBuilder();
int n = index;
while (n >= 0) {
sb.insert(0, (char) ('A' + (n % 26)));
n = n / 26 - 1;
}
return sb.toString();
}
public record ExcelHeaderResp(
List<String> columns, // ["A","B","C",...]
List<String> headers, // ["증권번호","설계사명",...]
List<List<String>> samples // 첫 N행
) {}
// ============================================================
// 내부 헬퍼
// ============================================================