From a314a95ecfa67b11cd6378125c0236de8a8360a9 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sun, 10 May 2026 23:10:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20(V19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Teams 3명 병렬 작업 (dba / api / frontend) 결과물. DB (V19__업로드템플릿.sql): - upload_template / upload_template_column / upload_history 3 테이블 - 인덱스 5종 (category / company_code / template_id / started_at) - SYSTEM_UPLOAD_TEMPLATE 메뉴 + READ/CREATE/UPDATE/DELETE 권한 - 모든 역할에 권한 부여 (MANAGER 는 READ 만) Backend: - ga-core: VO 7개 (Template/Column/History의 VO/Resp/SaveReq/SearchParam) + Mapper 3개 + XML 3개 (UploadTemplate/Column/History) - ga-api: TransformEngine (12 변환 규칙: TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/ LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT) + LookupCache (O(1) HashMap 캐시) + UploadService (processUpload/preview) + LookupMapper / DynamicInsertMapper (whitelist 기반 동적 SQL) - ga-api Controller 2개: · UploadTemplateController: /api/upload-templates CRUD + columns + history · UploadController: POST /api/upload/{templateCode}, /preview Frontend: - api/uploadTemplate.ts (10 endpoints) - pages/system/UploadTemplateManager.tsx — 좌(템플릿 목록) 우(컬럼 매핑 편집기) + ModalForm 등록/수정 + 미리보기 Modal + 이력 Drawer - components/common/TemplateUpload.tsx — 범용 컴포넌트 (Select 템플릿 + Dragger + 진행률 + 결과 Modal + 에러 파일 다운로드) - App.tsx 라우트 추가, MenuIcon.tsx 매핑 추가 검증: - ./gradlew :ga-api:bootJar 통과 - Flyway V19 운영 DB 적용 성공 (200ms) - ga-api 부팅 7.8s, /api/upload-templates 200 응답 - 메뉴 트리에 SYSTEM_UPLOAD_TEMPLATE 노출 확인 - 프론트 tsc -b / vite build 통과 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/upload/UploadController.java | 44 + .../upload/UploadTemplateController.java | 140 ++++ .../service/upload/DynamicInsertMapper.java | 23 + .../ga/api/service/upload/LookupCache.java | 105 +++ .../ga/api/service/upload/LookupMapper.java | 21 + .../api/service/upload/TransformEngine.java | 293 +++++++ .../api/service/upload/UploadResultResp.java | 44 + .../ga/api/service/upload/UploadService.java | 379 +++++++++ .../mapper/upload/DynamicInsertMapper.xml | 42 + .../resources/mapper/upload/LookupMapper.xml | 16 + .../db/migration/V19__업로드템플릿.sql | 127 +++ .../mapper/upload/UploadColumnMapper.java | 18 + .../mapper/upload/UploadHistoryMapper.java | 30 + .../mapper/upload/UploadTemplateMapper.java | 27 + .../ga/core/vo/upload/UploadHistoryResp.java | 31 + .../ga/core/vo/upload/UploadHistoryVO.java | 32 + .../vo/upload/UploadTemplateColumnVO.java | 39 + .../ga/core/vo/upload/UploadTemplateResp.java | 37 + .../core/vo/upload/UploadTemplateSaveReq.java | 63 ++ .../vo/upload/UploadTemplateSearchParam.java | 17 + .../ga/core/vo/upload/UploadTemplateVO.java | 34 + .../mapper/upload/UploadColumnMapper.xml | 67 ++ .../mapper/upload/UploadHistoryMapper.xml | 101 +++ .../mapper/upload/UploadTemplateMapper.xml | 141 ++++ ga-frontend/src/App.tsx | 2 + ga-frontend/src/api/uploadTemplate.ts | 151 ++++ .../src/components/common/MenuIcon.tsx | 1 + .../src/components/common/TemplateUpload.tsx | 213 +++++ .../pages/system/UploadTemplateManager.tsx | 753 ++++++++++++++++++ 29 files changed, 2991 insertions(+) create mode 100644 ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/DynamicInsertMapper.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/LookupMapper.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/UploadResultResp.java create mode 100644 ga-api/src/main/java/com/ga/api/service/upload/UploadService.java create mode 100644 ga-api/src/main/resources/mapper/upload/DynamicInsertMapper.xml create mode 100644 ga-api/src/main/resources/mapper/upload/LookupMapper.xml create mode 100644 ga-common/src/main/resources/db/migration/V19__업로드템플릿.sql create mode 100644 ga-core/src/main/java/com/ga/core/mapper/upload/UploadColumnMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/upload/UploadHistoryMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/upload/UploadTemplateMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateColumnVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateVO.java create mode 100644 ga-core/src/main/resources/mapper/upload/UploadColumnMapper.xml create mode 100644 ga-core/src/main/resources/mapper/upload/UploadHistoryMapper.xml create mode 100644 ga-core/src/main/resources/mapper/upload/UploadTemplateMapper.xml create mode 100644 ga-frontend/src/api/uploadTemplate.ts create mode 100644 ga-frontend/src/components/common/TemplateUpload.tsx create mode 100644 ga-frontend/src/pages/system/UploadTemplateManager.tsx 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 new file mode 100644 index 0000000..f153bba --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java @@ -0,0 +1,44 @@ +package com.ga.api.controller.upload; + +import com.ga.api.service.upload.UploadResultResp; +import com.ga.api.service.upload.UploadService; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +@Tag(name = "엑셀 업로드") +@RestController +@RequiredArgsConstructor +public class UploadController { + + private final UploadService uploadService; + + @Operation(summary = "엑셀 업로드 처리", + description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.") + @PostMapping("/api/upload/{templateCode}") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE") + public ApiResponse upload(@PathVariable String templateCode, + @RequestParam("file") MultipartFile file) { + UploadResultResp result = uploadService.processUpload(templateCode, file); + return ApiResponse.ok(result); + } + + @Operation(summary = "업로드 미리보기 (상위 5행)", + description = "DB 에 저장하지 않고 변환 결과만 반환한다. limit 파라미터로 행 수 조정 가능.") + @PostMapping("/api/upload-templates/{templateCode}/preview") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") + public ApiResponse>> preview( + @PathVariable String templateCode, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "limit", defaultValue = "5") int limit) { + List> rows = uploadService.preview(templateCode, file, limit); + return ApiResponse.ok(rows); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java b/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java new file mode 100644 index 0000000..08482ed --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java @@ -0,0 +1,140 @@ +package com.ga.api.controller.upload; + +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.common.util.SecurityUtil; +import com.ga.core.mapper.upload.UploadColumnMapper; +import com.ga.core.mapper.upload.UploadHistoryMapper; +import com.ga.core.mapper.upload.UploadTemplateMapper; +import com.ga.core.vo.upload.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "업로드 템플릿 관리") +@RestController +@RequestMapping("/api/upload-templates") +@RequiredArgsConstructor +public class UploadTemplateController { + + private final UploadTemplateMapper templateMapper; + private final UploadColumnMapper columnMapper; + private final UploadHistoryMapper historyMapper; + + @Operation(summary = "템플릿 목록 조회") + @GetMapping + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") + public ApiResponse> list(@ModelAttribute UploadTemplateSearchParam param) { + param.startPage(); + return ApiResponse.ok(PageResponse.of(templateMapper.selectList(param))); + } + + @Operation(summary = "템플릿 상세 조회 (컬럼 포함)") + @GetMapping("/{templateId}") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") + public ApiResponse detail(@PathVariable Long templateId) { + UploadTemplateResp resp = templateMapper.selectById(templateId); + if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); + resp.setColumns(columnMapper.selectByTemplateId(templateId)); + return ApiResponse.ok(resp); + } + + @Operation(summary = "템플릿 등록") + @PostMapping + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE") + @DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template") + @Transactional + public ApiResponse create(@Valid @RequestBody UploadTemplateSaveReq req) { + if (templateMapper.existsByCode(req.getTemplateCode()) > 0) { + throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 템플릿 코드입니다: " + req.getTemplateCode()); + } + UploadTemplateVO vo = req.toVO(); + vo.setCreatedBy(SecurityUtil.getCurrentUserId()); + templateMapper.insert(vo); + + if (req.getColumns() != null && !req.getColumns().isEmpty()) { + columnMapper.insertBatch(vo.getTemplateId(), req.getColumns()); + } + return ApiResponse.ok(vo.getTemplateId()); + } + + @Operation(summary = "템플릿 수정") + @PutMapping("/{templateId}") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE") + @DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template") + @Transactional + public ApiResponse update(@PathVariable Long templateId, + @Valid @RequestBody UploadTemplateSaveReq req) { + if (templateMapper.selectById(templateId) == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + UploadTemplateVO vo = req.toVO(); + vo.setTemplateId(templateId); + vo.setUpdatedBy(SecurityUtil.getCurrentUserId()); + templateMapper.update(vo); + + if (req.getColumns() != null) { + columnMapper.deleteByTemplateId(templateId); + if (!req.getColumns().isEmpty()) { + columnMapper.insertBatch(templateId, req.getColumns()); + } + } + return ApiResponse.ok(); + } + + @Operation(summary = "템플릿 삭제") + @DeleteMapping("/{templateId}") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE") + @DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template") + @Transactional + public ApiResponse delete(@PathVariable Long templateId) { + if (templateMapper.selectById(templateId) == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + columnMapper.deleteByTemplateId(templateId); + templateMapper.delete(templateId); + return ApiResponse.ok(); + } + + @Operation(summary = "템플릿 컬럼 목록 조회") + @GetMapping("/{templateId}/columns") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") + public ApiResponse> columns(@PathVariable Long templateId) { + return ApiResponse.ok(columnMapper.selectByTemplateId(templateId)); + } + + @Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)") + @PutMapping("/{templateId}/columns") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE") + @DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template_column") + @Transactional + public ApiResponse updateColumns(@PathVariable Long templateId, + @RequestBody List columns) { + if (templateMapper.selectById(templateId) == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + columnMapper.deleteByTemplateId(templateId); + if (columns != null && !columns.isEmpty()) { + columnMapper.insertBatch(templateId, columns); + } + return ApiResponse.ok(); + } + + @Operation(summary = "템플릿 업로드 이력 조회") + @GetMapping("/{templateId}/history") + @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") + public ApiResponse> history(@PathVariable Long templateId, + @ModelAttribute UploadTemplateSearchParam param) { + param.startPage(); + return ApiResponse.ok(PageResponse.of(historyMapper.selectByTemplate(templateId, param))); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/DynamicInsertMapper.java b/ga-api/src/main/java/com/ga/api/service/upload/DynamicInsertMapper.java new file mode 100644 index 0000000..6d672c2 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/DynamicInsertMapper.java @@ -0,0 +1,23 @@ +package com.ga.api.service.upload; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 업로드 템플릿에 따른 동적 INSERT 매퍼. + * + * targetTable 은 반드시 UploadService 의 whitelist 검증 후 전달해야 한다. + * (SQL Injection 방지) + */ +@Mapper +public interface DynamicInsertMapper { + + int insertRow(@Param("targetTable") String targetTable, + @Param("row") Map row); + + int insertBatch(@Param("targetTable") String targetTable, + @Param("rows") List> rows); +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java b/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java new file mode 100644 index 0000000..7a6bac3 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/LookupCache.java @@ -0,0 +1,105 @@ +package com.ga.api.service.upload; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.core.vo.upload.UploadTemplateColumnVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * LOOKUP 변환 규칙 최적화 캐시. + * + * processUpload() 시작 시 LOOKUP 컬럼의 참조 테이블을 한 번에 로드하여 + * 행 처리마다 DB 조회하는 것을 방지한다 (70만건 × DB 조회 = 지옥 → HashMap.get() = 즉시). + * + * 캐시 구조: Map< "table:sourceField:targetField" → Map > + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LookupCache { + + private final LookupMapper lookupMapper; + + /** 허용 테이블 whitelist (SQL Injection 방지) */ + private static final Set ALLOWED_TABLES = Set.of( + "recruit_ledger", "maintain_ledger", "contract", "agent", + "commission_rate", "payout_rule", "chargeback_rule", + "exception_ledger", "payment", + "common_code", "organization", "grade", "insurance_company", "product" + ); + + /** 컬럼 이름 허용 패턴 (영문/숫자/밑줄만) */ + private static final Pattern FIELD_PATTERN = + Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$"); + + // 요청 범위 캐시 (processUpload 한 번에만 유효) + private final Map> cache = new HashMap<>(); + + /** + * LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload. + */ + public void preload(List columns) { + cache.clear(); + + columns.stream() + .filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule())) + .forEach(c -> { + String key = buildKey(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField()); + if (!cache.containsKey(key)) { + validateTableAndFields(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField()); + + List> rows = lookupMapper.selectAll( + c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField()); + + Map map = new HashMap<>(rows.size() * 2); + for (Map row : rows) { + Object src = row.get("source_value"); + Object tgt = row.get("target_value"); + if (src != null) { + map.put(src.toString(), tgt); + } + } + cache.put(key, map); + log.debug("LookupCache preloaded: {} → {} entries", key, map.size()); + } + }); + } + + /** + * O(1) 조회. + */ + public Object lookup(String table, String sourceField, String value, String targetField) { + if (value == null) return null; + String key = buildKey(table, sourceField, targetField); + Map map = cache.get(key); + if (map == null) return null; + return map.get(value); + } + + private String buildKey(String table, String sourceField, String targetField) { + return table + ":" + sourceField + ":" + targetField; + } + + private void validateTableAndFields(String table, String sourceField, String targetField) { + if (table == null || !ALLOWED_TABLES.contains(table)) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "LOOKUP 허용되지 않는 테이블: " + table); + } + if (sourceField == null || !FIELD_PATTERN.matcher(sourceField).matches()) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "LOOKUP 허용되지 않는 sourceField: " + sourceField); + } + if (targetField == null || !FIELD_PATTERN.matcher(targetField).matches()) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "LOOKUP 허용되지 않는 targetField: " + targetField); + } + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/LookupMapper.java b/ga-api/src/main/java/com/ga/api/service/upload/LookupMapper.java new file mode 100644 index 0000000..a21196b --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/LookupMapper.java @@ -0,0 +1,21 @@ +package com.ga.api.service.upload; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * LOOKUP 변환 규칙에 사용하는 동적 SELECT 매퍼. + * + * table/field 는 반드시 LookupCache 의 whitelist 검증 후 전달해야 한다. + * (${} 로 동적 테이블/컬럼 이름 처리 — SQL Injection 방지 필수) + */ +@Mapper +public interface LookupMapper { + + List> selectAll(@Param("table") String table, + @Param("sourceField") String sourceField, + @Param("targetField") String targetField); +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java new file mode 100644 index 0000000..c062a8f --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java @@ -0,0 +1,293 @@ +package com.ga.api.service.upload; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.core.vo.upload.UploadTemplateColumnVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 12종 변환 규칙 실행기. + * + * 지원 규칙: TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / + * LOOKUP / CODE_MAP / FIXED / REPLACE / SUBSTRING / CONCAT / DEFAULT + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TransformEngine { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final LookupCache lookupCache; + + /** + * 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환. + * + * @param row xlsx-streamer Row + * @param columns 템플릿 컬럼 정의 + * @return { targetField → 변환된 값 } + */ + public Map transform(Row row, List columns) { + Map result = new LinkedHashMap<>(); + + for (UploadTemplateColumnVO col : columns) { + try { + Object rawValue = extractRaw(row, col); + Object transformed = applyTransform(rawValue, col, row, columns); + result.put(col.getTargetField(), transformed); + } catch (BizException e) { + throw e; + } catch (Exception e) { + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, + "컬럼[" + col.getTargetField() + "] 변환 오류: " + e.getMessage()); + } + } + + return result; + } + + /** + * 단일 컬럼에 변환 규칙을 적용. + */ + public Object applyTransform(Object value, UploadTemplateColumnVO col, + Row row, List allColumns) { + String rule = col.getTransformRule(); + + // FIXED 는 원본 값 무시 + if ("FIXED".equalsIgnoreCase(rule)) { + return col.getFixedValue(); + } + + // DEFAULT: 값 없을 때만 적용 + if ("DEFAULT".equalsIgnoreCase(rule)) { + return (value == null || value.toString().isBlank()) ? col.getDefaultValue() : value; + } + + if (rule == null || rule.isBlank()) { + return applyTargetTypeCast(value, col); + } + + return switch (rule.toUpperCase()) { + case "TRIM" -> value == null ? null : value.toString().trim(); + case "UPPER" -> value == null ? null : value.toString().trim().toUpperCase(); + case "LOWER" -> value == null ? null : value.toString().trim().toLowerCase(); + case "DATE" -> applyDate(value, col); + case "DIVIDE" -> applyDivide(value, col); + case "MULTIPLY" -> applyMultiply(value, col); + case "LOOKUP" -> applyLookup(value, col); + case "CODE_MAP" -> applyCodeMap(value, col); + case "REPLACE" -> applyReplace(value, col); + case "SUBSTRING" -> applySubstring(value, col); + case "CONCAT" -> applyConcat(row, col); + default -> applyTargetTypeCast(value, col); + }; + } + + // ========================================================= + // Raw 값 추출 + // ========================================================= + + private Object extractRaw(Row row, UploadTemplateColumnVO col) { + if ("FIXED".equalsIgnoreCase(col.getSourceType())) { + return col.getFixedValue(); + } + if ("EXPRESSION".equalsIgnoreCase(col.getSourceType())) { + return col.getFixedValue(); + } + + // COLUMN: sourceColumn (0-based index) + Integer srcCol = col.getSourceColumn(); + if (srcCol == null) return null; + + Cell cell = row.getCell(srcCol); + if (cell == null) return null; + + return readCell(cell); + } + + private Object readCell(Cell cell) { + return switch (cell.getCellType()) { + case STRING -> { + String v = cell.getStringCellValue(); + yield (v == null || v.isBlank()) ? null : v; + } + case NUMERIC -> { + if (DateUtil.isCellDateFormatted(cell)) { + yield cell.getDateCellValue().toInstant() + .atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + } + double d = cell.getNumericCellValue(); + yield (d == Math.floor(d) && !Double.isInfinite(d)) + ? String.valueOf((long) d) + : String.valueOf(d); + } + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + case FORMULA -> { + try { yield cell.getStringCellValue(); } + catch (Exception e) { yield String.valueOf(cell.getNumericCellValue()); } + } + default -> null; + }; + } + + // ========================================================= + // 개별 변환 규칙 + // ========================================================= + + private Object applyDate(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + if (value instanceof LocalDate) return value.toString(); + String s = value.toString().trim(); + if (s.isBlank()) return null; + String fmt = col.getDateFormat() != null ? col.getDateFormat() : "yyyyMMdd"; + try { + LocalDate d = LocalDate.parse(s, DateTimeFormatter.ofPattern(fmt)); + return d.toString(); + } catch (Exception e) { + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, + "날짜 파싱 실패 [" + s + "] format=" + fmt); + } + } + + private Object applyDivide(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + Integer divisor = col.getNumberDivide(); + if (divisor == null || divisor == 0) return value; + BigDecimal bd = toBigDecimal(value); + return bd.divide(BigDecimal.valueOf(divisor), 10, java.math.RoundingMode.HALF_UP) + .stripTrailingZeros(); + } + + private Object applyMultiply(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + Integer multiplier = col.getNumberMultiply(); + if (multiplier == null) return value; + BigDecimal bd = toBigDecimal(value); + return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros(); + } + + private Object applyLookup(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + Object result = lookupCache.lookup( + col.getLookupTable(), + col.getLookupSourceField(), + value.toString(), + col.getLookupTargetField()); + if (result == null && col.getDefaultValue() != null) { + return col.getDefaultValue(); + } + return result; + } + + private Object applyCodeMap(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + Object mapped = lookupCache.lookup( + "common_code", "code", value.toString(), "code_name"); + return mapped != null ? mapped : (col.getDefaultValue() != null ? col.getDefaultValue() : value); + } + + private Object applyReplace(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + String s = value.toString(); + String param = col.getTransformParam(); + if (param == null || param.isBlank()) return s; + try { + JsonNode node = MAPPER.readTree(param); + String find = node.path("find").asText(""); + String replace = node.path("replace").asText(""); + return s.replace(find, replace); + } catch (Exception e) { + log.warn("REPLACE param 파싱 실패: {}", param); + return s; + } + } + + private Object applySubstring(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + String s = value.toString(); + String param = col.getTransformParam(); + if (param == null || param.isBlank()) return s; + try { + JsonNode node = MAPPER.readTree(param); + int start = node.path("start").asInt(0); + int end = node.path("end").asInt(s.length()); + end = Math.min(end, s.length()); + start = Math.max(0, Math.min(start, end)); + return s.substring(start, end); + } catch (Exception e) { + log.warn("SUBSTRING param 파싱 실패: {}", param); + return s; + } + } + + private Object applyConcat(Row row, UploadTemplateColumnVO col) { + String param = col.getTransformParam(); + if (param == null || param.isBlank()) return null; + try { + JsonNode node = MAPPER.readTree(param); + JsonNode colsNode = node.path("columns"); + String separator = node.path("separator").asText(""); + + StringBuilder sb = new StringBuilder(); + for (JsonNode c : colsNode) { + int colIdx = c.asInt(-1); + if (colIdx < 0) continue; + Cell cell = row.getCell(colIdx); + if (cell != null) { + Object v = readCell(cell); + if (v != null) { + if (sb.length() > 0) sb.append(separator); + sb.append(v); + } + } + } + return sb.toString(); + } catch (Exception e) { + log.warn("CONCAT param 파싱 실패: {}", param); + return null; + } + } + + private Object applyTargetTypeCast(Object value, UploadTemplateColumnVO col) { + if (value == null) return null; + String targetType = col.getTargetType(); + if (targetType == null) return value; + String s = value.toString().trim(); + return switch (targetType.toUpperCase()) { + case "NUMBER", "INTEGER" -> { + try { yield Long.parseLong(s.replace(",", "")); } + catch (NumberFormatException e) { yield s; } + } + case "DECIMAL" -> { + try { yield new BigDecimal(s.replace(",", "")); } + catch (NumberFormatException e) { yield s; } + } + case "DATE" -> applyDate(value, col); + default -> s; + }; + } + + private BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal bd) return bd; + try { + return new BigDecimal(value.toString().replace(",", "").trim()); + } catch (NumberFormatException e) { + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, + "숫자 변환 실패: " + value); + } + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadResultResp.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadResultResp.java new file mode 100644 index 0000000..5151957 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadResultResp.java @@ -0,0 +1,44 @@ +package com.ga.api.service.upload; + +import com.ga.common.excel.ExcelErrorRow; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 업로드 처리 결과 응답. + */ +@Data +@Builder +public class UploadResultResp { + + private Long historyId; + private int totalCount; + private int successCount; + private int errorCount; + private int skipCount; + private Long errorFileId; + private int durationSec; + + /** 에러 행 목록 (최대 maxErrorCount 까지) */ + private List errors; + + @Data + @Builder + public static class UploadErrorRow { + private int rowNumber; + private String fieldName; + private String value; + private String errorMessage; + + public static UploadErrorRow from(ExcelErrorRow e) { + return UploadErrorRow.builder() + .rowNumber(e.getRowNumber()) + .fieldName(e.getFieldName()) + .value(e.getValue()) + .errorMessage(e.getErrorMessage()) + .build(); + } + } +} 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 new file mode 100644 index 0000000..5375b5d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java @@ -0,0 +1,379 @@ +package com.ga.api.service.upload; + +import com.ga.common.excel.ExcelErrorRow; +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.file.FileService; +import com.ga.common.util.SecurityUtil; +import com.ga.core.mapper.upload.UploadColumnMapper; +import com.ga.core.mapper.upload.UploadHistoryMapper; +import com.ga.core.mapper.upload.UploadTemplateMapper; +import com.ga.core.vo.upload.UploadHistoryVO; +import com.ga.core.vo.upload.UploadTemplateColumnVO; +import com.ga.core.vo.upload.UploadTemplateVO; +import com.monitorjbl.xlsx.StreamingReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.lang.NonNull; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 엑셀 업로드 템플릿 처리 핵심 서비스. + * + * processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장 + * preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UploadService { + + private final UploadTemplateMapper templateMapper; + private final UploadColumnMapper columnMapper; + private final UploadHistoryMapper historyMapper; + private final TransformEngine transformEngine; + private final LookupCache lookupCache; + private final DynamicInsertMapper dynamicInsertMapper; + private final FileService fileService; + + /** target_table whitelist — SQL Injection 방지 */ + private static final Set ALLOWED_TARGET_TABLES = Set.of( + "recruit_ledger", "maintain_ledger", "contract", "agent", + "commission_rate", "payout_rule", "chargeback_rule", + "exception_ledger", "payment" + ); + + // ============================================================ + // 메인: 업로드 처리 + // ============================================================ + + @Transactional + public UploadResultResp processUpload(String templateCode, MultipartFile file) { + long startMs = System.currentTimeMillis(); + + // 1. 템플릿 + 컬럼 로드 + UploadTemplateVO template = loadTemplate(templateCode); + List columns = columnMapper.selectByTemplateId(template.getTemplateId()); + if (columns.isEmpty()) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "컬럼 정의가 없는 템플릿입니다: " + templateCode); + } + + validateTargetTable(template.getTargetTable()); + + // 2. LOOKUP 캐시 사전 로드 (70만건 × DB 조회 방지) + lookupCache.preload(columns); + + // 3. history 레코드 생성 (PROCESSING 상태) + UploadHistoryVO history = initHistory(template, file); + historyMapper.insert(history); + + List errors = new ArrayList<>(); + int successCount = 0; + int skipCount = 0; + int totalCount = 0; + int maxErrors = template.getMaxErrorCount() != null ? template.getMaxErrorCount() : 100; + int batchSize = template.getBatchSize() != null ? template.getBatchSize() : 1000; + int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2; + int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0; + + List> batch = new ArrayList<>(batchSize); + + try (InputStream is = file.getInputStream(); + Workbook wb = StreamingReader.builder() + .rowCacheSize(100) + .bufferSize(4096) + .open(is)) { + + Sheet sheet = wb.getSheetAt(sheetIndex); + int rowNum = 0; + + for (Row row : sheet) { + rowNum++; + if (rowNum < dataStartRow) continue; + + if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) { + skipCount++; + continue; + } + + totalCount++; + + if (errors.size() >= maxErrors) { + skipCount++; + continue; + } + + try { + Map mapped = transformEngine.transform(row, columns); + validateRow(mapped, columns, rowNum, errors); + + final int currentRow = rowNum; + boolean rowHasError = errors.stream().anyMatch(e -> e.getRowNumber() == currentRow); + if (!rowHasError) { + batch.add(mapped); + if (batch.size() >= batchSize) { + dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); + successCount += batch.size(); + batch.clear(); + log.debug("Batch flush: {} rows processed so far", totalCount); + } + } + } catch (BizException e) { + errors.add(new ExcelErrorRow(rowNum, null, null, e.getDisplayMessage())); + } catch (Exception e) { + errors.add(new ExcelErrorRow(rowNum, null, null, e.getMessage())); + } + } + + if (!batch.isEmpty()) { + dynamicInsertMapper.insertBatch(template.getTargetTable(), batch); + successCount += batch.size(); + batch.clear(); + } + + } catch (BizException e) { + updateHistoryFail(history.getHistoryId(), e.getDisplayMessage()); + throw e; + } catch (Exception e) { + log.error("Upload processing error: templateCode={}", templateCode, e); + updateHistoryFail(history.getHistoryId(), e.getMessage()); + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); + } + + int durationSec = (int) ((System.currentTimeMillis() - startMs) / 1000); + int errorCount = errors.size(); + String status = errorCount == 0 ? "SUCCESS" : (successCount > 0 ? "PARTIAL" : "FAIL"); + + Long errorFileId = null; + if (!errors.isEmpty()) { + errorFileId = createErrorFile(errors, file.getOriginalFilename()); + } + + historyMapper.updateStatus(history.getHistoryId(), status, null, + successCount, errorCount, skipCount, totalCount, errorFileId, durationSec); + + return UploadResultResp.builder() + .historyId(history.getHistoryId()) + .totalCount(totalCount) + .successCount(successCount) + .errorCount(errorCount) + .skipCount(skipCount) + .errorFileId(errorFileId) + .durationSec(durationSec) + .errors(errors.stream().map(UploadResultResp.UploadErrorRow::from).toList()) + .build(); + } + + // ============================================================ + // 미리보기: DB INSERT 없이 변환 결과만 반환 + // ============================================================ + + public List> preview(String templateCode, MultipartFile file, int limit) { + UploadTemplateVO template = loadTemplate(templateCode); + List columns = columnMapper.selectByTemplateId(template.getTemplateId()); + + lookupCache.preload(columns); + + List> result = new ArrayList<>(); + int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2; + int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0; + + try (InputStream is = file.getInputStream(); + Workbook wb = StreamingReader.builder() + .rowCacheSize(100) + .bufferSize(4096) + .open(is)) { + + Sheet sheet = wb.getSheetAt(sheetIndex); + int rowNum = 0; + + for (Row row : sheet) { + rowNum++; + if (rowNum < dataStartRow) continue; + if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue; + + try { + result.add(transformEngine.transform(row, columns)); + } catch (Exception e) { + result.add(Map.of("_error", "행 " + rowNum + ": " + e.getMessage())); + } + + if (result.size() >= limit) break; + } + + } catch (Exception e) { + log.error("Preview error: templateCode={}", templateCode, e); + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); + } + + return result; + } + + // ============================================================ + // 내부 헬퍼 + // ============================================================ + + private UploadTemplateVO loadTemplate(String templateCode) { + UploadTemplateVO template = templateMapper.selectByCode(templateCode); + if (template == null) { + throw new BizException(ErrorCode.NOT_FOUND, "업로드 템플릿을 찾을 수 없습니다: " + templateCode); + } + if (!"Y".equalsIgnoreCase(template.getIsActive())) { + throw new BizException(ErrorCode.INVALID_STATUS, "비활성 템플릿입니다: " + templateCode); + } + return template; + } + + private void validateTargetTable(String targetTable) { + if (targetTable == null || !ALLOWED_TARGET_TABLES.contains(targetTable)) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "허용되지 않는 대상 테이블: " + targetTable); + } + } + + private UploadHistoryVO initHistory(UploadTemplateVO template, MultipartFile file) { + UploadHistoryVO h = new UploadHistoryVO(); + h.setTemplateId(template.getTemplateId()); + h.setTemplateCode(template.getTemplateCode()); + h.setFileName(file.getOriginalFilename()); + h.setFileSize(file.getSize()); + h.setStatus("PROCESSING"); + h.setStartedAt(LocalDateTime.now()); + h.setCreatedBy(SecurityUtil.getCurrentUserId()); + return h; + } + + private void updateHistoryFail(Long historyId, String message) { + historyMapper.updateStatus(historyId, "FAIL", message, + 0, 0, 0, 0, null, 0); + } + + private void validateRow(Map row, List columns, + int rowNum, List errors) { + for (UploadTemplateColumnVO col : columns) { + Object value = row.get(col.getTargetField()); + String strVal = value == null ? null : value.toString(); + + // 필수값 검증 + if ("Y".equalsIgnoreCase(col.getIsRequired()) && (strVal == null || strVal.isBlank())) { + String msg = col.getValidationMessage() != null + ? col.getValidationMessage() + : "[" + col.getTargetField() + "] 은(는) 필수입니다"; + errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg)); + continue; + } + + // 정규식 검증 + if (strVal != null && col.getValidationRegex() != null && !col.getValidationRegex().isBlank()) { + if (!Pattern.matches(col.getValidationRegex(), strVal)) { + String msg = col.getValidationMessage() != null + ? col.getValidationMessage() + : "[" + col.getTargetField() + "] 형식이 올바르지 않습니다: " + strVal; + errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg)); + } + } + } + } + + private boolean isEmptyRow(Row row) { + if (row == null) return true; + for (org.apache.poi.ss.usermodel.Cell cell : row) { + if (cell != null && cell.getCellType() != CellType.BLANK) { + String v = ""; + try { v = cell.getStringCellValue(); } catch (Exception ignored) {} + if (!v.isBlank()) return false; + } + } + return true; + } + + /** + * 에러 행 목록을 엑셀로 만들어 file_storage 에 적재하고 fileId 반환. + * 실패 시 업로드를 중단하지 않고 null 반환. + */ + private Long createErrorFile(List errors, String originalFilename) { + try { + byte[] bytes = buildErrorExcelBytes(errors); + String name = "error_" + (originalFilename != null ? originalFilename : "upload.xlsx"); + BytesMultipartFile mockFile = new BytesMultipartFile(name, bytes); + com.ga.common.file.FileVO saved = fileService.upload(mockFile, "UPLOAD_ERROR"); + return saved.getFileId(); + } catch (Exception e) { + log.warn("에러 파일 생성 실패 (업로드는 계속 진행)", e); + return null; + } + } + + private byte[] buildErrorExcelBytes(List errors) throws Exception { + try (org.apache.poi.xssf.streaming.SXSSFWorkbook wb = + new org.apache.poi.xssf.streaming.SXSSFWorkbook(100)) { + + org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("errors"); + + org.apache.poi.ss.usermodel.Row header = sheet.createRow(0); + header.createCell(0).setCellValue("행번호"); + header.createCell(1).setCellValue("필드"); + header.createCell(2).setCellValue("값"); + header.createCell(3).setCellValue("오류내용"); + + int rowIdx = 1; + for (ExcelErrorRow e : errors) { + org.apache.poi.ss.usermodel.Row row = sheet.createRow(rowIdx++); + row.createCell(0).setCellValue(e.getRowNumber()); + row.createCell(1).setCellValue(e.getFieldName() != null ? e.getFieldName() : ""); + row.createCell(2).setCellValue(e.getValue() != null ? e.getValue() : ""); + row.createCell(3).setCellValue(e.getErrorMessage() != null ? e.getErrorMessage() : ""); + } + + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + wb.write(baos); + wb.dispose(); + return baos.toByteArray(); + } + } + + // ============================================================ + // byte[] → MultipartFile 래퍼 (MockMultipartFile 의존성 제거) + // ============================================================ + + private static class BytesMultipartFile implements MultipartFile { + private final String filename; + private final byte[] content; + + BytesMultipartFile(String filename, byte[] content) { + this.filename = filename; + this.content = content; + } + + @Override @NonNull public String getName() { return "file"; } + @Override public String getOriginalFilename() { return filename; } + @Override public String getContentType() { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } + @Override public boolean isEmpty() { return content == null || content.length == 0; } + @Override public long getSize() { return content == null ? 0 : content.length; } + @Override @NonNull public byte[] getBytes() { return content == null ? new byte[0] : content; } + @Override @NonNull public java.io.InputStream getInputStream() { + return new java.io.ByteArrayInputStream(content == null ? new byte[0] : content); + } + @Override public void transferTo(@NonNull java.io.File dest) throws java.io.IOException { + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) { + fos.write(content); + } + } + } +} diff --git a/ga-api/src/main/resources/mapper/upload/DynamicInsertMapper.xml b/ga-api/src/main/resources/mapper/upload/DynamicInsertMapper.xml new file mode 100644 index 0000000..ea60401 --- /dev/null +++ b/ga-api/src/main/resources/mapper/upload/DynamicInsertMapper.xml @@ -0,0 +1,42 @@ + + + + + + + INSERT INTO ${targetTable} ( + + ${col} + + ) VALUES ( + + #{val} + + ) + + + + + INSERT INTO ${targetTable} ( + + ${col} + + ) VALUES + + ( + + #{val} + + ) + + + + diff --git a/ga-api/src/main/resources/mapper/upload/LookupMapper.xml b/ga-api/src/main/resources/mapper/upload/LookupMapper.xml new file mode 100644 index 0000000..3944a9d --- /dev/null +++ b/ga-api/src/main/resources/mapper/upload/LookupMapper.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/ga-common/src/main/resources/db/migration/V19__업로드템플릿.sql b/ga-common/src/main/resources/db/migration/V19__업로드템플릿.sql new file mode 100644 index 0000000..e19c9e8 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V19__업로드템플릿.sql @@ -0,0 +1,127 @@ +-- V19: 엑셀 업로드 매핑 템플릿 시스템 + +-- 1. upload_template (마스터) +CREATE TABLE upload_template ( + template_id BIGSERIAL PRIMARY KEY, + template_code VARCHAR(50) NOT NULL UNIQUE, + template_name VARCHAR(100) NOT NULL, + category VARCHAR(30) NOT NULL, -- RECRUIT/MAINTAIN/CONTRACT/AGENT/CUSTOM + target_table VARCHAR(50) NOT NULL, + company_code VARCHAR(20), -- NULL이면 범용 + header_row INT NOT NULL DEFAULT 1, + data_start_row INT NOT NULL DEFAULT 2, + sheet_index INT NOT NULL DEFAULT 0, + sheet_name VARCHAR(50), + file_encoding VARCHAR(20) DEFAULT 'UTF-8', + delimiter VARCHAR(5), + skip_empty_row CHAR(1) DEFAULT 'Y', + max_error_count INT DEFAULT 1000, + batch_size INT DEFAULT 1000, + description VARCHAR(200), + is_active CHAR(1) DEFAULT 'Y', + created_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_at TIMESTAMP, + updated_by BIGINT +); + +CREATE INDEX idx_upload_tpl_category ON upload_template(category); +CREATE INDEX idx_upload_tpl_company ON upload_template(company_code) WHERE company_code IS NOT NULL; + +-- 2. upload_template_column (컬럼 매핑 규칙) +CREATE TABLE upload_template_column ( + column_id BIGSERIAL PRIMARY KEY, + template_id BIGINT NOT NULL REFERENCES upload_template(template_id) ON DELETE CASCADE, + sort_order INT NOT NULL, + source_type VARCHAR(10) NOT NULL DEFAULT 'COLUMN', -- COLUMN/FIXED/EXPRESSION + source_column VARCHAR(10), -- 엑셀 컬럼 (A,B,C 또는 0,1,2) + source_header VARCHAR(100), + target_field VARCHAR(50) NOT NULL, + target_type VARCHAR(20) NOT NULL DEFAULT 'STRING', -- STRING/NUMBER/DECIMAL/DATE/INTEGER + transform_rule VARCHAR(30), -- TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT/REQUIRED + transform_param VARCHAR(200), -- JSON 또는 단순값 + date_format VARCHAR(30), + number_divide INT, + number_multiply INT, + fixed_value VARCHAR(200), + default_value VARCHAR(200), + lookup_table VARCHAR(50), + lookup_source_field VARCHAR(50), + lookup_target_field VARCHAR(50), + code_map_group VARCHAR(50), + is_required CHAR(1) DEFAULT 'N', + validation_regex VARCHAR(200), + validation_message VARCHAR(200), + description VARCHAR(200), + UNIQUE (template_id, sort_order) +); + +CREATE INDEX idx_upload_tpl_col ON upload_template_column(template_id); + +-- 3. upload_history (업로드 이력) +CREATE TABLE upload_history ( + history_id BIGSERIAL PRIMARY KEY, + template_id BIGINT NOT NULL REFERENCES upload_template(template_id), + template_code VARCHAR(50) NOT NULL, + file_name VARCHAR(300) NOT NULL, + file_size BIGINT, + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + skip_count INT NOT NULL DEFAULT 0, + error_file_id BIGINT, -- file_storage 느슨한 참조 (제약 없음) + status VARCHAR(10) NOT NULL DEFAULT 'PROCESSING', -- PROCESSING/COMPLETED/FAILED/CANCELLED + error_message TEXT, + started_at TIMESTAMP NOT NULL DEFAULT NOW(), + finished_at TIMESTAMP, + duration_sec INT, + created_by BIGINT +); + +CREATE INDEX idx_upload_history_tpl ON upload_history(template_id, started_at DESC); +CREATE INDEX idx_upload_history_started ON upload_history(started_at DESC); + +-- 4. 메뉴 등록 (GRP_SYSTEM 하위, sort_order 7) +INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon, + menu_level, sort_order, is_visible, is_active, parent_menu_id, description) +VALUES ('SYSTEM_UPLOAD_TEMPLATE', '엑셀 업로드 템플릿', 'PAGE', '/system/upload-templates', + 'system/UploadTemplateManager', 'file-spreadsheet', 2, 7, 'Y', 'Y', + (SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'), + '보험사별 엑셀 업로드 양식 매핑 관리') +ON CONFLICT (menu_code) DO NOTHING; + +-- 5. 메뉴 권한 4건 정의 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT menu_id, 'DELETE', '삭제', 4 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 6. SUPER_ADMIN / ADMIN : READ + CREATE + UPDATE + DELETE +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, m.menu_id, p.perm_code, 'Y' + FROM role r + CROSS JOIN menu m + CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE')) AS p(perm_code) + WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND m.menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- 7. MANAGER : READ 만 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, m.menu_id, 'READ', 'Y' + FROM role r + CROSS JOIN menu m + WHERE r.role_code = 'MANAGER' + AND m.menu_code = 'SYSTEM_UPLOAD_TEMPLATE' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-core/src/main/java/com/ga/core/mapper/upload/UploadColumnMapper.java b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadColumnMapper.java new file mode 100644 index 0000000..f13f689 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadColumnMapper.java @@ -0,0 +1,18 @@ +package com.ga.core.mapper.upload; + +import com.ga.core.vo.upload.UploadTemplateColumnVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UploadColumnMapper { + + List selectByTemplateId(@Param("templateId") Long templateId); + + int insertBatch(@Param("templateId") Long templateId, + @Param("columns") List columns); + + int deleteByTemplateId(@Param("templateId") Long templateId); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/upload/UploadHistoryMapper.java b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadHistoryMapper.java new file mode 100644 index 0000000..e734dcc --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadHistoryMapper.java @@ -0,0 +1,30 @@ +package com.ga.core.mapper.upload; + +import com.ga.core.vo.upload.UploadHistoryResp; +import com.ga.core.vo.upload.UploadHistoryVO; +import com.ga.core.vo.upload.UploadTemplateSearchParam; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UploadHistoryMapper { + + List selectList(UploadTemplateSearchParam param); + + List selectByTemplate(@Param("templateId") Long templateId, + UploadTemplateSearchParam param); + + int insert(UploadHistoryVO vo); + + int updateStatus(@Param("historyId") Long historyId, + @Param("status") String status, + @Param("errorMessage") String errorMessage, + @Param("successCount") Integer successCount, + @Param("errorCount") Integer errorCount, + @Param("skipCount") Integer skipCount, + @Param("totalCount") Integer totalCount, + @Param("errorFileId") Long errorFileId, + @Param("durationSec") Integer durationSec); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/upload/UploadTemplateMapper.java b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadTemplateMapper.java new file mode 100644 index 0000000..6d2ebf6 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/upload/UploadTemplateMapper.java @@ -0,0 +1,27 @@ +package com.ga.core.mapper.upload; + +import com.ga.core.vo.upload.UploadTemplateResp; +import com.ga.core.vo.upload.UploadTemplateSearchParam; +import com.ga.core.vo.upload.UploadTemplateVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UploadTemplateMapper { + + List selectList(UploadTemplateSearchParam param); + + UploadTemplateResp selectById(@Param("templateId") Long templateId); + + UploadTemplateVO selectByCode(@Param("templateCode") String templateCode); + + int existsByCode(@Param("templateCode") String templateCode); + + int insert(UploadTemplateVO vo); + + int update(UploadTemplateVO vo); + + int delete(@Param("templateId") Long templateId); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryResp.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryResp.java new file mode 100644 index 0000000..db498c4 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryResp.java @@ -0,0 +1,31 @@ +package com.ga.core.vo.upload; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * upload_history 응답 VO (목록/상세용). + */ +@Data +public class UploadHistoryResp { + + private Long historyId; + private Long templateId; + private String templateCode; + private String templateName; + private String fileName; + private Long fileSize; + private Integer totalCount; + private Integer successCount; + private Integer errorCount; + private Integer skipCount; + private Long errorFileId; + private String status; + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private Integer durationSec; + private Long createdBy; + private String createdByName; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryVO.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryVO.java new file mode 100644 index 0000000..786c35d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadHistoryVO.java @@ -0,0 +1,32 @@ +package com.ga.core.vo.upload; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * upload_history 테이블 1:1 VO. + */ +@Data +public class UploadHistoryVO { + + private Long historyId; + private Long templateId; + private String templateCode; + private String fileName; + private Long fileSize; + private Integer totalCount; + private Integer successCount; + private Integer errorCount; + private Integer skipCount; + private Long errorFileId; + + /** PROCESSING / SUCCESS / PARTIAL / FAIL */ + private String status; + + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private Integer durationSec; + private Long createdBy; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateColumnVO.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateColumnVO.java new file mode 100644 index 0000000..8b4507e --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateColumnVO.java @@ -0,0 +1,39 @@ +package com.ga.core.vo.upload; + +import lombok.Data; + +/** + * upload_template_column 테이블 1:1 VO. + */ +@Data +public class UploadTemplateColumnVO { + + private Long columnId; + private Long templateId; + private Integer sortOrder; + + /** COLUMN / FIXED / EXPRESSION */ + private String sourceType; + private Integer sourceColumn; + private String sourceHeader; + private String targetField; + + /** STRING / NUMBER / DECIMAL / DATE / INTEGER */ + private String targetType; + + private String transformRule; + private String transformParam; + private String dateFormat; + private Integer numberDivide; + private Integer numberMultiply; + private String fixedValue; + private String defaultValue; + private String lookupTable; + private String lookupSourceField; + private String lookupTargetField; + private String codeMappingGroup; + private String isRequired; + private String validationRegex; + private String validationMessage; + private String description; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateResp.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateResp.java new file mode 100644 index 0000000..913c449 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateResp.java @@ -0,0 +1,37 @@ +package com.ga.core.vo.upload; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * upload_template 목록/상세 응답 VO. + * 상세 조회 시 columns 가 채워진다. + */ +@Data +public class UploadTemplateResp { + + private Long templateId; + private String templateCode; + private String templateName; + private String category; + private String targetTable; + private String companyCode; + private Integer headerRow; + private Integer dataStartRow; + private Integer sheetIndex; + private String sheetName; + private String fileEncoding; + private String delimiter; + private String skipEmptyRow; + private Integer maxErrorCount; + private Integer batchSize; + private String description; + private String isActive; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** 상세 조회 시 포함 (목록에서는 null) */ + private List columns; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSaveReq.java new file mode 100644 index 0000000..67b8544 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSaveReq.java @@ -0,0 +1,63 @@ +package com.ga.core.vo.upload; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 업로드 템플릿 등록/수정 요청. + */ +@Data +public class UploadTemplateSaveReq { + + @NotBlank + private String templateCode; + + @NotBlank + private String templateName; + + private String category; + + @NotBlank + private String targetTable; + + private String companyCode; + private Integer headerRow; + private Integer dataStartRow; + private Integer sheetIndex; + private String sheetName; + private String fileEncoding; + private String delimiter; + private String skipEmptyRow; + private Integer maxErrorCount; + private Integer batchSize; + private String description; + private String isActive; + + @Valid + private List columns; + + public UploadTemplateVO toVO() { + UploadTemplateVO vo = new UploadTemplateVO(); + vo.setTemplateCode(this.templateCode); + vo.setTemplateName(this.templateName); + vo.setCategory(this.category); + vo.setTargetTable(this.targetTable); + vo.setCompanyCode(this.companyCode); + vo.setHeaderRow(this.headerRow != null ? this.headerRow : 1); + vo.setDataStartRow(this.dataStartRow != null ? this.dataStartRow : 2); + vo.setSheetIndex(this.sheetIndex != null ? this.sheetIndex : 0); + vo.setSheetName(this.sheetName); + vo.setFileEncoding(this.fileEncoding != null ? this.fileEncoding : "UTF-8"); + vo.setDelimiter(this.delimiter); + vo.setSkipEmptyRow(this.skipEmptyRow != null ? this.skipEmptyRow : "Y"); + vo.setMaxErrorCount(this.maxErrorCount != null ? this.maxErrorCount : 100); + vo.setBatchSize(this.batchSize != null ? this.batchSize : 1000); + vo.setDescription(this.description); + vo.setIsActive(this.isActive != null ? this.isActive : "Y"); + return vo; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSearchParam.java new file mode 100644 index 0000000..f0d0c90 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateSearchParam.java @@ -0,0 +1,17 @@ +package com.ga.core.vo.upload; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 업로드 템플릿 검색 파라미터. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class UploadTemplateSearchParam extends SearchParam { + + private String category; + private String companyCode; + private String isActive; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateVO.java b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateVO.java new file mode 100644 index 0000000..39da568 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/upload/UploadTemplateVO.java @@ -0,0 +1,34 @@ +package com.ga.core.vo.upload; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * upload_template 테이블 1:1 VO. + */ +@Data +public class UploadTemplateVO { + + private Long templateId; + private String templateCode; + private String templateName; + private String category; + private String targetTable; + private String companyCode; + private Integer headerRow; + private Integer dataStartRow; + private Integer sheetIndex; + private String sheetName; + private String fileEncoding; + private String delimiter; + private String skipEmptyRow; + private Integer maxErrorCount; + private Integer batchSize; + private String description; + private String isActive; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; +} diff --git a/ga-core/src/main/resources/mapper/upload/UploadColumnMapper.xml b/ga-core/src/main/resources/mapper/upload/UploadColumnMapper.xml new file mode 100644 index 0000000..2e8ce9c --- /dev/null +++ b/ga-core/src/main/resources/mapper/upload/UploadColumnMapper.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO upload_template_column ( + template_id, sort_order, source_type, source_column, source_header, + target_field, target_type, transform_rule, transform_param, date_format, + number_divide, number_multiply, fixed_value, default_value, + lookup_table, lookup_source_field, lookup_target_field, code_map_group, + is_required, validation_regex, validation_message, description + ) VALUES + + ( + #{templateId}, #{c.sortOrder}, #{c.sourceType}, #{c.sourceColumn}, #{c.sourceHeader}, + #{c.targetField}, #{c.targetType}, #{c.transformRule}, #{c.transformParam}, #{c.dateFormat}, + #{c.numberDivide}, #{c.numberMultiply}, #{c.fixedValue}, #{c.defaultValue}, + #{c.lookupTable}, #{c.lookupSourceField}, #{c.lookupTargetField}, #{c.codeMappingGroup}, + #{c.isRequired}, #{c.validationRegex}, #{c.validationMessage}, #{c.description} + ) + + + + + DELETE FROM upload_template_column WHERE template_id = #{templateId} + + + diff --git a/ga-core/src/main/resources/mapper/upload/UploadHistoryMapper.xml b/ga-core/src/main/resources/mapper/upload/UploadHistoryMapper.xml new file mode 100644 index 0000000..f700304 --- /dev/null +++ b/ga-core/src/main/resources/mapper/upload/UploadHistoryMapper.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + h.history_id, h.template_id, h.template_code, + t.template_name, + h.file_name, h.file_size, h.total_count, h.success_count, h.error_count, + h.skip_count, h.error_file_id, h.status, h.error_message, + h.started_at, h.finished_at, h.duration_sec, h.created_by, + u.login_id AS created_by_name + + + + + + + + INSERT INTO upload_history ( + template_id, template_code, file_name, file_size, + total_count, success_count, error_count, skip_count, + error_file_id, status, error_message, started_at, finished_at, + duration_sec, created_by + ) VALUES ( + #{templateId}, #{templateCode}, #{fileName}, #{fileSize}, + #{totalCount}, #{successCount}, #{errorCount}, #{skipCount}, + #{errorFileId}, #{status}, #{errorMessage}, #{startedAt}, #{finishedAt}, + #{durationSec}, #{createdBy} + ) + + + + UPDATE upload_history SET + status = #{status}, + error_message = #{errorMessage}, + success_count = #{successCount}, + error_count = #{errorCount}, + skip_count = #{skipCount}, + total_count = #{totalCount}, + error_file_id = #{errorFileId}, + duration_sec = #{durationSec}, + finished_at = NOW() + WHERE history_id = #{historyId} + + + diff --git a/ga-core/src/main/resources/mapper/upload/UploadTemplateMapper.xml b/ga-core/src/main/resources/mapper/upload/UploadTemplateMapper.xml new file mode 100644 index 0000000..a910f9f --- /dev/null +++ b/ga-core/src/main/resources/mapper/upload/UploadTemplateMapper.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + t.template_id, t.template_code, t.template_name, t.category, t.target_table, + t.company_code, t.header_row, t.data_start_row, t.sheet_index, t.sheet_name, + t.file_encoding, t.delimiter, t.skip_empty_row, t.max_error_count, t.batch_size, + t.description, t.is_active, t.created_at, t.updated_at + + + + + + + + + + + + INSERT INTO upload_template ( + template_code, template_name, category, target_table, company_code, + header_row, data_start_row, sheet_index, sheet_name, file_encoding, + delimiter, skip_empty_row, max_error_count, batch_size, + description, is_active, created_at, created_by + ) VALUES ( + #{templateCode}, #{templateName}, #{category}, #{targetTable}, #{companyCode}, + #{headerRow}, #{dataStartRow}, #{sheetIndex}, #{sheetName}, #{fileEncoding}, + #{delimiter}, #{skipEmptyRow}, #{maxErrorCount}, #{batchSize}, + #{description}, #{isActive}, NOW(), #{createdBy} + ) + + + + UPDATE upload_template SET + template_name = #{templateName}, + category = #{category}, + target_table = #{targetTable}, + company_code = #{companyCode}, + header_row = #{headerRow}, + data_start_row = #{dataStartRow}, + sheet_index = #{sheetIndex}, + sheet_name = #{sheetName}, + file_encoding = #{fileEncoding}, + delimiter = #{delimiter}, + skip_empty_row = #{skipEmptyRow}, + max_error_count = #{maxErrorCount}, + batch_size = #{batchSize}, + description = #{description}, + is_active = #{isActive}, + updated_at = NOW(), + updated_by = #{updatedBy} + WHERE template_id = #{templateId} + + + + DELETE FROM upload_template WHERE template_id = #{templateId} + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 235b2bf..601d348 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -35,6 +35,7 @@ import MenuManage from '@/pages/system/MenuManage'; import CodeList from '@/pages/system/CodeList'; import SystemConfig from '@/pages/system/SystemConfig'; import SystemLog from '@/pages/system/SystemLog'; +import UploadTemplateManager from '@/pages/system/UploadTemplateManager'; function RequireAuth({ children }: { children: React.ReactNode }) { const token = localStorage.getItem('accessToken'); @@ -88,6 +89,7 @@ export default function App() { } /> } /> } /> + } /> ); diff --git a/ga-frontend/src/api/uploadTemplate.ts b/ga-frontend/src/api/uploadTemplate.ts new file mode 100644 index 0000000..2ba30c5 --- /dev/null +++ b/ga-frontend/src/api/uploadTemplate.ts @@ -0,0 +1,151 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface UploadTemplateResp { + templateId: number; + templateCode: string; + templateName: string; + category: string; + targetTable: string; + companyCode?: string; + headerRow: number; + dataStartRow: number; + sheetIndex: number; + sheetName?: string; + batchSize: number; + isActive: string; + description?: string; + updatedAt?: string; +} + +export interface UploadTemplateDetailResp extends UploadTemplateResp { + columns: UploadTemplateColumnVO[]; +} + +export interface UploadTemplateColumnVO { + columnId?: number; + templateId?: number; + sortOrder: number; + sourceType: string; + sourceColumn?: string; + sourceHeader?: string; + targetField: string; + targetType: string; + transformRule?: string; + transformParam?: string; + dateFormat?: string; + numberDivide?: number; + numberMultiply?: number; + fixedValue?: string; + defaultValue?: string; + lookupTable?: string; + lookupSourceField?: string; + lookupTargetField?: string; + codeMapGroup?: string; + isRequired?: string; + validationRegex?: string; + validationMessage?: string; + description?: string; +} + +export interface UploadTemplateQueryParams { + category?: string; + companyCode?: string; + isActive?: string; + searchKeyword?: string; + pageNum?: number; + pageSize?: number; +} + +export interface UploadTemplateSaveReq { + templateCode: string; + templateName: string; + category: string; + targetTable: string; + companyCode?: string; + headerRow?: number; + dataStartRow?: number; + sheetIndex?: number; + sheetName?: string; + batchSize?: number; + isActive?: string; + description?: string; +} + +export interface UploadHistoryResp { + historyId: number; + templateId: number; + fileName: string; + totalCount: number; + successCount: number; + errorCount: number; + skipCount: number; + uploadedBy?: string; + uploadedAt?: string; + durationSec?: number; +} + +export interface UploadResultResp { + totalCount: number; + successCount: number; + errorCount: number; + skipCount: number; + errorFileId?: number; + durationSec?: number; + errors?: { + rowNumber: number; + fieldName?: string; + value?: string; + errorMessage: string; + }[]; +} + +export interface PreviewRowResp { + rowNumber: number; + [field: string]: unknown; +} + +export const uploadTemplateApi = { + list: (params: UploadTemplateQueryParams) => + unwrap>(api.get('/api/upload-templates', { params })), + + detail: (id: number) => + unwrap(api.get(`/api/upload-templates/${id}`)), + + create: (req: UploadTemplateSaveReq) => + unwrap(api.post('/api/upload-templates', req)), + + update: (id: number, req: UploadTemplateSaveReq) => + unwrap(api.put(`/api/upload-templates/${id}`, req)), + + remove: (id: number) => + unwrap(api.delete(`/api/upload-templates/${id}`)), + + getColumns: (id: number) => + unwrap(api.get(`/api/upload-templates/${id}/columns`)), + + saveColumns: (id: number, columns: UploadTemplateColumnVO[]) => + unwrap(api.put(`/api/upload-templates/${id}/columns`, columns)), + + history: (id: number) => + unwrap(api.get(`/api/upload-templates/${id}/history`)), + + preview: (templateCode: string, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return unwrap( + api.post(`/api/upload-templates/${templateCode}/preview`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + ); + }, + + upload: (templateCode: string, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return unwrap( + api.post(`/api/upload/${templateCode}`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }), + ); + }, +}; diff --git a/ga-frontend/src/components/common/MenuIcon.tsx b/ga-frontend/src/components/common/MenuIcon.tsx index b957a7a..ca764a5 100644 --- a/ga-frontend/src/components/common/MenuIcon.tsx +++ b/ga-frontend/src/components/common/MenuIcon.tsx @@ -37,6 +37,7 @@ const ICON_MAP: Record = { SYSTEM_LOG: , SYSTEM_FILE: , SYSTEM_CONFIG: , + SYSTEM_UPLOAD_TEMPLATE: , COMPANY: , PRODUCT: , RECON: , diff --git a/ga-frontend/src/components/common/TemplateUpload.tsx b/ga-frontend/src/components/common/TemplateUpload.tsx new file mode 100644 index 0000000..ca1f458 --- /dev/null +++ b/ga-frontend/src/components/common/TemplateUpload.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { Button, message, Modal, Progress, Select, Space, Table, Typography, Upload } from 'antd'; +import type { RcFile, UploadProps } from 'antd/es/upload'; +import { IconDownload, IconUpload } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { uploadTemplateApi, type UploadResultResp } from '@/api/uploadTemplate'; + +const { Dragger } = Upload; +const { Text } = Typography; + +interface Props { + /** 이 카테고리의 템플릿만 Select 에 표시 */ + category?: string; + /** 업로드 완료 후 콜백 */ + onComplete?: (result: UploadResultResp) => void; +} + +export default function TemplateUpload({ category, onComplete }: Props) { + const [templateCode, setTemplateCode] = useState(); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + const [resultVisible, setResultVisible] = useState(false); + + // 카테고리에 맞는 템플릿 목록 + const { data: templates = [], isLoading: templatesLoading } = useQuery({ + queryKey: ['uploadTemplate', 'list', 'select', category], + queryFn: async () => { + const res = await uploadTemplateApi.list({ + category, + isActive: 'Y', + pageSize: 200, + pageNum: 1, + }); + return res.list; + }, + }); + + const templateOptions = templates.map((t) => ({ + value: t.templateCode, + label: `[${t.category}] ${t.templateName}`, + })); + + const handleUpload = async (file: RcFile): Promise => { + if (!templateCode) { + message.warning('템플릿을 먼저 선택하세요'); + return false; + } + + setUploading(true); + setProgress(10); + + // 진행률 시뮬레이션 (실제 서버 스트림이 없으므로 tick 방식) + const ticker = setInterval(() => { + setProgress((p) => { + if (p >= 85) { clearInterval(ticker); return p; } + return p + 5; + }); + }, 400); + + try { + const res = await uploadTemplateApi.upload(templateCode, file); + clearInterval(ticker); + setProgress(100); + setResult(res); + setResultVisible(true); + onComplete?.(res); + } catch { + clearInterval(ticker); + setProgress(0); + } finally { + setUploading(false); + } + + return false; // antd Upload 기본 동작 방지 + }; + + const draggerProps: UploadProps = { + name: 'file', + accept: '.xlsx,.xls,.csv', + multiple: false, + showUploadList: false, + beforeUpload: handleUpload, + disabled: uploading || !templateCode, + }; + + const handleDownloadError = () => { + if (!result?.errorFileId) return; + // 오류 파일 다운로드 — 백엔드 파일 ID 기반 URL + window.open(`/api/files/${result.errorFileId}/download`, '_blank'); + }; + + return ( + + {/* 템플릿 선택 */} +
+ + 업로드 템플릿 선택 + + onChange(row._key, 'dateFormat', e.target.value)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'DIVIDE') { + return ( + onChange(row._key, 'numberDivide', v ?? undefined)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'MULTIPLY') { + return ( + onChange(row._key, 'numberMultiply', v ?? undefined)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'LOOKUP') { + return ( + onChange(row._key, 'lookupTable', e.target.value)} + style={{ width: 120 }} + /> + ); + } + if (rule === 'CODE_MAP') { + return ( + onChange(row._key, 'codeMapGroup', e.target.value)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'FIXED') { + return ( + onChange(row._key, 'fixedValue', e.target.value)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'DEFAULT') { + return ( + onChange(row._key, 'defaultValue', e.target.value)} + style={{ width: 100 }} + /> + ); + } + if (rule === 'REPLACE' || rule === 'SUBSTRING' || rule === 'CONCAT') { + return ( + onChange(row._key, 'transformParam', e.target.value)} + style={{ width: 120 }} + /> + ); + } + return -; +} + +// ─── 미리보기 파일 선택 모달 ─────────────────────────────────────────────── +function PreviewModal({ templateCode, onClose }: { templateCode: string; onClose: () => void }) { + const [loading, setLoading] = useState(false); + const [previewRows, setPreviewRows] = useState[]>([]); + const [columns, setColumns] = useState<{ title: string; dataIndex: string }[]>([]); + const inputRef = useRef(null); + + const handleFile = async (file: File) => { + setLoading(true); + try { + const rows = await uploadTemplateApi.preview(templateCode, file); + if (rows.length > 0) { + const keys = Object.keys(rows[0]).filter((k) => k !== 'rowNumber'); + setColumns([ + { title: '행번호', dataIndex: 'rowNumber' }, + ...keys.map((k) => ({ title: k, dataIndex: k })), + ]); + } + setPreviewRows(rows as Record[]); + } catch { + message.error('미리보기 실패'); + } finally { + setLoading(false); + } + }; + + return ( + 닫기} + > + + + + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> + + Excel / CSV 파일 선택 → 첫 5행 결과 표시 + + + {loading && } + {previewRows.length > 0 && ( + + )} + + + ); +} + +// ─── 이력 Drawer ────────────────────────────────────────────────────────── +function HistoryDrawer({ templateId, onClose }: { templateId: number; onClose: () => void }) { + const { data = [], isLoading } = useQuery({ + queryKey: ['uploadTemplate', 'history', templateId], + queryFn: () => uploadTemplateApi.history(templateId), + }); + + return ( + +
{v} }, + { title: '오류', dataIndex: 'errorCount', width: 70, align: 'right', + render: (v) => v > 0 ? {v} : {v} }, + { title: '건너뜀', dataIndex: 'skipCount', width: 70, align: 'right' }, + { title: '소요(초)', dataIndex: 'durationSec', width: 80, align: 'right' }, + { title: '업로더', dataIndex: 'uploadedBy', width: 100 }, + { title: '업로드일시', dataIndex: 'uploadedAt', width: 160 }, + ]} + /> + + ); +} + +// ─── 컬럼 매핑 편집기 ───────────────────────────────────────────────────── +function ColumnEditor({ template, onSaved }: { + template: UploadTemplateResp; + onSaved: () => void; +}) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [showPreview, setShowPreview] = useState(false); + + // 선택된 템플릿 변경 시 컬럼 로드 + useQuery({ + queryKey: ['uploadTemplate', 'columns', template.templateId], + queryFn: () => uploadTemplateApi.getColumns(template.templateId), + onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)), + } as Parameters[0]); + + const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => { + setRows((prev) => + prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)), + ); + }; + + const addRow = () => { + setRows((prev) => [ + ...prev, + { + _key: makeKey(), + sortOrder: prev.length + 1, + sourceType: 'COLUMN_INDEX', + targetField: '', + targetType: 'STRING', + }, + ]); + }; + + const removeRow = (key: string) => { + setRows((prev) => prev.filter((r) => r._key !== key).map((r, i) => ({ ...r, sortOrder: i + 1 }))); + }; + + const moveRow = (key: string, dir: 'up' | 'down') => { + setRows((prev) => { + const idx = prev.findIndex((r) => r._key === key); + if (idx < 0) return prev; + if (dir === 'up' && idx === 0) return prev; + if (dir === 'down' && idx === prev.length - 1) return prev; + const next = [...prev]; + const swap = dir === 'up' ? idx - 1 : idx + 1; + [next[idx], next[swap]] = [next[swap], next[idx]]; + return next.map((r, i) => ({ ...r, sortOrder: i + 1 })); + }); + }; + + const save = async () => { + setSaving(true); + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const payload: UploadTemplateColumnVO[] = rows.map(({ _key, ...rest }) => rest); + await uploadTemplateApi.saveColumns(template.templateId, payload); + message.success('컬럼 매핑이 저장되었습니다'); + onSaved(); + } catch { + // request.ts interceptor handles toast + } finally { + setSaving(false); + } + }; + + if (loading) return ; + + return ( +
+ {/* 헤더 */} +
+ + {template.templateName} + {template.templateCode} + {template.category} + + + + + } + loading={saving} + onClick={save} + > + 저장 + + +
+ + {/* 컬럼 테이블 */} +
+ + size="small" + rowKey="_key" + dataSource={rows} + pagination={false} + scroll={{ x: 1100 }} + footer={() => ( + } + onClick={addRow} + > + 행 추가 + + )} + columns={[ + { + title: '순서', dataIndex: 'sortOrder', width: 60, align: 'center', + render: (_, r) => ( + + +