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:
@@ -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행
|
||||
) {}
|
||||
|
||||
// ============================================================
|
||||
// 내부 헬퍼
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user