feat: 엑셀 업로드 템플릿 시스템 (V19)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<UploadResultResp> 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<List<Map<String, Object>>> preview(
|
||||||
|
@PathVariable String templateCode,
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "5") int limit) {
|
||||||
|
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
|
||||||
|
return ApiResponse.ok(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PageResponse<UploadTemplateResp>> 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<UploadTemplateResp> 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<Long> 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<Void> 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<Void> 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<List<UploadTemplateColumnVO>> 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<Void> updateColumns(@PathVariable Long templateId,
|
||||||
|
@RequestBody List<UploadTemplateColumnVO> 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<PageResponse<UploadHistoryResp>> history(@PathVariable Long templateId,
|
||||||
|
@ModelAttribute UploadTemplateSearchParam param) {
|
||||||
|
param.startPage();
|
||||||
|
return ApiResponse.ok(PageResponse.of(historyMapper.selectByTemplate(templateId, param)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> row);
|
||||||
|
|
||||||
|
int insertBatch(@Param("targetTable") String targetTable,
|
||||||
|
@Param("rows") List<Map<String, Object>> rows);
|
||||||
|
}
|
||||||
@@ -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<sourceValue, targetValue> >
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LookupCache {
|
||||||
|
|
||||||
|
private final LookupMapper lookupMapper;
|
||||||
|
|
||||||
|
/** 허용 테이블 whitelist (SQL Injection 방지) */
|
||||||
|
private static final Set<String> 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<String, Map<String, Object>> cache = new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload.
|
||||||
|
*/
|
||||||
|
public void preload(List<UploadTemplateColumnVO> 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<Map<String, Object>> rows = lookupMapper.selectAll(
|
||||||
|
c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
||||||
|
|
||||||
|
Map<String, Object> map = new HashMap<>(rows.size() * 2);
|
||||||
|
for (Map<String, Object> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, Object>> selectAll(@Param("table") String table,
|
||||||
|
@Param("sourceField") String sourceField,
|
||||||
|
@Param("targetField") String targetField);
|
||||||
|
}
|
||||||
@@ -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<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
|
||||||
|
Map<String, Object> 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<UploadTemplateColumnVO> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UploadErrorRow> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<UploadTemplateColumnVO> 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<ExcelErrorRow> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> preview(String templateCode, MultipartFile file, int limit) {
|
||||||
|
UploadTemplateVO template = loadTemplate(templateCode);
|
||||||
|
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
||||||
|
|
||||||
|
lookupCache.preload(columns);
|
||||||
|
|
||||||
|
List<Map<String, Object>> 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<String, Object> row, List<UploadTemplateColumnVO> columns,
|
||||||
|
int rowNum, List<ExcelErrorRow> 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<ExcelErrorRow> 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<ExcelErrorRow> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.api.service.upload.DynamicInsertMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
동적 단건 INSERT.
|
||||||
|
targetTable 은 UploadService 의 whitelist 검증 후 전달한다.
|
||||||
|
row Map 의 key/value 를 동적으로 조립한다.
|
||||||
|
-->
|
||||||
|
<insert id="insertRow">
|
||||||
|
INSERT INTO ${targetTable} (
|
||||||
|
<foreach collection="row" index="col" item="val" separator=",">
|
||||||
|
${col}
|
||||||
|
</foreach>
|
||||||
|
) VALUES (
|
||||||
|
<foreach collection="row" index="col" item="val" separator=",">
|
||||||
|
#{val}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
동적 배치 INSERT.
|
||||||
|
rows 첫 번째 맵의 key 순서로 컬럼명을 결정한다.
|
||||||
|
-->
|
||||||
|
<insert id="insertBatch">
|
||||||
|
INSERT INTO ${targetTable} (
|
||||||
|
<foreach collection="rows[0]" index="col" item="val" separator=",">
|
||||||
|
${col}
|
||||||
|
</foreach>
|
||||||
|
) VALUES
|
||||||
|
<foreach collection="rows" item="row" separator=",">
|
||||||
|
(
|
||||||
|
<foreach collection="row" index="col" item="val" separator=",">
|
||||||
|
#{val}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.api.service.upload.LookupMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
동적 테이블/컬럼 조회.
|
||||||
|
${} 사용 — LookupCache.preload() 에서 whitelist 검증 후 호출한다.
|
||||||
|
-->
|
||||||
|
<select id="selectAll" resultType="java.util.HashMap">
|
||||||
|
SELECT ${sourceField} AS source_value,
|
||||||
|
${targetField} AS target_value
|
||||||
|
FROM ${table}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -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;
|
||||||
@@ -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<UploadTemplateColumnVO> selectByTemplateId(@Param("templateId") Long templateId);
|
||||||
|
|
||||||
|
int insertBatch(@Param("templateId") Long templateId,
|
||||||
|
@Param("columns") List<UploadTemplateColumnVO> columns);
|
||||||
|
|
||||||
|
int deleteByTemplateId(@Param("templateId") Long templateId);
|
||||||
|
}
|
||||||
@@ -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<UploadHistoryResp> selectList(UploadTemplateSearchParam param);
|
||||||
|
|
||||||
|
List<UploadHistoryResp> 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);
|
||||||
|
}
|
||||||
@@ -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<UploadTemplateResp> 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<UploadTemplateColumnVO> columns;
|
||||||
|
}
|
||||||
@@ -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<UploadTemplateColumnVO> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.upload.UploadColumnMapper">
|
||||||
|
|
||||||
|
<resultMap id="ColumnMap" type="com.ga.core.vo.upload.UploadTemplateColumnVO">
|
||||||
|
<id property="columnId" column="column_id"/>
|
||||||
|
<result property="templateId" column="template_id"/>
|
||||||
|
<result property="sortOrder" column="sort_order"/>
|
||||||
|
<result property="sourceType" column="source_type"/>
|
||||||
|
<result property="sourceColumn" column="source_column"/>
|
||||||
|
<result property="sourceHeader" column="source_header"/>
|
||||||
|
<result property="targetField" column="target_field"/>
|
||||||
|
<result property="targetType" column="target_type"/>
|
||||||
|
<result property="transformRule" column="transform_rule"/>
|
||||||
|
<result property="transformParam" column="transform_param"/>
|
||||||
|
<result property="dateFormat" column="date_format"/>
|
||||||
|
<result property="numberDivide" column="number_divide"/>
|
||||||
|
<result property="numberMultiply" column="number_multiply"/>
|
||||||
|
<result property="fixedValue" column="fixed_value"/>
|
||||||
|
<result property="defaultValue" column="default_value"/>
|
||||||
|
<result property="lookupTable" column="lookup_table"/>
|
||||||
|
<result property="lookupSourceField" column="lookup_source_field"/>
|
||||||
|
<result property="lookupTargetField" column="lookup_target_field"/>
|
||||||
|
<result property="codeMappingGroup" column="code_map_group"/>
|
||||||
|
<result property="isRequired" column="is_required"/>
|
||||||
|
<result property="validationRegex" column="validation_regex"/>
|
||||||
|
<result property="validationMessage" column="validation_message"/>
|
||||||
|
<result property="description" column="description"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="selectByTemplateId" resultMap="ColumnMap">
|
||||||
|
SELECT column_id, 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
|
||||||
|
FROM upload_template_column
|
||||||
|
WHERE template_id = #{templateId}
|
||||||
|
ORDER BY sort_order ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- forEach 로 다건 INSERT -->
|
||||||
|
<insert id="insertBatch">
|
||||||
|
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
|
||||||
|
<foreach collection="columns" item="c" separator=",">
|
||||||
|
(
|
||||||
|
#{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}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="deleteByTemplateId">
|
||||||
|
DELETE FROM upload_template_column WHERE template_id = #{templateId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.upload.UploadHistoryMapper">
|
||||||
|
|
||||||
|
<resultMap id="HistoryRespMap" type="com.ga.core.vo.upload.UploadHistoryResp">
|
||||||
|
<id property="historyId" column="history_id"/>
|
||||||
|
<result property="templateId" column="template_id"/>
|
||||||
|
<result property="templateCode" column="template_code"/>
|
||||||
|
<result property="templateName" column="template_name"/>
|
||||||
|
<result property="fileName" column="file_name"/>
|
||||||
|
<result property="fileSize" column="file_size"/>
|
||||||
|
<result property="totalCount" column="total_count"/>
|
||||||
|
<result property="successCount" column="success_count"/>
|
||||||
|
<result property="errorCount" column="error_count"/>
|
||||||
|
<result property="skipCount" column="skip_count"/>
|
||||||
|
<result property="errorFileId" column="error_file_id"/>
|
||||||
|
<result property="status" column="status"/>
|
||||||
|
<result property="errorMessage" column="error_message"/>
|
||||||
|
<result property="startedAt" column="started_at"/>
|
||||||
|
<result property="finishedAt" column="finished_at"/>
|
||||||
|
<result property="durationSec" column="duration_sec"/>
|
||||||
|
<result property="createdBy" column="created_by"/>
|
||||||
|
<result property="createdByName" column="created_by_name"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="historyCols">
|
||||||
|
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
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
|
||||||
|
resultMap="HistoryRespMap">
|
||||||
|
SELECT <include refid="historyCols"/>
|
||||||
|
FROM upload_history h
|
||||||
|
LEFT JOIN upload_template t ON t.template_id = h.template_id
|
||||||
|
LEFT JOIN users u ON u.user_id = h.created_by
|
||||||
|
<where>
|
||||||
|
<if test="searchKeyword != null and searchKeyword != ''">
|
||||||
|
AND (h.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
|
||||||
|
OR h.file_name LIKE CONCAT('%', #{searchKeyword}, '%'))
|
||||||
|
</if>
|
||||||
|
<if test="status != null and status != ''">
|
||||||
|
AND h.status = #{status}
|
||||||
|
</if>
|
||||||
|
<if test="startDate != null and startDate != ''">
|
||||||
|
AND h.started_at >= #{startDate}::timestamp
|
||||||
|
</if>
|
||||||
|
<if test="endDate != null and endDate != ''">
|
||||||
|
AND h.started_at < (#{endDate}::date + INTERVAL '1 day')
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY h.history_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectByTemplate" resultMap="HistoryRespMap">
|
||||||
|
SELECT <include refid="historyCols"/>
|
||||||
|
FROM upload_history h
|
||||||
|
LEFT JOIN upload_template t ON t.template_id = h.template_id
|
||||||
|
LEFT JOIN users u ON u.user_id = h.created_by
|
||||||
|
WHERE h.template_id = #{templateId}
|
||||||
|
<if test="param.status != null and param.status != ''">
|
||||||
|
AND h.status = #{param.status}
|
||||||
|
</if>
|
||||||
|
ORDER BY h.history_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadHistoryVO"
|
||||||
|
useGeneratedKeys="true" keyProperty="historyId">
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateStatus">
|
||||||
|
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}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.upload.UploadTemplateMapper">
|
||||||
|
|
||||||
|
<resultMap id="UploadTemplateVOMap" type="com.ga.core.vo.upload.UploadTemplateVO">
|
||||||
|
<id property="templateId" column="template_id"/>
|
||||||
|
<result property="templateCode" column="template_code"/>
|
||||||
|
<result property="templateName" column="template_name"/>
|
||||||
|
<result property="category" column="category"/>
|
||||||
|
<result property="targetTable" column="target_table"/>
|
||||||
|
<result property="companyCode" column="company_code"/>
|
||||||
|
<result property="headerRow" column="header_row"/>
|
||||||
|
<result property="dataStartRow" column="data_start_row"/>
|
||||||
|
<result property="sheetIndex" column="sheet_index"/>
|
||||||
|
<result property="sheetName" column="sheet_name"/>
|
||||||
|
<result property="fileEncoding" column="file_encoding"/>
|
||||||
|
<result property="delimiter" column="delimiter"/>
|
||||||
|
<result property="skipEmptyRow" column="skip_empty_row"/>
|
||||||
|
<result property="maxErrorCount" column="max_error_count"/>
|
||||||
|
<result property="batchSize" column="batch_size"/>
|
||||||
|
<result property="description" column="description"/>
|
||||||
|
<result property="isActive" column="is_active"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
<result property="createdBy" column="created_by"/>
|
||||||
|
<result property="updatedAt" column="updated_at"/>
|
||||||
|
<result property="updatedBy" column="updated_by"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<resultMap id="UploadTemplateRespMap" type="com.ga.core.vo.upload.UploadTemplateResp">
|
||||||
|
<id property="templateId" column="template_id"/>
|
||||||
|
<result property="templateCode" column="template_code"/>
|
||||||
|
<result property="templateName" column="template_name"/>
|
||||||
|
<result property="category" column="category"/>
|
||||||
|
<result property="targetTable" column="target_table"/>
|
||||||
|
<result property="companyCode" column="company_code"/>
|
||||||
|
<result property="headerRow" column="header_row"/>
|
||||||
|
<result property="dataStartRow" column="data_start_row"/>
|
||||||
|
<result property="sheetIndex" column="sheet_index"/>
|
||||||
|
<result property="sheetName" column="sheet_name"/>
|
||||||
|
<result property="fileEncoding" column="file_encoding"/>
|
||||||
|
<result property="delimiter" column="delimiter"/>
|
||||||
|
<result property="skipEmptyRow" column="skip_empty_row"/>
|
||||||
|
<result property="maxErrorCount" column="max_error_count"/>
|
||||||
|
<result property="batchSize" column="batch_size"/>
|
||||||
|
<result property="description" column="description"/>
|
||||||
|
<result property="isActive" column="is_active"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
<result property="updatedAt" column="updated_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="templateCols">
|
||||||
|
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
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
|
||||||
|
resultMap="UploadTemplateRespMap">
|
||||||
|
SELECT <include refid="templateCols"/>
|
||||||
|
FROM upload_template t
|
||||||
|
<where>
|
||||||
|
<if test="category != null and category != ''">
|
||||||
|
AND t.category = #{category}
|
||||||
|
</if>
|
||||||
|
<if test="companyCode != null and companyCode != ''">
|
||||||
|
AND t.company_code = #{companyCode}
|
||||||
|
</if>
|
||||||
|
<if test="isActive != null and isActive != ''">
|
||||||
|
AND t.is_active = #{isActive}
|
||||||
|
</if>
|
||||||
|
<if test="searchKeyword != null and searchKeyword != ''">
|
||||||
|
AND (t.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
|
||||||
|
OR t.template_name LIKE CONCAT('%', #{searchKeyword}, '%'))
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY t.template_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectById" resultMap="UploadTemplateRespMap">
|
||||||
|
SELECT <include refid="templateCols"/>
|
||||||
|
FROM upload_template t
|
||||||
|
WHERE t.template_id = #{templateId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectByCode" resultMap="UploadTemplateVOMap">
|
||||||
|
SELECT 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.created_by, t.updated_at, t.updated_by
|
||||||
|
FROM upload_template t
|
||||||
|
WHERE t.template_code = #{templateCode}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsByCode" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM upload_template WHERE template_code = #{templateCode}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadTemplateVO"
|
||||||
|
useGeneratedKeys="true" keyProperty="templateId">
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="update" parameterType="com.ga.core.vo.upload.UploadTemplateVO">
|
||||||
|
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}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="delete">
|
||||||
|
DELETE FROM upload_template WHERE template_id = #{templateId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -35,6 +35,7 @@ import MenuManage from '@/pages/system/MenuManage';
|
|||||||
import CodeList from '@/pages/system/CodeList';
|
import CodeList from '@/pages/system/CodeList';
|
||||||
import SystemConfig from '@/pages/system/SystemConfig';
|
import SystemConfig from '@/pages/system/SystemConfig';
|
||||||
import SystemLog from '@/pages/system/SystemLog';
|
import SystemLog from '@/pages/system/SystemLog';
|
||||||
|
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
@@ -88,6 +89,7 @@ export default function App() {
|
|||||||
<Route path="system/codes" element={<CodeList />} />
|
<Route path="system/codes" element={<CodeList />} />
|
||||||
<Route path="system/config" element={<SystemConfig />} />
|
<Route path="system/config" element={<SystemConfig />} />
|
||||||
<Route path="system/logs" element={<SystemLog />} />
|
<Route path="system/logs" element={<SystemLog />} />
|
||||||
|
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<PageResponse<UploadTemplateResp>>(api.get('/api/upload-templates', { params })),
|
||||||
|
|
||||||
|
detail: (id: number) =>
|
||||||
|
unwrap<UploadTemplateDetailResp>(api.get(`/api/upload-templates/${id}`)),
|
||||||
|
|
||||||
|
create: (req: UploadTemplateSaveReq) =>
|
||||||
|
unwrap<number>(api.post('/api/upload-templates', req)),
|
||||||
|
|
||||||
|
update: (id: number, req: UploadTemplateSaveReq) =>
|
||||||
|
unwrap<void>(api.put(`/api/upload-templates/${id}`, req)),
|
||||||
|
|
||||||
|
remove: (id: number) =>
|
||||||
|
unwrap<void>(api.delete(`/api/upload-templates/${id}`)),
|
||||||
|
|
||||||
|
getColumns: (id: number) =>
|
||||||
|
unwrap<UploadTemplateColumnVO[]>(api.get(`/api/upload-templates/${id}/columns`)),
|
||||||
|
|
||||||
|
saveColumns: (id: number, columns: UploadTemplateColumnVO[]) =>
|
||||||
|
unwrap<void>(api.put(`/api/upload-templates/${id}/columns`, columns)),
|
||||||
|
|
||||||
|
history: (id: number) =>
|
||||||
|
unwrap<UploadHistoryResp[]>(api.get(`/api/upload-templates/${id}/history`)),
|
||||||
|
|
||||||
|
preview: (templateCode: string, file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return unwrap<PreviewRowResp[]>(
|
||||||
|
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<UploadResultResp>(
|
||||||
|
api.post(`/api/upload/${templateCode}`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -37,6 +37,7 @@ const ICON_MAP: Record<string, ReactNode> = {
|
|||||||
SYSTEM_LOG: <IconClipboardList size={16} />,
|
SYSTEM_LOG: <IconClipboardList size={16} />,
|
||||||
SYSTEM_FILE: <IconFolder size={16} />,
|
SYSTEM_FILE: <IconFolder size={16} />,
|
||||||
SYSTEM_CONFIG: <IconSettings size={16} />,
|
SYSTEM_CONFIG: <IconSettings size={16} />,
|
||||||
|
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
|
||||||
COMPANY: <IconBuildingBank size={16} />,
|
COMPANY: <IconBuildingBank size={16} />,
|
||||||
PRODUCT: <IconFileSpreadsheet size={16} />,
|
PRODUCT: <IconFileSpreadsheet size={16} />,
|
||||||
RECON: <IconReportMoney size={16} />,
|
RECON: <IconReportMoney size={16} />,
|
||||||
|
|||||||
@@ -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<string | undefined>();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [result, setResult] = useState<UploadResultResp | null>(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<false> => {
|
||||||
|
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 (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
{/* 템플릿 선택 */}
|
||||||
|
<div>
|
||||||
|
<Text style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
|
||||||
|
업로드 템플릿 선택
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
placeholder="템플릿을 선택하세요"
|
||||||
|
loading={templatesLoading}
|
||||||
|
options={templateOptions}
|
||||||
|
value={templateCode}
|
||||||
|
onChange={setTemplateCode}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 드래그 업로드 */}
|
||||||
|
<Dragger {...draggerProps} style={{ padding: '8px 0' }}>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
<IconUpload size={32} color={!templateCode ? '#bbb' : '#185FA5'} />
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '8px 0 4px', fontSize: 14, fontWeight: 500,
|
||||||
|
color: !templateCode ? '#bbb' : undefined }}>
|
||||||
|
파일을 여기로 드래그하거나 클릭하여 선택
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||||
|
Excel (.xlsx, .xls) / CSV 지원
|
||||||
|
{!templateCode && ' · 먼저 템플릿을 선택하세요'}
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
|
||||||
|
{/* 진행률 */}
|
||||||
|
{uploading && (
|
||||||
|
<Progress
|
||||||
|
percent={progress}
|
||||||
|
status={progress === 100 ? 'success' : 'active'}
|
||||||
|
strokeColor={{ '0%': '#185FA5', '100%': '#0F6E56' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과 모달 */}
|
||||||
|
<Modal
|
||||||
|
open={resultVisible}
|
||||||
|
title="업로드 결과"
|
||||||
|
onCancel={() => { setResultVisible(false); setProgress(0); }}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
{result?.errorFileId && (
|
||||||
|
<Button
|
||||||
|
icon={<IconDownload size={14} />}
|
||||||
|
onClick={handleDownloadError}
|
||||||
|
>
|
||||||
|
오류 파일 다운로드
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="primary" onClick={() => { setResultVisible(false); setProgress(0); }}>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={[
|
||||||
|
{ key: 'total', label: '전체', value: result.totalCount },
|
||||||
|
{ key: 'success', label: '성공', value: result.successCount },
|
||||||
|
{ key: 'error', label: '오류', value: result.errorCount },
|
||||||
|
{ key: 'skip', label: '건너뜀', value: result.skipCount },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{ title: '구분', dataIndex: 'label', width: 100 },
|
||||||
|
{
|
||||||
|
title: '건수', dataIndex: 'value', align: 'right',
|
||||||
|
render: (v, r) => {
|
||||||
|
if (r.key === 'success') return <Text style={{ color: '#0F6E56', fontWeight: 600 }}>{v}</Text>;
|
||||||
|
if (r.key === 'error' && v > 0) return <Text type="danger" style={{ fontWeight: 600 }}>{v}</Text>;
|
||||||
|
return <Text>{v}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{result.durationSec !== undefined && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
소요 시간: {result.durationSec}초
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 오류 상세 (최대 10건) */}
|
||||||
|
{result.errors && result.errors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text strong style={{ color: '#E24B4A' }}>
|
||||||
|
오류 상세 (상위 {Math.min(result.errors.length, 10)}건)
|
||||||
|
</Text>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={result.errors.slice(0, 10)}
|
||||||
|
rowKey="rowNumber"
|
||||||
|
scroll={{ y: 200 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '행', dataIndex: 'rowNumber', width: 60 },
|
||||||
|
{ title: '필드', dataIndex: 'fieldName', width: 100 },
|
||||||
|
{ title: '값', dataIndex: 'value', width: 100, ellipsis: true },
|
||||||
|
{ title: '오류', dataIndex: 'errorMessage', ellipsis: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Card, Col, Divider, Drawer, Empty, Form, Input, InputNumber,
|
||||||
|
message, Modal, Row, Select, Space, Spin, Table, Tag, Tooltip, Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { ProCard, ProTable, ModalForm, ProFormText, ProFormSelect,
|
||||||
|
ProFormDigit, ProFormTextArea, type ActionType, type ProColumns,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy,
|
||||||
|
IconArrowUp, IconArrowDown,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
|
import PermissionButton from '@/components/common/PermissionButton';
|
||||||
|
import CodeBadge from '@/components/common/CodeBadge';
|
||||||
|
import {
|
||||||
|
uploadTemplateApi,
|
||||||
|
type UploadTemplateResp,
|
||||||
|
type UploadTemplateColumnVO,
|
||||||
|
type UploadTemplateSaveReq,
|
||||||
|
} from '@/api/uploadTemplate';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// transformRule → color tag
|
||||||
|
const RULE_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
TRIM: { color: 'default', label: 'TRIM' },
|
||||||
|
UPPER: { color: 'default', label: 'UPPER' },
|
||||||
|
LOWER: { color: 'default', label: 'LOWER' },
|
||||||
|
DATE: { color: 'blue', label: 'DATE' },
|
||||||
|
DIVIDE: { color: 'blue', label: 'DIVIDE' },
|
||||||
|
MULTIPLY: { color: 'blue', label: 'MULTIPLY' },
|
||||||
|
LOOKUP: { color: 'green', label: 'LOOKUP' },
|
||||||
|
CODE_MAP: { color: 'green', label: 'CODE_MAP' },
|
||||||
|
FIXED: { color: 'orange', label: 'FIXED' },
|
||||||
|
DEFAULT: { color: 'orange', label: 'DEFAULT' },
|
||||||
|
REPLACE: { color: 'purple', label: 'REPLACE' },
|
||||||
|
SUBSTRING: { color: 'purple', label: 'SUBSTRING' },
|
||||||
|
CONCAT: { color: 'purple', label: 'CONCAT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_TYPES = [
|
||||||
|
{ value: 'COLUMN_INDEX', label: '열 인덱스' },
|
||||||
|
{ value: 'COLUMN_HEADER', label: '열 헤더명' },
|
||||||
|
{ value: 'FIXED', label: '고정값' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TARGET_TYPES = [
|
||||||
|
{ value: 'STRING', label: 'STRING' },
|
||||||
|
{ value: 'NUMBER', label: 'NUMBER' },
|
||||||
|
{ value: 'DATE', label: 'DATE' },
|
||||||
|
{ value: 'BOOLEAN', label: 'BOOLEAN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRANSFORM_RULES = [
|
||||||
|
{ value: '', label: '없음' },
|
||||||
|
{ value: 'TRIM', label: 'TRIM' },
|
||||||
|
{ value: 'UPPER', label: 'UPPER' },
|
||||||
|
{ value: 'LOWER', label: 'LOWER' },
|
||||||
|
{ value: 'DATE', label: 'DATE (날짜 변환)' },
|
||||||
|
{ value: 'DIVIDE', label: 'DIVIDE (나누기)' },
|
||||||
|
{ value: 'MULTIPLY', label: 'MULTIPLY (곱하기)' },
|
||||||
|
{ value: 'LOOKUP', label: 'LOOKUP (테이블 조회)' },
|
||||||
|
{ value: 'CODE_MAP', label: 'CODE_MAP (코드 매핑)' },
|
||||||
|
{ value: 'FIXED', label: 'FIXED (고정값)' },
|
||||||
|
{ value: 'DEFAULT', label: 'DEFAULT (기본값)' },
|
||||||
|
{ value: 'REPLACE', label: 'REPLACE (치환)' },
|
||||||
|
{ value: 'SUBSTRING', label: 'SUBSTRING (자르기)' },
|
||||||
|
{ value: 'CONCAT', label: 'CONCAT (연결)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'RECRUIT', label: '모집' },
|
||||||
|
{ value: 'MAINTAIN', label: '유지' },
|
||||||
|
{ value: 'EXCEPTION', label: '예외' },
|
||||||
|
{ value: 'PAYMENT', label: '지급' },
|
||||||
|
{ value: 'ETC', label: '기타' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 컬럼 매핑 행 타입 (로컬 편집용 key 포함) ───────────────────────────────
|
||||||
|
interface ColumnRow extends UploadTemplateColumnVO {
|
||||||
|
_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKey() {
|
||||||
|
return `r_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRows(cols: UploadTemplateColumnVO[]): ColumnRow[] {
|
||||||
|
return cols.map((c) => ({ ...c, _key: makeKey() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 변환 규칙에 따라 동적 파라미터 입력 렌더 ──────────────────────────────
|
||||||
|
function RuleParamCell({ row, onChange }: {
|
||||||
|
row: ColumnRow;
|
||||||
|
onChange: (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const rule = row.transformRule ?? '';
|
||||||
|
if (rule === 'DATE') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="yyyyMMdd"
|
||||||
|
value={row.dateFormat ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'dateFormat', e.target.value)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'DIVIDE') {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
placeholder="나누는 수"
|
||||||
|
value={row.numberDivide}
|
||||||
|
onChange={(v) => onChange(row._key, 'numberDivide', v ?? undefined)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'MULTIPLY') {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
placeholder="곱하는 수"
|
||||||
|
value={row.numberMultiply}
|
||||||
|
onChange={(v) => onChange(row._key, 'numberMultiply', v ?? undefined)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'LOOKUP') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="테이블.컬럼"
|
||||||
|
value={row.lookupTable ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'lookupTable', e.target.value)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'CODE_MAP') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="그룹코드"
|
||||||
|
value={row.codeMapGroup ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'codeMapGroup', e.target.value)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'FIXED') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="고정값"
|
||||||
|
value={row.fixedValue ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'fixedValue', e.target.value)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'DEFAULT') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="기본값"
|
||||||
|
value={row.defaultValue ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'defaultValue', e.target.value)}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule === 'REPLACE' || rule === 'SUBSTRING' || rule === 'CONCAT') {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="파라미터"
|
||||||
|
value={row.transformParam ?? ''}
|
||||||
|
onChange={(e) => onChange(row._key, 'transformParam', e.target.value)}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text type="secondary" style={{ fontSize: 12 }}>-</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 미리보기 파일 선택 모달 ───────────────────────────────────────────────
|
||||||
|
function PreviewModal({ templateCode, onClose }: { templateCode: string; onClose: () => void }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [previewRows, setPreviewRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [columns, setColumns] = useState<{ title: string; dataIndex: string }[]>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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<string, unknown>[]);
|
||||||
|
} catch {
|
||||||
|
message.error('미리보기 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={`매핑 미리보기 — ${templateCode}`}
|
||||||
|
width={900}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={<Button onClick={onClose}>닫기</Button>}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
icon={<IconEye size={14} />}
|
||||||
|
>
|
||||||
|
파일 선택
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Excel / CSV 파일 선택 → 첫 5행 결과 표시
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{loading && <Spin />}
|
||||||
|
{previewRows.length > 0 && (
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="rowNumber"
|
||||||
|
dataSource={previewRows}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 이력 Drawer ──────────────────────────────────────────────────────────
|
||||||
|
function HistoryDrawer({ templateId, onClose }: { templateId: number; onClose: () => void }) {
|
||||||
|
const { data = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['uploadTemplate', 'history', templateId],
|
||||||
|
queryFn: () => uploadTemplateApi.history(templateId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer title="업로드 이력" width={720} open onClose={onClose}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="historyId"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '파일명', dataIndex: 'fileName', ellipsis: true },
|
||||||
|
{ title: '전체', dataIndex: 'totalCount', width: 70, align: 'right' },
|
||||||
|
{ title: '성공', dataIndex: 'successCount', width: 70, align: 'right',
|
||||||
|
render: (v) => <Text style={{ color: '#0F6E56' }}>{v}</Text> },
|
||||||
|
{ title: '오류', dataIndex: 'errorCount', width: 70, align: 'right',
|
||||||
|
render: (v) => v > 0 ? <Text type="danger">{v}</Text> : <Text>{v}</Text> },
|
||||||
|
{ title: '건너뜀', dataIndex: 'skipCount', width: 70, align: 'right' },
|
||||||
|
{ title: '소요(초)', dataIndex: 'durationSec', width: 80, align: 'right' },
|
||||||
|
{ title: '업로더', dataIndex: 'uploadedBy', width: 100 },
|
||||||
|
{ title: '업로드일시', dataIndex: 'uploadedAt', width: 160 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 컬럼 매핑 편집기 ─────────────────────────────────────────────────────
|
||||||
|
function ColumnEditor({ template, onSaved }: {
|
||||||
|
template: UploadTemplateResp;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<ColumnRow[]>([]);
|
||||||
|
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<typeof useQuery>[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 <Spin style={{ margin: 32 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<Space>
|
||||||
|
<Text strong>{template.templateName}</Text>
|
||||||
|
<Tag>{template.templateCode}</Tag>
|
||||||
|
<Tag color="blue">{template.category}</Tag>
|
||||||
|
<CodeBadge groupCode="ACTIVE_YN" value={template.isActive} />
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<IconEye size={14} />} onClick={() => setShowPreview(true)}>
|
||||||
|
미리보기
|
||||||
|
</Button>
|
||||||
|
<PermissionButton
|
||||||
|
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||||
|
permCode="UPDATE"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDeviceFloppy size={14} />}
|
||||||
|
loading={saving}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</PermissionButton>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 테이블 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<Table<ColumnRow>
|
||||||
|
size="small"
|
||||||
|
rowKey="_key"
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
footer={() => (
|
||||||
|
<PermissionButton
|
||||||
|
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||||
|
permCode="UPDATE"
|
||||||
|
size="small"
|
||||||
|
icon={<IconPlus size={14} />}
|
||||||
|
onClick={addRow}
|
||||||
|
>
|
||||||
|
행 추가
|
||||||
|
</PermissionButton>
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '순서', dataIndex: 'sortOrder', width: 60, align: 'center',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title="위로">
|
||||||
|
<Button size="small" type="text" icon={<IconArrowUp size={12} />}
|
||||||
|
onClick={() => moveRow(r._key, 'up')} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="아래로">
|
||||||
|
<Button size="small" type="text" icon={<IconArrowDown size={12} />}
|
||||||
|
onClick={() => moveRow(r._key, 'down')} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '소스 유형', dataIndex: 'sourceType', width: 120,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={r.sourceType}
|
||||||
|
options={SOURCE_TYPES}
|
||||||
|
onChange={(v) => updateCell(r._key, 'sourceType', v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '소스 컬럼', dataIndex: 'sourceColumn', width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={r.sourceType === 'COLUMN_HEADER' ? (r.sourceHeader ?? '') : (r.sourceColumn ?? '')}
|
||||||
|
placeholder={r.sourceType === 'COLUMN_HEADER' ? '헤더명' : '열 번호'}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (r.sourceType === 'COLUMN_HEADER') {
|
||||||
|
updateCell(r._key, 'sourceHeader', e.target.value);
|
||||||
|
} else {
|
||||||
|
updateCell(r._key, 'sourceColumn', e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '대상 필드', dataIndex: 'targetField', width: 120,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={r.targetField}
|
||||||
|
onChange={(e) => updateCell(r._key, 'targetField', e.target.value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '대상 타입', dataIndex: 'targetType', width: 100,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={r.targetType}
|
||||||
|
options={TARGET_TYPES}
|
||||||
|
onChange={(v) => updateCell(r._key, 'targetType', v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '변환 규칙', dataIndex: 'transformRule', width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={r.transformRule ?? ''}
|
||||||
|
options={TRANSFORM_RULES}
|
||||||
|
onChange={(v) => updateCell(r._key, 'transformRule', v || undefined)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
dropdownMatchSelectWidth={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '파라미터', width: 130,
|
||||||
|
render: (_, r) => <RuleParamCell row={r} onChange={updateCell} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '필수', dataIndex: 'isRequired', width: 60, align: 'center',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={r.isRequired ?? 'N'}
|
||||||
|
options={[{ value: 'Y', label: <Tag color="error" style={{ margin: 0 }}>Y</Tag> }, { value: 'N', label: 'N' }]}
|
||||||
|
onChange={(v) => updateCell(r._key, 'isRequired', v)}
|
||||||
|
style={{ width: 60 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '액션', width: 60, align: 'center', fixed: 'right',
|
||||||
|
render: (_, r) => (
|
||||||
|
<PermissionButton
|
||||||
|
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||||
|
permCode="DELETE"
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<IconTrash size={14} />}
|
||||||
|
onClick={() => removeRow(r._key)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<PreviewModal
|
||||||
|
templateCode={template.templateCode}
|
||||||
|
onClose={() => setShowPreview(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 템플릿 등록 / 수정 모달 폼 ─────────────────────────────────────────
|
||||||
|
function TemplateFormModal({ editTarget, onSuccess, trigger }: {
|
||||||
|
editTarget?: UploadTemplateResp;
|
||||||
|
onSuccess: () => void;
|
||||||
|
trigger: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ModalForm<UploadTemplateSaveReq>
|
||||||
|
title={editTarget ? '템플릿 수정' : '템플릿 등록'}
|
||||||
|
trigger={trigger}
|
||||||
|
width={560}
|
||||||
|
modalProps={{ destroyOnClose: true }}
|
||||||
|
initialValues={editTarget ?? { headerRow: 1, dataStartRow: 2, sheetIndex: 0, batchSize: 1000, isActive: 'Y' }}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
if (editTarget) {
|
||||||
|
await uploadTemplateApi.update(editTarget.templateId, values);
|
||||||
|
message.success('수정 완료');
|
||||||
|
} else {
|
||||||
|
await uploadTemplateApi.create(values);
|
||||||
|
message.success('등록 완료');
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText name="templateCode" label="템플릿 코드" rules={[{ required: true }]}
|
||||||
|
fieldProps={{ disabled: !!editTarget }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText name="templateName" label="템플릿 명" rules={[{ required: true }]} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormSelect name="category" label="카테고리" rules={[{ required: true }]}
|
||||||
|
options={CATEGORIES} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText name="targetTable" label="대상 테이블" rules={[{ required: true }]} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText name="companyCode" label="보험사 코드" />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormSelect name="isActive" label="활성여부"
|
||||||
|
options={[{ value: 'Y', label: '활성' }, { value: 'N', label: '비활성' }]} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<ProFormDigit name="headerRow" label="헤더 행" min={0} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<ProFormDigit name="dataStartRow" label="데이터 시작 행" min={1} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<ProFormDigit name="sheetIndex" label="시트 인덱스" min={0} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText name="sheetName" label="시트 이름" />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormDigit name="batchSize" label="배치 크기" min={1} />
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 2 }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 페이지 ──────────────────────────────────────────────────────────
|
||||||
|
export default function UploadTemplateManager() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const actionRef = useRef<ActionType>();
|
||||||
|
const [selected, setSelected] = useState<UploadTemplateResp | null>(null);
|
||||||
|
const [historyTarget, setHistoryTarget] = useState<UploadTemplateResp | null>(null);
|
||||||
|
const [editTarget, setEditTarget] = useState<UploadTemplateResp | undefined>();
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['uploadTemplate', 'list'] });
|
||||||
|
actionRef.current?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: '삭제 확인',
|
||||||
|
content: '이 템플릿을 삭제하시겠습니까?',
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
await uploadTemplateApi.remove(id);
|
||||||
|
message.success('삭제 완료');
|
||||||
|
if (selected?.templateId === id) setSelected(null);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ProColumns<UploadTemplateResp>[] = [
|
||||||
|
{
|
||||||
|
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||||
|
fieldProps: { placeholder: '코드 / 이름' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '카테고리', dataIndex: 'category', width: 90, valueType: 'select',
|
||||||
|
valueEnum: Object.fromEntries(CATEGORIES.map((c) => [c.value, { text: c.label }])),
|
||||||
|
render: (_, r) => <Tag>{r.category}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '코드', dataIndex: 'templateCode', width: 160, search: false, ellipsis: true },
|
||||||
|
{ title: '이름', dataIndex: 'templateName', search: false, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false,
|
||||||
|
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>{r.isActive}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '액션', width: 140, fixed: 'right', search: false,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={2}>
|
||||||
|
<TemplateFormModal
|
||||||
|
editTarget={r}
|
||||||
|
onSuccess={refresh}
|
||||||
|
trigger={
|
||||||
|
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="UPDATE"
|
||||||
|
size="small" type="text" icon={<IconEdit size={14} />}
|
||||||
|
onClick={() => setEditTarget(r)}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</PermissionButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="DELETE"
|
||||||
|
size="small" type="text" danger icon={<IconTrash size={14} />}
|
||||||
|
onClick={() => handleDelete(r.templateId)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</PermissionButton>
|
||||||
|
<Tooltip title="이력">
|
||||||
|
<Button size="small" type="text" icon={<IconHistory size={14} />}
|
||||||
|
onClick={() => setHistoryTarget(r)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="업로드 템플릿 관리"
|
||||||
|
description="엑셀 / CSV 업로드 매핑 템플릿 등록 및 컬럼 매핑 편집"
|
||||||
|
extra={
|
||||||
|
<TemplateFormModal
|
||||||
|
onSuccess={refresh}
|
||||||
|
trigger={
|
||||||
|
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="CREATE"
|
||||||
|
type="primary" icon={<IconPlus size={14} />}
|
||||||
|
>
|
||||||
|
템플릿 등록
|
||||||
|
</PermissionButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProCard split="vertical" style={{ minHeight: 600 }}>
|
||||||
|
{/* 좌측: 템플릿 목록 */}
|
||||||
|
<ProCard colSpan={380} style={{ overflow: 'hidden' }}>
|
||||||
|
<ProTable<UploadTemplateResp>
|
||||||
|
actionRef={actionRef}
|
||||||
|
rowKey="templateId"
|
||||||
|
columns={columns}
|
||||||
|
onRow={(r) => ({
|
||||||
|
onClick: () => setSelected(r),
|
||||||
|
style: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: r.templateId === selected?.templateId ? '#E6F1FB' : undefined,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
request={async (params) => {
|
||||||
|
const { current, pageSize, ...rest } = params as {
|
||||||
|
current: number;
|
||||||
|
pageSize: number;
|
||||||
|
category?: string;
|
||||||
|
searchKeyword?: string;
|
||||||
|
};
|
||||||
|
const res = await uploadTemplateApi.list({ ...rest, pageNum: current, pageSize });
|
||||||
|
return { data: res.list, total: res.total, success: true };
|
||||||
|
}}
|
||||||
|
pagination={{ pageSize: 20, showTotal: (t) => `총 ${t}건` }}
|
||||||
|
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||||
|
options={{ density: false, reload: true }}
|
||||||
|
dateFormatter="string"
|
||||||
|
scroll={{ x: 380 }}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
{/* 우측: 컬럼 매핑 편집기 */}
|
||||||
|
<ProCard style={{ padding: 16 }}>
|
||||||
|
{selected ? (
|
||||||
|
<ColumnEditor
|
||||||
|
key={selected.templateId}
|
||||||
|
template={selected}
|
||||||
|
onSaved={() => qc.invalidateQueries({ queryKey: ['uploadTemplate', 'columns', selected.templateId] })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 400 }}>
|
||||||
|
<Empty description="좌측에서 템플릿을 선택하면 컬럼 매핑을 편집할 수 있습니다" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
{/* 이력 Drawer */}
|
||||||
|
{historyTarget && (
|
||||||
|
<HistoryDrawer
|
||||||
|
templateId={historyTarget.templateId}
|
||||||
|
onClose={() => setHistoryTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user