diff --git a/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java b/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java index f153bba..5d3eb4b 100644 --- a/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java +++ b/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java @@ -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>> preview( @@ -41,4 +45,32 @@ public class UploadController { List> 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 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> 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> targetTables() { + return ApiResponse.ok(metaService.getAvailableTables()); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java b/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java new file mode 100644 index 0000000..8d81751 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java @@ -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> 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 getFields(String targetTable) { + if (targetTable == null) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "target_table 누락"); + } + List fields = META.get(targetTable); + if (fields == null) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "허용되지 않은 target_table: " + targetTable + " (가능: " + META.keySet() + ")"); + } + return fields; + } + + public List getAvailableTables() { + return List.copyOf(META.keySet()); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java index 5375b5d..5c7409a 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java @@ -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 headers = new ArrayList<>(); + List> 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 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 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 columns, // ["A","B","C",...] + List headers, // ["증권번호","설계사명",...] + List> samples // 첫 N행 + ) {} + // ============================================================ // 내부 헬퍼 // ============================================================ diff --git a/ga-frontend/src/api/uploadTemplate.ts b/ga-frontend/src/api/uploadTemplate.ts index 2ba30c5..c51f36b 100644 --- a/ga-frontend/src/api/uploadTemplate.ts +++ b/ga-frontend/src/api/uploadTemplate.ts @@ -104,6 +104,21 @@ export interface PreviewRowResp { [field: string]: unknown; } +/** 샘플 엑셀 헤더 추출 응답 */ +export interface ExcelHeaderResp { + columns: string[]; // ['A','B','C',...] + headers: string[]; // ['증권번호','설계사명',...] + samples: string[][]; // 첫 N행 +} + +/** target_table 별 사용 가능한 컬럼 메타 */ +export interface TargetFieldMeta { + field: string; // policy_no + label: string; // 증권번호 + type: string; // STRING/NUMBER/DECIMAL/DATE/INTEGER + required: boolean; +} + export const uploadTemplateApi = { list: (params: UploadTemplateQueryParams) => unwrap>(api.get('/api/upload-templates', { params })), @@ -148,4 +163,24 @@ export const uploadTemplateApi = { }), ); }, + + /** 샘플 엑셀 → 헤더 + 미리보기 행 자동 추출 (등록 전 매핑 자동 채우기용) */ + extractHeaders: (file: File, headerRow = 1, sheetIndex = 0, previewRows = 5) => { + const fd = new FormData(); + fd.append('file', file); + return unwrap( + api.post('/api/upload-templates/excel-headers', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + params: { headerRow, sheetIndex, previewRows }, + }), + ); + }, + + /** target_table 별 사용 가능한 DB 컬럼 (target_field Select 옵션) */ + targetFields: (targetTable: string) => + unwrap(api.get(`/api/upload-templates/target-fields/${targetTable}`)), + + /** 사용 가능한 target_table 목록 */ + targetTables: () => + unwrap(api.get('/api/upload-templates/target-tables')), }; diff --git a/ga-frontend/src/pages/system/UploadTemplateManager.tsx b/ga-frontend/src/pages/system/UploadTemplateManager.tsx index a7a1929..6a506eb 100644 --- a/ga-frontend/src/pages/system/UploadTemplateManager.tsx +++ b/ga-frontend/src/pages/system/UploadTemplateManager.tsx @@ -9,7 +9,7 @@ import { ProCard, ProTable, ModalForm, ProFormText, ProFormSelect, import { useQuery, useQueryClient } from '@tanstack/react-query'; import { IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy, - IconArrowUp, IconArrowDown, + IconArrowUp, IconArrowDown, IconWand, IconUpload, } from '@tabler/icons-react'; import PageContainer from '@/components/common/PageContainer'; import PermissionButton from '@/components/common/PermissionButton'; @@ -19,6 +19,8 @@ import { type UploadTemplateResp, type UploadTemplateColumnVO, type UploadTemplateSaveReq, + type TargetFieldMeta, + type ExcelHeaderResp, } from '@/api/uploadTemplate'; const { Text } = Typography; @@ -301,6 +303,8 @@ function ColumnEditor({ template, onSaved }: { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [showPreview, setShowPreview] = useState(false); + const [headerSample, setHeaderSample] = useState(null); + const sampleInputRef = useRef(null); // 선택된 템플릿 변경 시 컬럼 로드 useQuery({ @@ -309,6 +313,57 @@ function ColumnEditor({ template, onSaved }: { onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)), } as Parameters[0]); + // target_table 별 사용 가능한 컬럼 메타 (target_field Select 옵션) + const { data: targetFields = [] } = useQuery({ + queryKey: ['uploadTemplate', 'targetFields', template.targetTable], + queryFn: () => uploadTemplateApi.targetFields(template.targetTable), + enabled: !!template.targetTable, + }); + const targetFieldOptions = targetFields.map((f) => ({ + value: f.field, + label: `${f.label} (${f.field})${f.required ? ' *' : ''}`, + type: f.type, + })); + const targetFieldTypeMap: Record = Object.fromEntries( + targetFields.map((f) => [f.field, f.type]), + ); + + /** 샘플 엑셀 업로드 → 헤더 추출 → 비어있으면 자동 채움 / 있으면 사용자 확인 후 덮어쓰기 */ + const onSampleFile = async (file: File) => { + try { + const meta = await uploadTemplateApi.extractHeaders(file, template.headerRow ?? 1, template.sheetIndex ?? 0, 5); + setHeaderSample(meta); + + const apply = () => { + // headers 의 길이만큼 행 생성 (sourceColumn=A,B,C, sourceHeader=헤더명) + const newRows: ColumnRow[] = meta.headers.map((h, idx) => ({ + _key: makeKey(), + sortOrder: idx + 1, + sourceType: 'COLUMN_INDEX', + sourceColumn: meta.columns[idx], + sourceHeader: h, + targetField: '', // 사용자가 Select 로 선택 + targetType: 'STRING', + })); + setRows(newRows); + message.success(`${meta.headers.length}개 컬럼이 자동 추출되었습니다. 대상 필드만 선택해주세요.`); + }; + + if (rows.length > 0) { + Modal.confirm({ + title: '기존 매핑이 덮어쓰기 됩니다', + content: `현재 ${rows.length}개 매핑이 있습니다. 샘플 엑셀의 ${meta.headers.length}개 컬럼으로 교체하시겠습니까?`, + okText: '교체', okType: 'danger', + onOk: apply, + }); + } else { + apply(); + } + } catch { + // request.ts interceptor 가 토스트 표시 + } + }; + const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => { setRows((prev) => prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)), @@ -366,13 +421,35 @@ function ColumnEditor({ template, onSaved }: {
{/* 헤더 */}
- + {template.templateName} {template.templateCode} {template.category} + → {template.targetTable} + {/* 샘플 엑셀 자동 채우기 */} + { + const f = e.target.files?.[0]; + if (f) onSampleFile(f); + e.target.value = ''; + }} + /> + + + @@ -390,6 +467,36 @@ function ColumnEditor({ template, onSaved }: {
+ {/* 샘플 미리보기 (자동 채우기 후 첫 5행) */} + {headerSample && headerSample.samples.length > 0 && ( +
+
+ + 샘플 데이터 (첫 5행) +
+
+ ({ + _idx: idx, + ...Object.fromEntries(headerSample.headers.map((h, i) => [h, row[i]])), + }))} + rowKey="_idx" + columns={headerSample.headers.map((h, idx) => ({ + title: {headerSample.columns[idx]}{h}, + dataIndex: h, + width: 120, + }))} + scroll={{ x: 'max-content' }} + /> + + + )} + {/* 컬럼 테이블 */}
@@ -455,12 +562,25 @@ function ColumnEditor({ template, onSaved }: { ), }, { - title: '대상 필드', dataIndex: 'targetField', width: 120, + title: '대상 필드', dataIndex: 'targetField', width: 200, render: (_, r) => ( - updateCell(r._key, 'targetField', e.target.value)} + value={r.targetField || undefined} + showSearch + allowClear + placeholder="DB 컬럼 선택" + optionFilterProp="label" + options={targetFieldOptions} + onChange={(v) => { + updateCell(r._key, 'targetField', v ?? ''); + // 타입 자동 채우기 + if (v && targetFieldTypeMap[v]) { + updateCell(r._key, 'targetType', targetFieldTypeMap[v]); + } + }} + style={{ width: '100%' }} + notFoundContent={targetFields.length === 0 ? '메타 로딩 중...' : '일치하는 필드 없음'} /> ), },