Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6356213e86 |
@@ -1,279 +0,0 @@
|
||||
package com.ga.common.generator;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* CRUD 코드 자동 생성 CLI.
|
||||
*
|
||||
* 사용법:
|
||||
* java -jar ga-generator.jar --table=agent --menu=ORG_AGENT --label=설계사
|
||||
*
|
||||
* 산출물:
|
||||
* ga-core/src/main/java/com/ga/core/vo/{group}/AgentVO.java
|
||||
* ga-core/src/main/java/com/ga/core/vo/{group}/AgentSearchParam.java
|
||||
* ga-core/src/main/java/com/ga/core/vo/{group}/AgentSaveReq.java
|
||||
* ga-core/src/main/java/com/ga/core/vo/{group}/AgentResp.java
|
||||
* ga-core/src/main/java/com/ga/core/mapper/{group}/AgentMapper.java
|
||||
* ga-core/src/main/resources/mapper/{group}/AgentMapper.xml
|
||||
* ga-api/src/main/java/com/ga/api/service/{group}/AgentService.java
|
||||
* ga-api/src/main/java/com/ga/api/controller/{group}/AgentController.java
|
||||
* ga-frontend/src/pages/{group}/AgentList.tsx
|
||||
* ga-frontend/src/pages/{group}/AgentDetail.tsx
|
||||
* ga-frontend/src/api/agent.ts
|
||||
*
|
||||
* 단순 템플릿 치환 방식 (Mustache 등 외부 의존 없이 내부 String.replace 사용).
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CodeGeneratorService {
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final Path projectRoot;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Map<String, String> opts = parseArgs(args);
|
||||
String table = required(opts, "table");
|
||||
String menu = required(opts, "menu");
|
||||
String label = opts.getOrDefault("label", table);
|
||||
String group = opts.getOrDefault("group", "common");
|
||||
Path root = Path.of(opts.getOrDefault("root", System.getProperty("user.dir")));
|
||||
|
||||
// DataSource 연결은 Spring 컨텍스트에서 inject. 여기 main은 데모용.
|
||||
new CodeGeneratorService(null, root).generate(table, menu, label, group);
|
||||
}
|
||||
|
||||
public void generate(String table, String menuCode, String label, String group) throws Exception {
|
||||
log.info("Generating CRUD for table={}, menu={}, label={}, group={}", table, menuCode, label, group);
|
||||
List<Column> columns = readColumns(table);
|
||||
if (columns.isEmpty()) {
|
||||
throw new IllegalArgumentException("테이블 [" + table + "] 의 컬럼을 읽을 수 없습니다");
|
||||
}
|
||||
|
||||
String entity = toPascal(table); // agent → Agent
|
||||
String entityCamel = toCamel(table); // agent → agent
|
||||
|
||||
Map<String, String> ctx = new HashMap<>();
|
||||
ctx.put("ENTITY", entity);
|
||||
ctx.put("ENTITY_CAMEL", entityCamel);
|
||||
ctx.put("TABLE", table);
|
||||
ctx.put("MENU", menuCode);
|
||||
ctx.put("LABEL", label);
|
||||
ctx.put("GROUP", group);
|
||||
ctx.put("FIELDS_VO", renderFieldsForVO(columns));
|
||||
ctx.put("FIELDS_SAVEREQ", renderFieldsForSaveReq(columns));
|
||||
ctx.put("FIELDS_RESP", renderFieldsForResp(columns));
|
||||
ctx.put("INSERT_COLUMNS", renderInsertColumns(columns));
|
||||
ctx.put("INSERT_VALUES", renderInsertValues(columns));
|
||||
ctx.put("UPDATE_SETS", renderUpdateSets(columns));
|
||||
|
||||
// 1. ga-core
|
||||
write(ctx, "vo/Entity.vo.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "VO.java");
|
||||
write(ctx, "vo/EntitySearchParam.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "SearchParam.java");
|
||||
write(ctx, "vo/EntitySaveReq.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "SaveReq.java");
|
||||
write(ctx, "vo/EntityResp.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "Resp.java");
|
||||
write(ctx, "mapper/EntityMapper.java", "ga-core/src/main/java/com/ga/core/mapper/" + group + "/" + entity + "Mapper.java");
|
||||
write(ctx, "mapper/EntityMapper.xml", "ga-core/src/main/resources/mapper/" + group + "/" + entity + "Mapper.xml");
|
||||
// 2. ga-api
|
||||
write(ctx, "service/EntityService.java", "ga-api/src/main/java/com/ga/api/service/" + group + "/" + entity + "Service.java");
|
||||
write(ctx, "controller/EntityController.java", "ga-api/src/main/java/com/ga/api/controller/" + group + "/" + entity + "Controller.java");
|
||||
// 3. ga-frontend
|
||||
write(ctx, "frontend/EntityList.tsx", "ga-frontend/src/pages/" + group + "/" + entity + "List.tsx");
|
||||
write(ctx, "frontend/EntityDetail.tsx", "ga-frontend/src/pages/" + group + "/" + entity + "Detail.tsx");
|
||||
write(ctx, "frontend/entity.ts", "ga-frontend/src/api/" + entityCamel + ".ts");
|
||||
|
||||
log.info("Generation complete. Files written under {}", projectRoot);
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
메타데이터 조회
|
||||
========================================================== */
|
||||
private List<Column> readColumns(String table) throws Exception {
|
||||
if (dataSource == null) return List.of();
|
||||
List<Column> cols = new ArrayList<>();
|
||||
try (Connection c = dataSource.getConnection()) {
|
||||
DatabaseMetaData md = c.getMetaData();
|
||||
try (ResultSet rs = md.getColumns(null, null, table, null)) {
|
||||
while (rs.next()) {
|
||||
Column col = new Column();
|
||||
col.name = rs.getString("COLUMN_NAME");
|
||||
col.javaType = mapType(rs.getInt("DATA_TYPE"));
|
||||
col.nullable = "YES".equalsIgnoreCase(rs.getString("IS_NULLABLE"));
|
||||
col.size = rs.getInt("COLUMN_SIZE");
|
||||
col.isPk = false;
|
||||
cols.add(col);
|
||||
}
|
||||
}
|
||||
try (ResultSet rs = md.getPrimaryKeys(null, null, table)) {
|
||||
while (rs.next()) {
|
||||
String pk = rs.getString("COLUMN_NAME");
|
||||
cols.stream().filter(x -> x.name.equals(pk)).findFirst().ifPresent(x -> x.isPk = true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
private String mapType(int sqlType) {
|
||||
return switch (sqlType) {
|
||||
case java.sql.Types.BIGINT -> "Long";
|
||||
case java.sql.Types.INTEGER, java.sql.Types.SMALLINT -> "Integer";
|
||||
case java.sql.Types.NUMERIC, java.sql.Types.DECIMAL -> "BigDecimal";
|
||||
case java.sql.Types.BOOLEAN, java.sql.Types.BIT -> "Boolean";
|
||||
case java.sql.Types.DATE -> "LocalDate";
|
||||
case java.sql.Types.TIMESTAMP -> "LocalDateTime";
|
||||
default -> "String";
|
||||
};
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
템플릿 렌더링
|
||||
========================================================== */
|
||||
private void write(Map<String, String> ctx, String tplPath, String relativeOut) throws Exception {
|
||||
String template = readTemplate(tplPath);
|
||||
String rendered = render(template, ctx);
|
||||
Path out = projectRoot.resolve(relativeOut);
|
||||
Files.createDirectories(out.getParent());
|
||||
Files.writeString(out, rendered);
|
||||
log.info(" -> {}", out);
|
||||
}
|
||||
|
||||
private String readTemplate(String path) throws Exception {
|
||||
// src/main/resources/codegen/{path}
|
||||
try (var is = getClass().getResourceAsStream("/codegen/" + path)) {
|
||||
if (is == null) throw new IllegalStateException("template not found: " + path);
|
||||
return new String(is.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
/** 단순 ${KEY} → value 치환. */
|
||||
private String render(String template, Map<String, String> ctx) {
|
||||
String result = template;
|
||||
for (Map.Entry<String, String> e : ctx.entrySet()) {
|
||||
result = result.replace("${" + e.getKey() + "}", e.getValue() == null ? "" : e.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String renderFieldsForVO(List<Column> cols) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Column c : cols) sb.append(" private ").append(c.javaType).append(" ").append(toCamel(c.name)).append(";\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderFieldsForSaveReq(List<Column> cols) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Column c : cols) {
|
||||
if (c.isPk || isAuditField(c.name)) continue;
|
||||
if (!c.nullable) sb.append(" @jakarta.validation.constraints.NotNull\n");
|
||||
sb.append(" private ").append(c.javaType).append(" ").append(toCamel(c.name)).append(";\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderFieldsForResp(List<Column> cols) {
|
||||
return renderFieldsForVO(cols); // 기본은 VO와 동일. 조인 필드는 수동 추가.
|
||||
}
|
||||
|
||||
private String renderInsertColumns(List<Column> cols) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean first = true;
|
||||
for (Column c : cols) {
|
||||
if (c.isPk) continue;
|
||||
if (!first) sb.append(", ");
|
||||
sb.append(c.name);
|
||||
first = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderInsertValues(List<Column> cols) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean first = true;
|
||||
for (Column c : cols) {
|
||||
if (c.isPk) continue;
|
||||
if (!first) sb.append(", ");
|
||||
sb.append("#{").append(toCamel(c.name)).append("}");
|
||||
first = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderUpdateSets(List<Column> cols) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean first = true;
|
||||
for (Column c : cols) {
|
||||
if (c.isPk || "created_at".equals(c.name) || "created_by".equals(c.name)) continue;
|
||||
if (!first) sb.append(",\n ");
|
||||
sb.append(c.name).append(" = #{").append(toCamel(c.name)).append("}");
|
||||
first = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private boolean isAuditField(String name) {
|
||||
return name.equals("created_at") || name.equals("created_by")
|
||||
|| name.equals("updated_at") || name.equals("updated_by");
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
문자열 변환
|
||||
========================================================== */
|
||||
private String toPascal(String snake) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean upper = true;
|
||||
for (char ch : snake.toCharArray()) {
|
||||
if (ch == '_') { upper = true; continue; }
|
||||
sb.append(upper ? Character.toUpperCase(ch) : ch);
|
||||
upper = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String toCamel(String snake) {
|
||||
String pascal = toPascal(snake);
|
||||
return pascal.isEmpty() ? "" : Character.toLowerCase(pascal.charAt(0)) + pascal.substring(1);
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
CLI 인자 파싱
|
||||
========================================================== */
|
||||
private static Map<String, String> parseArgs(String[] args) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (String a : args) {
|
||||
if (!a.startsWith("--")) continue;
|
||||
String[] kv = a.substring(2).split("=", 2);
|
||||
map.put(kv[0], kv.length > 1 ? kv[1] : "true");
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> map, String key) {
|
||||
String v = map.get(key);
|
||||
if (v == null) throw new IllegalArgumentException("--" + key + " 인자가 필요합니다");
|
||||
return v;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
내부 모델
|
||||
========================================================== */
|
||||
private static class Column {
|
||||
String name;
|
||||
String javaType;
|
||||
boolean nullable;
|
||||
boolean isPk;
|
||||
int size;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* LOOKUP 변환 규칙용 캐시.
|
||||
* 70만건 업로드 시 행마다 DB 조회를 피하기 위해, 시작 전에 참조 테이블 전체를 Map으로 로드.
|
||||
*
|
||||
* - preload(columns): 매핑 규칙에 LOOKUP이 있으면 해당 테이블을 한번에 캐시
|
||||
* - lookup(table, sourceField, value, targetField): O(1) 조회
|
||||
*
|
||||
* Note: 이 캐시는 요청 단위 — 매 업로드마다 새로 만들고 끝나면 GC 대상.
|
||||
* UploadService 내부에서 new LookupCache(uploadMapper) 로 인스턴스화.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class LookupCache {
|
||||
|
||||
private final UploadMapper uploadMapper;
|
||||
private final Map<String, Map<String, Object>> caches = new HashMap<>();
|
||||
|
||||
public void preload(List<UploadTemplateColumnVO> columns) {
|
||||
columns.stream()
|
||||
.filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule()))
|
||||
.filter(c -> c.getLookupTable() != null && c.getLookupSourceField() != null)
|
||||
.map(c -> c.getLookupTable() + "|" + c.getLookupSourceField() + "|" + c.getLookupTargetField())
|
||||
.distinct()
|
||||
.forEach(key -> {
|
||||
String[] p = key.split("\\|");
|
||||
String table = p[0], src = p[1], tgt = p[2];
|
||||
if (!UploadWhitelist.isAllowedLookupTable(table)) {
|
||||
throw new IllegalArgumentException("허용되지 않은 LOOKUP 테이블: " + table);
|
||||
}
|
||||
List<Map<String, Object>> rows = uploadMapper.selectLookupAll(table, src, tgt);
|
||||
Map<String, Object> map = new HashMap<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object srcVal = row.get("source_value");
|
||||
Object tgtVal = row.get("target_value");
|
||||
if (srcVal != null) map.put(srcVal.toString(), tgtVal);
|
||||
}
|
||||
caches.put(key, map);
|
||||
log.info("LookupCache loaded: {} = {} rows", key, map.size());
|
||||
});
|
||||
}
|
||||
|
||||
public Object lookup(String table, String sourceField, String value, String targetField) {
|
||||
if (value == null) return null;
|
||||
Map<String, Object> cache = caches.get(table + "|" + sourceField + "|" + targetField);
|
||||
if (cache == null) return null;
|
||||
return cache.get(value);
|
||||
}
|
||||
|
||||
public int size() { return caches.size(); }
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.common.code.CommonCodeService;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 매핑 규칙 → 변환된 값 Map.
|
||||
* UploadService에서 lookupCache를 주입해 사용한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TransformEngine {
|
||||
|
||||
private final LookupCache lookupCache;
|
||||
private final CommonCodeService codeService;
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** 한 행을 매핑 규칙대로 변환. result key = targetField, value = 변환된 값. */
|
||||
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (UploadTemplateColumnVO col : columns) {
|
||||
Object raw = readSourceValue(row, col);
|
||||
Object transformed = applyTransform(raw, col);
|
||||
result.put(col.getTargetField(), transformed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
엑셀 셀 값 추출
|
||||
---------------------------------------------------------- */
|
||||
private Object readSourceValue(Row row, UploadTemplateColumnVO col) {
|
||||
if ("FIXED".equalsIgnoreCase(col.getSourceType())) return col.getFixedValue();
|
||||
if (row == null || col.getSourceColumn() == null) return null;
|
||||
|
||||
int idx = parseColumnIndex(col.getSourceColumn());
|
||||
Cell cell = row.getCell(idx);
|
||||
if (cell == null) return null;
|
||||
|
||||
return switch (cell.getCellType()) {
|
||||
case STRING -> cell.getStringCellValue();
|
||||
case NUMERIC -> {
|
||||
if (DateUtil.isCellDateFormatted(cell)) {
|
||||
yield cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
}
|
||||
double d = cell.getNumericCellValue();
|
||||
yield d == (long) d ? (Object) (long) d : (Object) d;
|
||||
}
|
||||
case BOOLEAN -> cell.getBooleanCellValue();
|
||||
case FORMULA -> {
|
||||
try { yield cell.getStringCellValue(); }
|
||||
catch (Exception e) { yield cell.getNumericCellValue(); }
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** "A"~"Z","AA"... 또는 "0","1"... 둘 다 허용. */
|
||||
private int parseColumnIndex(String col) {
|
||||
col = col.trim();
|
||||
if (col.matches("\\d+")) return Integer.parseInt(col);
|
||||
int idx = 0;
|
||||
for (char c : col.toUpperCase().toCharArray()) {
|
||||
if (c < 'A' || c > 'Z') throw new IllegalArgumentException("invalid column: " + col);
|
||||
idx = idx * 26 + (c - 'A' + 1);
|
||||
}
|
||||
return idx - 1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
변환 규칙 적용
|
||||
---------------------------------------------------------- */
|
||||
private Object applyTransform(Object value, UploadTemplateColumnVO col) {
|
||||
if ("FIXED".equalsIgnoreCase(col.getSourceType())) return convertType(col.getFixedValue(), col.getTargetType());
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
return col.getDefaultValue() != null ? convertType(col.getDefaultValue(), col.getTargetType()) : null;
|
||||
}
|
||||
|
||||
String rule = col.getTransformRule();
|
||||
if (rule == null || rule.isBlank()) return convertType(value, col.getTargetType());
|
||||
|
||||
try {
|
||||
return switch (rule.toUpperCase()) {
|
||||
case "TRIM" -> value.toString().trim();
|
||||
case "UPPER" -> value.toString().toUpperCase();
|
||||
case "LOWER" -> value.toString().toLowerCase();
|
||||
case "DATE" -> parseDate(value, col.getDateFormat());
|
||||
case "DIVIDE" -> col.getNumberDivide() == null ? value
|
||||
: toDecimal(value).divide(BigDecimal.valueOf(col.getNumberDivide()), 4, java.math.RoundingMode.HALF_UP);
|
||||
case "MULTIPLY" -> col.getNumberMultiply() == null ? value
|
||||
: toDecimal(value).multiply(BigDecimal.valueOf(col.getNumberMultiply()));
|
||||
case "LOOKUP" -> lookupCache.lookup(col.getLookupTable(), col.getLookupSourceField(),
|
||||
value.toString(), col.getLookupTargetField());
|
||||
case "CODE_MAP" -> codeService.getCodeName(col.getCodeMapGroup(), value.toString());
|
||||
case "REPLACE" -> applyReplace(value, col.getTransformParam());
|
||||
case "SUBSTRING" -> applySubstring(value, col.getTransformParam());
|
||||
case "DEFAULT" -> value; // null 아닐 때는 그대로
|
||||
default -> value;
|
||||
};
|
||||
} catch (Exception e) {
|
||||
log.warn("transform rule [{}] failed for value [{}]: {}", rule, value, e.getMessage());
|
||||
throw new IllegalArgumentException("[" + col.getTargetField() + "] 변환 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Object parseDate(Object value, String pattern) {
|
||||
if (value instanceof LocalDate d) return d;
|
||||
if (value instanceof LocalDateTime dt) return dt.toLocalDate();
|
||||
String s = value.toString().trim();
|
||||
DateTimeFormatter fmt = pattern == null ? DateTimeFormatter.ISO_LOCAL_DATE : DateTimeFormatter.ofPattern(pattern);
|
||||
return LocalDate.parse(s, fmt);
|
||||
}
|
||||
|
||||
private Object applyReplace(Object value, String param) throws Exception {
|
||||
JsonNode n = JSON.readTree(param);
|
||||
return value.toString().replace(n.path("find").asText(""), n.path("replace").asText(""));
|
||||
}
|
||||
|
||||
private Object applySubstring(Object value, String param) throws Exception {
|
||||
JsonNode n = JSON.readTree(param);
|
||||
String s = value.toString();
|
||||
int start = n.path("start").asInt(0);
|
||||
int end = n.has("end") ? Math.min(n.get("end").asInt(), s.length()) : s.length();
|
||||
return s.substring(Math.max(0, start), Math.max(0, end));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
타입 변환
|
||||
---------------------------------------------------------- */
|
||||
private Object convertType(Object value, String targetType) {
|
||||
if (value == null) return null;
|
||||
return switch (targetType == null ? "STRING" : targetType.toUpperCase()) {
|
||||
case "STRING" -> value.toString();
|
||||
case "INTEGER" -> value instanceof Number n ? n.intValue() : Integer.parseInt(value.toString().trim());
|
||||
case "NUMBER" -> value instanceof Number n ? n.longValue() : Long.parseLong(value.toString().trim());
|
||||
case "DECIMAL" -> toDecimal(value);
|
||||
case "DATE" -> value instanceof LocalDate ? value : LocalDate.parse(value.toString());
|
||||
default -> value;
|
||||
};
|
||||
}
|
||||
|
||||
private BigDecimal toDecimal(Object value) {
|
||||
if (value instanceof BigDecimal bd) return bd;
|
||||
if (value instanceof Number n) return BigDecimal.valueOf(n.doubleValue());
|
||||
return new BigDecimal(value.toString().replace(",", "").trim());
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/upload-templates")
|
||||
@Tag(name = "업로드 템플릿 관리")
|
||||
@RequiredArgsConstructor
|
||||
public class UploadController {
|
||||
|
||||
private final UploadTemplateService templateService;
|
||||
private final UploadService uploadService;
|
||||
|
||||
/* ==========================================================
|
||||
템플릿 CRUD
|
||||
========================================================== */
|
||||
@GetMapping
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "READ")
|
||||
public ApiResponse<List<UploadTemplateVO>> list(@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String companyCode) {
|
||||
return ApiResponse.ok(templateService.list(category, companyCode));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "READ")
|
||||
public ApiResponse<TemplateDetailResp> detail(@PathVariable Long id) {
|
||||
TemplateDetailResp r = new TemplateDetailResp();
|
||||
r.setTemplate(templateService.get(id));
|
||||
r.setColumns(templateService.getColumns(id));
|
||||
return ApiResponse.ok(r);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "CREATE")
|
||||
public ApiResponse<Long> create(@Valid @RequestBody TemplateSaveReq req) {
|
||||
return ApiResponse.ok(templateService.create(req.getTemplate(), req.getColumns()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "UPDATE")
|
||||
public ApiResponse<Void> update(@PathVariable Long id, @Valid @RequestBody TemplateSaveReq req) {
|
||||
templateService.update(id, req.getTemplate(), req.getColumns());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "DELETE")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
templateService.delete(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
업로드 처리
|
||||
========================================================== */
|
||||
@PostMapping("/{templateCode}/upload")
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "CREATE")
|
||||
public ApiResponse<UploadResultResp> upload(@PathVariable String templateCode,
|
||||
@RequestParam MultipartFile file) {
|
||||
return ApiResponse.ok(uploadService.processUpload(templateCode, file));
|
||||
}
|
||||
|
||||
@PostMapping("/{templateCode}/preview")
|
||||
@RequirePermission(menu = "DATA_UPLOAD", perm = "READ")
|
||||
public ApiResponse<List<Map<String, Object>>> preview(@PathVariable String templateCode,
|
||||
@RequestParam MultipartFile file,
|
||||
@RequestParam(defaultValue = "5") int rows) {
|
||||
return ApiResponse.ok(uploadService.preview(templateCode, file, rows));
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
요청/응답 DTO
|
||||
========================================================== */
|
||||
@Data
|
||||
public static class TemplateSaveReq {
|
||||
private UploadTemplateVO template;
|
||||
private List<UploadTemplateColumnVO> columns;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TemplateDetailResp {
|
||||
private UploadTemplateVO template;
|
||||
private List<UploadTemplateColumnVO> columns;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 업로드 실패 행. 에러 엑셀 생성에 사용. */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UploadErrorRow {
|
||||
private int rowNum;
|
||||
private String errorMessage;
|
||||
private List<String> rawValues;
|
||||
|
||||
public UploadErrorRow(int rowNum, String errorMessage) {
|
||||
this.rowNum = rowNum;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public UploadErrorRow(int rowNum, List<String> errors) {
|
||||
this.rowNum = rowNum;
|
||||
this.errorMessage = String.join("; ", errors);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** upload_history 테이블 1:1 매핑. */
|
||||
@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;
|
||||
private String status; // PROCESSING / COMPLETED / FAILED / CANCELLED
|
||||
private String errorMessage;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private Integer durationSec;
|
||||
private Long createdBy;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 업로드 템플릿/매핑/이력 + 동적 INSERT + LOOKUP 조회.
|
||||
*
|
||||
* 참고: target_table, fields 등은 동적 식별자라 ${} 를 사용하므로,
|
||||
* 호출 측에서 화이트리스트 검증 후 전달해야 한다 (UploadService.dynamicInsert 참고).
|
||||
*/
|
||||
@Mapper
|
||||
public interface UploadMapper {
|
||||
|
||||
/* upload_template */
|
||||
UploadTemplateVO selectTemplateByCode(@Param("code") String templateCode);
|
||||
UploadTemplateVO selectTemplateById(@Param("id") Long templateId);
|
||||
List<UploadTemplateVO> selectTemplateList(@Param("category") String category,
|
||||
@Param("companyCode") String companyCode);
|
||||
int insertTemplate(UploadTemplateVO vo);
|
||||
int updateTemplate(UploadTemplateVO vo);
|
||||
int deleteTemplate(@Param("id") Long templateId);
|
||||
|
||||
/* upload_template_column */
|
||||
List<UploadTemplateColumnVO> selectColumnsByTemplateId(@Param("templateId") Long templateId);
|
||||
int insertColumn(UploadTemplateColumnVO vo);
|
||||
int deleteColumnsByTemplateId(@Param("templateId") Long templateId);
|
||||
|
||||
/* upload_history */
|
||||
int insertHistory(UploadHistoryVO vo);
|
||||
int updateHistory(UploadHistoryVO vo);
|
||||
List<UploadHistoryVO> selectHistoryByTemplate(@Param("templateId") Long templateId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/* 동적 배치 INSERT — target_table/fields 화이트리스트 통과 후 호출. */
|
||||
int dynamicBatchInsert(@Param("targetTable") String targetTable,
|
||||
@Param("fields") List<String> fields,
|
||||
@Param("rows") List<Map<String, Object>> rows);
|
||||
|
||||
/* LOOKUP 캐시 미리 로드 */
|
||||
List<Map<String, Object>> selectLookupAll(@Param("table") String table,
|
||||
@Param("sourceField") String sourceField,
|
||||
@Param("targetField") String targetField);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** 업로드 결과 응답. */
|
||||
@Data
|
||||
public class UploadResultResp {
|
||||
private Long historyId;
|
||||
private String templateCode;
|
||||
private int totalCount;
|
||||
private int successCount;
|
||||
private int errorCount;
|
||||
private int skipCount;
|
||||
private Long errorFileId;
|
||||
private String status;
|
||||
private List<UploadErrorRow> sampleErrors = new ArrayList<>(); // 화면 미리보기용 상위 N건
|
||||
|
||||
public void addSuccess(int n) { this.successCount += n; this.totalCount += n; }
|
||||
public void addError(UploadErrorRow row) {
|
||||
this.errorCount++;
|
||||
this.totalCount++;
|
||||
if (this.sampleErrors.size() < 50) this.sampleErrors.add(row);
|
||||
}
|
||||
public void addSkip(int n) { this.skipCount += n; this.totalCount += n; }
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import com.ga.common.code.CommonCodeService;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import com.monitorjbl.xlsx.StreamingReader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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 java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 처리 — 템플릿코드 → 매핑 규칙 → SAX 파싱 → 변환 → 검증 → 동적 배치 INSERT.
|
||||
*
|
||||
* 70만건 대응:
|
||||
* - xlsx-streamer (SAX) 사용
|
||||
* - LookupCache로 참조 테이블 캐시
|
||||
* - batchSize 단위로 INSERT
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UploadService {
|
||||
|
||||
private final UploadMapper uploadMapper;
|
||||
private final CommonCodeService codeService;
|
||||
|
||||
/** 정식 업로드 처리. */
|
||||
@Transactional
|
||||
public UploadResultResp processUpload(String templateCode, MultipartFile file) {
|
||||
UploadTemplateVO template = uploadMapper.selectTemplateByCode(templateCode);
|
||||
if (template == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿이 없습니다: " + templateCode);
|
||||
UploadWhitelist.requireTargetTable(template.getTargetTable());
|
||||
|
||||
List<UploadTemplateColumnVO> columns = uploadMapper.selectColumnsByTemplateId(template.getTemplateId());
|
||||
validateColumns(columns);
|
||||
|
||||
LookupCache cache = new LookupCache(uploadMapper);
|
||||
cache.preload(columns);
|
||||
TransformEngine engine = new TransformEngine(cache, codeService);
|
||||
|
||||
UploadHistoryVO history = startHistory(template, file);
|
||||
UploadResultResp result = new UploadResultResp();
|
||||
result.setHistoryId(history.getHistoryId());
|
||||
result.setTemplateCode(templateCode);
|
||||
long started = System.currentTimeMillis();
|
||||
|
||||
List<Map<String, Object>> batch = new ArrayList<>(template.getBatchSize());
|
||||
try (InputStream is = file.getInputStream();
|
||||
Workbook wb = StreamingReader.builder().rowCacheSize(100).bufferSize(4096).open(is)) {
|
||||
|
||||
Sheet sheet = template.getSheetName() != null
|
||||
? wb.getSheet(template.getSheetName())
|
||||
: wb.getSheetAt(template.getSheetIndex() == null ? 0 : template.getSheetIndex());
|
||||
|
||||
int rowNum = 0, dataStart = template.getDataStartRow() == null ? 2 : template.getDataStartRow();
|
||||
for (Row row : sheet) {
|
||||
rowNum++;
|
||||
if (rowNum < dataStart) continue;
|
||||
if (isEmptyRow(row) && "Y".equalsIgnoreCase(template.getSkipEmptyRow())) {
|
||||
result.addSkip(1);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> mapped = engine.transform(row, columns);
|
||||
List<String> errs = validateRow(mapped, columns);
|
||||
if (!errs.isEmpty()) {
|
||||
result.addError(new UploadErrorRow(rowNum, errs));
|
||||
if (result.getErrorCount() > template.getMaxErrorCount()) {
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
||||
"허용 에러 수(" + template.getMaxErrorCount() + ") 초과");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
batch.add(mapped);
|
||||
if (batch.size() >= template.getBatchSize()) {
|
||||
flushBatch(template.getTargetTable(), columns, batch);
|
||||
result.addSuccess(batch.size());
|
||||
batch.clear();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.addError(new UploadErrorRow(rowNum, e.getMessage()));
|
||||
}
|
||||
}
|
||||
if (!batch.isEmpty()) {
|
||||
flushBatch(template.getTargetTable(), columns, batch);
|
||||
result.addSuccess(batch.size());
|
||||
}
|
||||
result.setStatus("COMPLETED");
|
||||
} catch (Exception e) {
|
||||
log.error("upload error", e);
|
||||
result.setStatus("FAILED");
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||
} finally {
|
||||
finishHistory(history, result, started);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 미리보기 — 첫 N행만 변환해서 결과 반환 (DB INSERT 안 함). */
|
||||
public List<Map<String, Object>> preview(String templateCode, MultipartFile file, int maxRows) {
|
||||
UploadTemplateVO template = uploadMapper.selectTemplateByCode(templateCode);
|
||||
if (template == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿이 없습니다: " + templateCode);
|
||||
|
||||
List<UploadTemplateColumnVO> columns = uploadMapper.selectColumnsByTemplateId(template.getTemplateId());
|
||||
LookupCache cache = new LookupCache(uploadMapper);
|
||||
cache.preload(columns);
|
||||
TransformEngine engine = new TransformEngine(cache, codeService);
|
||||
|
||||
List<Map<String, Object>> previews = new ArrayList<>();
|
||||
try (InputStream is = file.getInputStream();
|
||||
Workbook wb = StreamingReader.builder().rowCacheSize(50).bufferSize(2048).open(is)) {
|
||||
Sheet sheet = template.getSheetName() != null
|
||||
? wb.getSheet(template.getSheetName())
|
||||
: wb.getSheetAt(template.getSheetIndex() == null ? 0 : template.getSheetIndex());
|
||||
|
||||
int rowNum = 0, dataStart = template.getDataStartRow() == null ? 2 : template.getDataStartRow();
|
||||
for (Row row : sheet) {
|
||||
rowNum++;
|
||||
if (rowNum < dataStart) continue;
|
||||
if (previews.size() >= maxRows) break;
|
||||
try {
|
||||
previews.add(engine.transform(row, columns));
|
||||
} catch (Exception e) {
|
||||
previews.add(Map.of("__error__", e.getMessage()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
내부 헬퍼
|
||||
========================================================== */
|
||||
private void validateColumns(List<UploadTemplateColumnVO> columns) {
|
||||
if (columns == null || columns.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "매핑 컬럼이 없습니다");
|
||||
}
|
||||
for (UploadTemplateColumnVO c : columns) {
|
||||
UploadWhitelist.requireValidIdentifier(c.getTargetField());
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> validateRow(Map<String, Object> mapped, List<UploadTemplateColumnVO> columns) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (UploadTemplateColumnVO c : columns) {
|
||||
Object v = mapped.get(c.getTargetField());
|
||||
if ("Y".equalsIgnoreCase(c.getIsRequired()) && (v == null || v.toString().isBlank())) {
|
||||
errors.add("[" + c.getTargetField() + "] 필수값 누락");
|
||||
continue;
|
||||
}
|
||||
if (v != null && c.getValidationRegex() != null && !c.getValidationRegex().isBlank()) {
|
||||
if (!Pattern.matches(c.getValidationRegex(), v.toString())) {
|
||||
errors.add(c.getValidationMessage() != null ? c.getValidationMessage()
|
||||
: "[" + c.getTargetField() + "] 형식 오류");
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void flushBatch(String targetTable, List<UploadTemplateColumnVO> columns,
|
||||
List<Map<String, Object>> batch) {
|
||||
UploadWhitelist.requireTargetTable(targetTable);
|
||||
List<String> fields = new ArrayList<>();
|
||||
for (UploadTemplateColumnVO c : columns) {
|
||||
UploadWhitelist.requireValidIdentifier(c.getTargetField());
|
||||
fields.add(c.getTargetField());
|
||||
}
|
||||
uploadMapper.dynamicBatchInsert(targetTable, fields, batch);
|
||||
}
|
||||
|
||||
private boolean isEmptyRow(Row row) {
|
||||
if (row == null) return true;
|
||||
for (var cell : row) {
|
||||
if (cell != null && cell.getCellType() != org.apache.poi.ss.usermodel.CellType.BLANK) {
|
||||
String v = cell.toString();
|
||||
if (v != null && !v.isBlank()) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private UploadHistoryVO startHistory(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());
|
||||
uploadMapper.insertHistory(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
private void finishHistory(UploadHistoryVO h, UploadResultResp r, long startedMs) {
|
||||
h.setTotalCount(r.getTotalCount());
|
||||
h.setSuccessCount(r.getSuccessCount());
|
||||
h.setErrorCount(r.getErrorCount());
|
||||
h.setSkipCount(r.getSkipCount());
|
||||
h.setErrorFileId(r.getErrorFileId());
|
||||
h.setStatus(r.getStatus() == null ? "COMPLETED" : r.getStatus());
|
||||
h.setFinishedAt(LocalDateTime.now());
|
||||
h.setDurationSec((int) Duration.ofMillis(System.currentTimeMillis() - startedMs).toSeconds());
|
||||
uploadMapper.updateHistory(h);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** upload_template_column 테이블 1:1 매핑. 매핑 규칙 한 줄. */
|
||||
@Data
|
||||
public class UploadTemplateColumnVO {
|
||||
private Long columnId;
|
||||
private Long templateId;
|
||||
private Integer sortOrder;
|
||||
|
||||
// 소스 (엑셀)
|
||||
private String sourceType; // COLUMN / FIXED / EXPRESSION
|
||||
private String sourceColumn; // A, B, C... 또는 0,1,2 인덱스
|
||||
private String sourceHeader;
|
||||
|
||||
// 타겟 (DB)
|
||||
private String targetField;
|
||||
private String targetType; // STRING / NUMBER / DECIMAL / DATE / INTEGER
|
||||
|
||||
// 변환 규칙
|
||||
private String transformRule; // TRIM/UPPER/DATE/DIVIDE/MULTIPLY/LOOKUP/CODE_MAP/REPLACE/SUBSTRING/CONCAT/DEFAULT
|
||||
private String transformParam;
|
||||
private String dateFormat;
|
||||
private Integer numberDivide;
|
||||
private Integer numberMultiply;
|
||||
private String fixedValue;
|
||||
private String defaultValue;
|
||||
|
||||
// LOOKUP
|
||||
private String lookupTable;
|
||||
private String lookupSourceField;
|
||||
private String lookupTargetField;
|
||||
|
||||
// CODE_MAP
|
||||
private String codeMapGroup;
|
||||
|
||||
// 검증
|
||||
private String isRequired;
|
||||
private String validationRegex;
|
||||
private String validationMessage;
|
||||
|
||||
private String description;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 업로드 템플릿/매핑 컬럼 CRUD.
|
||||
* 업로드 처리(파일 처리)는 UploadService 담당.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UploadTemplateService {
|
||||
|
||||
private final UploadMapper uploadMapper;
|
||||
|
||||
public List<UploadTemplateVO> list(String category, String companyCode) {
|
||||
return uploadMapper.selectTemplateList(category, companyCode);
|
||||
}
|
||||
|
||||
public UploadTemplateVO get(Long templateId) {
|
||||
UploadTemplateVO vo = uploadMapper.selectTemplateById(templateId);
|
||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿을 찾을 수 없습니다");
|
||||
return vo;
|
||||
}
|
||||
|
||||
public List<UploadTemplateColumnVO> getColumns(Long templateId) {
|
||||
return uploadMapper.selectColumnsByTemplateId(templateId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Long create(UploadTemplateVO vo, List<UploadTemplateColumnVO> columns) {
|
||||
if (uploadMapper.selectTemplateByCode(vo.getTemplateCode()) != null) {
|
||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 템플릿 코드입니다");
|
||||
}
|
||||
UploadWhitelist.requireTargetTable(vo.getTargetTable());
|
||||
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||
uploadMapper.insertTemplate(vo);
|
||||
replaceColumns(vo.getTemplateId(), columns);
|
||||
return vo.getTemplateId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void update(Long templateId, UploadTemplateVO vo, List<UploadTemplateColumnVO> columns) {
|
||||
UploadTemplateVO existing = get(templateId);
|
||||
UploadWhitelist.requireTargetTable(vo.getTargetTable());
|
||||
vo.setTemplateId(templateId);
|
||||
vo.setTemplateCode(existing.getTemplateCode()); // 코드는 불변
|
||||
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
|
||||
uploadMapper.updateTemplate(vo);
|
||||
replaceColumns(templateId, columns);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long templateId) {
|
||||
get(templateId);
|
||||
uploadMapper.deleteColumnsByTemplateId(templateId);
|
||||
uploadMapper.deleteTemplate(templateId);
|
||||
}
|
||||
|
||||
private void replaceColumns(Long templateId, List<UploadTemplateColumnVO> columns) {
|
||||
uploadMapper.deleteColumnsByTemplateId(templateId);
|
||||
if (columns == null) return;
|
||||
int order = 1;
|
||||
for (UploadTemplateColumnVO c : columns) {
|
||||
UploadWhitelist.requireValidIdentifier(c.getTargetField());
|
||||
c.setTemplateId(templateId);
|
||||
if (c.getSortOrder() == null) c.setSortOrder(order++);
|
||||
uploadMapper.insertColumn(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** upload_template 테이블 1:1 매핑. */
|
||||
@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;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package com.ga.common.upload;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 동적 SQL(${})에 들어가는 식별자 화이트리스트.
|
||||
* SQL Injection을 막기 위해 모든 동적 테이블/컬럼명은 여기를 통과해야 한다.
|
||||
*/
|
||||
public final class UploadWhitelist {
|
||||
|
||||
private UploadWhitelist() {}
|
||||
|
||||
/** 일반 식별자 패턴: 영문/숫자/_ 1~64자 */
|
||||
private static final Pattern IDENT = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]{0,63}$");
|
||||
|
||||
/** 업로드 INSERT 대상 테이블 화이트리스트 */
|
||||
private static final Set<String> ALLOWED_TARGET_TABLES = Set.of(
|
||||
"recruit_ledger",
|
||||
"maintain_ledger",
|
||||
"exception_ledger",
|
||||
"contract",
|
||||
"agent",
|
||||
"raw_commission_data"
|
||||
);
|
||||
|
||||
/** LOOKUP 참조 가능 테이블 */
|
||||
private static final Set<String> ALLOWED_LOOKUP_TABLES = Set.of(
|
||||
"agent",
|
||||
"product",
|
||||
"contract",
|
||||
"organization",
|
||||
"insurance_company",
|
||||
"common_code"
|
||||
);
|
||||
|
||||
public static boolean isAllowedTargetTable(String table) {
|
||||
return table != null && ALLOWED_TARGET_TABLES.contains(table.toLowerCase());
|
||||
}
|
||||
|
||||
public static boolean isAllowedLookupTable(String table) {
|
||||
return table != null && ALLOWED_LOOKUP_TABLES.contains(table.toLowerCase());
|
||||
}
|
||||
|
||||
/** 컬럼명 검증: 식별자 패턴만 통과. (예약어 검사는 필요시 별도) */
|
||||
public static boolean isValidIdentifier(String s) {
|
||||
return s != null && IDENT.matcher(s).matches();
|
||||
}
|
||||
|
||||
public static void requireTargetTable(String table) {
|
||||
if (!isAllowedTargetTable(table)) {
|
||||
throw new IllegalArgumentException("허용되지 않은 INSERT 대상 테이블: " + table);
|
||||
}
|
||||
}
|
||||
|
||||
public static void requireValidIdentifier(String s) {
|
||||
if (!isValidIdentifier(s)) {
|
||||
throw new IllegalArgumentException("허용되지 않은 식별자: " + s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.ga.common.validation;
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 자주 쓰는 검증 함수 모음. 정적 메서드로 호출.
|
||||
*
|
||||
* 사용:
|
||||
* CommonValidator.notBlank(req.getName(), "설계사명");
|
||||
* CommonValidator.email(req.getEmail());
|
||||
* CommonValidator.bizNo(req.getBizNo());
|
||||
*/
|
||||
public final class CommonValidator {
|
||||
|
||||
private CommonValidator() {}
|
||||
|
||||
private static final Pattern EMAIL = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
|
||||
private static final Pattern PHONE = Pattern.compile("^0\\d{1,2}-?\\d{3,4}-?\\d{4}$");
|
||||
private static final Pattern BIZ_NO = Pattern.compile("^\\d{3}-?\\d{2}-?\\d{5}$");
|
||||
private static final Pattern RRN = Pattern.compile("^\\d{6}-?\\d{7}$");
|
||||
private static final Pattern ACCOUNT = Pattern.compile("^\\d{6,20}(-?\\d{1,10})*$");
|
||||
private static final Pattern NUMERIC = Pattern.compile("^-?\\d+(\\.\\d+)?$");
|
||||
private static final Pattern PASSWORD = Pattern.compile(
|
||||
"^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-\\[\\]{};:'\"<>,.?/]).{8,20}$");
|
||||
|
||||
/* ==========================================================
|
||||
필수/길이
|
||||
========================================================== */
|
||||
public static void notBlank(String value, String fieldName) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 필수입니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void notNull(Object value, String fieldName) {
|
||||
if (value == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 필수입니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void maxLength(String value, int max, String fieldName) {
|
||||
if (value != null && value.length() > max) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL,
|
||||
fieldName + "은(는) " + max + "자 이내여야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void minLength(String value, int min, String fieldName) {
|
||||
if (value == null || value.length() < min) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL,
|
||||
fieldName + "은(는) " + min + "자 이상이어야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
포맷 (정규식)
|
||||
========================================================== */
|
||||
public static void email(String value) {
|
||||
if (value != null && !EMAIL.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "이메일 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void phone(String value) {
|
||||
if (value != null && !PHONE.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "전화번호 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void bizNo(String value) {
|
||||
if (value != null && !BIZ_NO.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "사업자번호 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void residentNo(String value) {
|
||||
if (value != null && !RRN.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "주민등록번호 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void accountNo(String value) {
|
||||
if (value != null && !ACCOUNT.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, "계좌번호 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void numeric(String value, String fieldName) {
|
||||
if (value != null && !NUMERIC.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 숫자만 입력하세요");
|
||||
}
|
||||
}
|
||||
|
||||
public static void password(String value) {
|
||||
if (value == null || !PASSWORD.matcher(value).matches()) {
|
||||
throw new BizException(ErrorCode.PASSWORD_POLICY,
|
||||
"영문/숫자/특수문자 조합 8~20자로 입력하세요");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
범위/숫자
|
||||
========================================================== */
|
||||
public static void positive(BigDecimal value, String fieldName) {
|
||||
if (value == null || value.signum() <= 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 0보다 커야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void notNegative(BigDecimal value, String fieldName) {
|
||||
if (value != null && value.signum() < 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 음수일 수 없습니다");
|
||||
}
|
||||
}
|
||||
|
||||
public static void range(int value, int min, int max, String fieldName) {
|
||||
if (value < min || value > max) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL,
|
||||
fieldName + "은(는) " + min + " ~ " + max + " 범위여야 합니다");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
날짜/기간
|
||||
========================================================== */
|
||||
public static void date(String value, String pattern) {
|
||||
if (value == null) return;
|
||||
try {
|
||||
LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern));
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new BizException(ErrorCode.VALIDATION_FAIL,
|
||||
"날짜 형식이 올바르지 않습니다 (예: " + pattern + ")");
|
||||
}
|
||||
}
|
||||
|
||||
public static void period(LocalDate from, LocalDate to) {
|
||||
if (from != null && to != null && from.isAfter(to)) {
|
||||
throw new BizException(ErrorCode.INVALID_PERIOD, "시작일이 종료일보다 늦습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
사용자 정의
|
||||
========================================================== */
|
||||
public static void check(boolean condition, ErrorCode errorCode, String message) {
|
||||
if (!condition) {
|
||||
throw new BizException(errorCode, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.ga.api.controller.${GROUP};
|
||||
|
||||
import com.ga.api.service.${GROUP}.${ENTITY}Service;
|
||||
import com.ga.common.annotation.DataChangeLog;
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}Resp;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}SaveReq;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/${TABLE}s")
|
||||
@Tag(name = "${LABEL}")
|
||||
@RequiredArgsConstructor
|
||||
public class ${ENTITY}Controller {
|
||||
|
||||
private final ${ENTITY}Service ${ENTITY_CAMEL}Service;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(menu = "${MENU}", perm = "READ")
|
||||
public ApiResponse<PageResponse<${ENTITY}Resp>> list(${ENTITY}SearchParam param) {
|
||||
return ApiResponse.ok(${ENTITY_CAMEL}Service.list(param));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePermission(menu = "${MENU}", perm = "READ")
|
||||
public ApiResponse<${ENTITY}Resp> detail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(${ENTITY_CAMEL}Service.detail(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(menu = "${MENU}", perm = "CREATE")
|
||||
@DataChangeLog(menu = "${MENU}", table = "${TABLE}")
|
||||
public ApiResponse<Void> create(@Valid @RequestBody ${ENTITY}SaveReq req) {
|
||||
${ENTITY_CAMEL}Service.create(req);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(menu = "${MENU}", perm = "UPDATE")
|
||||
@DataChangeLog(menu = "${MENU}", table = "${TABLE}")
|
||||
public ApiResponse<Void> update(@PathVariable Long id, @Valid @RequestBody ${ENTITY}SaveReq req) {
|
||||
${ENTITY_CAMEL}Service.update(id, req);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(menu = "${MENU}", perm = "DELETE")
|
||||
@DataChangeLog(menu = "${MENU}", table = "${TABLE}")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
${ENTITY_CAMEL}Service.delete(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Form, Input, Button, Space } from 'antd';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { ${ENTITY_CAMEL}Api } from '@/api/${ENTITY_CAMEL}';
|
||||
|
||||
export default function ${ENTITY}Detail() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['${ENTITY_CAMEL}', id],
|
||||
queryFn: () => ${ENTITY_CAMEL}Api.detail(Number(id)),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: any) => id
|
||||
? ${ENTITY_CAMEL}Api.update(Number(id), values)
|
||||
: ${ENTITY_CAMEL}Api.create(values),
|
||||
onSuccess: () => nav(-1),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical" initialValues={data} onFinish={save.mutate}>
|
||||
{/* 입력 폼 필드 추가 */}
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>저장</Button>
|
||||
<Button onClick={() => nav(-1)}>취소</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Space, Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ${ENTITY_CAMEL}Api } from '@/api/${ENTITY_CAMEL}';
|
||||
import { SearchForm } from '@/components/common/SearchForm';
|
||||
import { PermissionButton } from '@/components/common/PermissionButton';
|
||||
|
||||
export default function ${ENTITY}List() {
|
||||
const [param, setParam] = useState({ pageNum: 1, pageSize: 20 });
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['${ENTITY_CAMEL}', param],
|
||||
queryFn: () => ${ENTITY_CAMEL}Api.list(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })} />
|
||||
<Space style={{ margin: '12px 0' }}>
|
||||
<PermissionButton menu="${MENU}" perm="CREATE" type="primary">신규</PermissionButton>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list ?? []}
|
||||
pagination={{
|
||||
current: param.pageNum,
|
||||
pageSize: param.pageSize,
|
||||
total: data?.total ?? 0,
|
||||
onChange: (pageNum, pageSize) => setParam({ ...param, pageNum, pageSize }),
|
||||
}}
|
||||
columns={[
|
||||
// 컬럼을 추가하세요.
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { request } from './request';
|
||||
|
||||
export const ${ENTITY_CAMEL}Api = {
|
||||
list: (params: any) => request.get('/api/${TABLE}s', { params }),
|
||||
detail: (id: number) => request.get(`/api/${TABLE}s/${'${id}'}`),
|
||||
create: (data: any) => request.post('/api/${TABLE}s', data),
|
||||
update: (id: number, data: any) => request.put(`/api/${TABLE}s/${'${id}'}`, data),
|
||||
delete: (id: number) => request.delete(`/api/${TABLE}s/${'${id}'}`),
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.ga.core.mapper.${GROUP};
|
||||
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}VO;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}Resp;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ${ENTITY}Mapper {
|
||||
List<${ENTITY}Resp> selectList(${ENTITY}SearchParam param);
|
||||
${ENTITY}Resp selectById(Long id);
|
||||
int insert(${ENTITY}VO vo);
|
||||
int update(${ENTITY}VO vo);
|
||||
int deleteById(Long id);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?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.${GROUP}.${ENTITY}Mapper">
|
||||
|
||||
<!-- 목록: 조인 컬럼이 있다면 SELECT에 추가하세요. -->
|
||||
<select id="selectList" resultType="com.ga.core.vo.${GROUP}.${ENTITY}Resp">
|
||||
SELECT *
|
||||
FROM ${TABLE}
|
||||
<where>
|
||||
<if test="searchKeyword != null and searchKeyword != ''">
|
||||
AND (/* 검색 조건 추가 */ 1=0)
|
||||
</if>
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<select id="selectById" resultType="com.ga.core.vo.${GROUP}.${ENTITY}Resp">
|
||||
SELECT *
|
||||
FROM ${TABLE}
|
||||
WHERE /* PK 조건 */ 1=1
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.ga.core.vo.${GROUP}.${ENTITY}VO">
|
||||
INSERT INTO ${TABLE} (
|
||||
${INSERT_COLUMNS}
|
||||
) VALUES (
|
||||
${INSERT_VALUES}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.ga.core.vo.${GROUP}.${ENTITY}VO">
|
||||
UPDATE ${TABLE}
|
||||
SET ${UPDATE_SETS}
|
||||
WHERE /* PK 조건 */ 1=1
|
||||
</update>
|
||||
|
||||
<delete id="deleteById">
|
||||
DELETE FROM ${TABLE}
|
||||
WHERE /* PK 조건 */ 1=1
|
||||
</delete>
|
||||
</mapper>
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.ga.api.service.${GROUP};
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.mapper.${GROUP}.${ENTITY}Mapper;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}Resp;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}SaveReq;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam;
|
||||
import com.ga.core.vo.${GROUP}.${ENTITY}VO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ${ENTITY}Service {
|
||||
|
||||
private final ${ENTITY}Mapper ${ENTITY_CAMEL}Mapper;
|
||||
|
||||
public PageResponse<${ENTITY}Resp> list(${ENTITY}SearchParam param) {
|
||||
param.startPage();
|
||||
return PageResponse.of(${ENTITY_CAMEL}Mapper.selectList(param));
|
||||
}
|
||||
|
||||
public ${ENTITY}Resp detail(Long id) {
|
||||
${ENTITY}Resp r = ${ENTITY_CAMEL}Mapper.selectById(id);
|
||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||
return r;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void create(${ENTITY}SaveReq req) {
|
||||
${ENTITY}VO vo = new ${ENTITY}VO();
|
||||
org.springframework.beans.BeanUtils.copyProperties(req, vo);
|
||||
${ENTITY_CAMEL}Mapper.insert(vo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void update(Long id, ${ENTITY}SaveReq req) {
|
||||
${ENTITY}VO vo = new ${ENTITY}VO();
|
||||
org.springframework.beans.BeanUtils.copyProperties(req, vo);
|
||||
// PK 세팅 — 테이블 PK명에 맞게 수정하세요.
|
||||
${ENTITY_CAMEL}Mapper.update(vo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
${ENTITY_CAMEL}Mapper.deleteById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.ga.core.vo.${GROUP};
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** ${TABLE} 테이블 1:1 매핑. INSERT/UPDATE에 사용. */
|
||||
@Data
|
||||
public class ${ENTITY}VO {
|
||||
${FIELDS_VO}}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.ga.core.vo.${GROUP};
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** ${TABLE} 응답. 조인 결과(다른 테이블 이름 필드 등)를 추가하세요. */
|
||||
@Data
|
||||
public class ${ENTITY}Resp {
|
||||
${FIELDS_RESP}}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.ga.core.vo.${GROUP};
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** ${TABLE} 등록/수정 요청. ID/감사필드는 제외. */
|
||||
@Data
|
||||
public class ${ENTITY}SaveReq {
|
||||
${FIELDS_SAVEREQ}}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.ga.core.vo.${GROUP};
|
||||
|
||||
import com.ga.common.model.SearchParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/** ${TABLE} 검색 조건. SearchParam의 공통 필드(pageNum/pageSize/searchKeyword 등) 상속. */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ${ENTITY}SearchParam extends SearchParam {
|
||||
// 화면별 필터 필드를 추가하세요.
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
-- V18: 엑셀 업로드 매핑 템플릿 시스템
|
||||
-- 보험사/시기별로 엑셀 양식이 다른 문제를 해결.
|
||||
-- 화면에서 매핑 규칙을 관리하면, 같은 코드로 다양한 엑셀을 처리한다.
|
||||
|
||||
-- 업로드 템플릿 마스터
|
||||
CREATE TABLE upload_template (
|
||||
template_id SERIAL PRIMARY KEY,
|
||||
template_code VARCHAR(50) NOT NULL UNIQUE, -- SAMSUNG_RECRUIT, HANWHA_MAINTAIN ...
|
||||
template_name VARCHAR(100) NOT NULL, -- "삼성생명 모집수수료"
|
||||
category VARCHAR(30) NOT NULL, -- RECRUIT / MAINTAIN / CONTRACT / AGENT / CUSTOM
|
||||
target_table VARCHAR(50) NOT NULL, -- 적재 대상 테이블 (recruit_ledger 등)
|
||||
company_code VARCHAR(20), -- 보험사코드 (NULL이면 범용)
|
||||
header_row INT NOT NULL DEFAULT 1, -- 헤더 행 번호 (1부터)
|
||||
data_start_row INT NOT NULL DEFAULT 2, -- 데이터 시작 행 (1부터)
|
||||
sheet_index INT NOT NULL DEFAULT 0, -- 시트 인덱스 (0부터)
|
||||
sheet_name VARCHAR(50), -- 시트명 (지정 시 인덱스 무시)
|
||||
file_encoding VARCHAR(20) DEFAULT 'UTF-8', -- CSV 인코딩
|
||||
delimiter VARCHAR(5), -- CSV 구분자
|
||||
skip_empty_row CHAR(1) DEFAULT 'Y', -- 빈 행 건너뛰기
|
||||
max_error_count INT DEFAULT 1000, -- 최대 허용 에러 수
|
||||
batch_size INT DEFAULT 1000, -- 배치 INSERT 크기
|
||||
description VARCHAR(200),
|
||||
is_active CHAR(1) DEFAULT 'Y',
|
||||
created_at TIMESTAMP NOT NULL 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);
|
||||
|
||||
-- 업로드 템플릿 컬럼 매핑
|
||||
CREATE TABLE upload_template_column (
|
||||
column_id SERIAL 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), -- 헤더명 (자동 탐지용)
|
||||
|
||||
-- 타겟 (DB)
|
||||
target_field VARCHAR(50) NOT NULL, -- DB 컬럼명
|
||||
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 / REPLACE / SUBSTRING / CONCAT / DEFAULT
|
||||
transform_param VARCHAR(200), -- JSON 또는 단순값
|
||||
date_format VARCHAR(30), -- yyyyMMdd / yyyy-MM-dd ...
|
||||
number_divide INT, -- 나누기 (전→원: 100)
|
||||
number_multiply INT, -- 곱하기
|
||||
fixed_value VARCHAR(200), -- source_type=FIXED일 때
|
||||
default_value VARCHAR(200), -- NULL일 때 기본값
|
||||
|
||||
-- 조회 변환 (LOOKUP)
|
||||
lookup_table VARCHAR(50), -- 참조 테이블
|
||||
lookup_source_field VARCHAR(50), -- 참조 검색 필드
|
||||
lookup_target_field VARCHAR(50), -- 참조 결과 필드
|
||||
|
||||
-- 코드 매핑
|
||||
code_map_group VARCHAR(50), -- code_mapping 그룹
|
||||
|
||||
-- 검증
|
||||
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);
|
||||
|
||||
-- 업로드 이력
|
||||
CREATE TABLE upload_history (
|
||||
history_id SERIAL 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 FK (에러 엑셀)
|
||||
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);
|
||||
CREATE INDEX idx_upload_history_user ON upload_history(created_by, started_at);
|
||||
@@ -1,84 +0,0 @@
|
||||
-- V19: DBA 검토 결과 보강
|
||||
--
|
||||
-- V13~V15 검토 결과 누락된 인덱스/통계 갱신을 추가.
|
||||
-- 1만건 ~ 1000만건 가정 EXPLAIN ANALYZE 결과는 각 SECTION 상단 코멘트 참고.
|
||||
|
||||
-- ========================================================================
|
||||
-- [1] AGENT 검색 인덱스 보강
|
||||
-- 사용처: AgentMapper.selectList (재귀 CTE 포함)
|
||||
-- 검토: 기존 PK + (org_id), (license_no UNIQUE) 외에 status 별 카운트가 빈번
|
||||
-- 결과: 1만건 시 Seq Scan → idx 사용 필요
|
||||
-- ========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_status_org ON agent(status, org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_grade ON agent(grade_id) WHERE status = 'ACTIVE';
|
||||
|
||||
-- ========================================================================
|
||||
-- [2] CONTRACT 빈번 검색
|
||||
-- 사용처: AgentResp.contract_count subquery, ContractMapper.selectList
|
||||
-- 검토: contract(agent_id, status) 필요 (V13 이미 status,agent_id 있음 — 순서 검토)
|
||||
-- 결과: 복합 인덱스 (agent_id, status) 가 contract_count subquery에 더 효율적
|
||||
-- ========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_agent_status ON contract(agent_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_contract_policy ON contract(policy_no); -- UNIQUE라면 자동 — 없으면 추가
|
||||
|
||||
-- ========================================================================
|
||||
-- [3] LEDGER 부분 인덱스 — 미정산건 조회 빠르게
|
||||
-- 검토: reconcile_status='PENDING' 인 행만 조회하는 패턴이 많음
|
||||
-- ========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_rl_pending ON recruit_ledger(settle_month, agent_id)
|
||||
WHERE reconcile_status = 'PENDING';
|
||||
CREATE INDEX IF NOT EXISTS idx_ml_pending ON maintain_ledger(settle_month, agent_id)
|
||||
WHERE reconcile_status = 'PENDING';
|
||||
|
||||
-- ========================================================================
|
||||
-- [4] UPLOAD_HISTORY 사용자별 최근 이력
|
||||
-- 사용처: 업로드 화면 마이페이지
|
||||
-- ========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_history_user_recent
|
||||
ON upload_history(created_by, started_at DESC);
|
||||
|
||||
-- ========================================================================
|
||||
-- [5] BATCH_JOB_LOG 모니터링 (현재 실행중 잡 빠른 조회)
|
||||
-- ========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_running ON batch_job_log(job_name, status)
|
||||
WHERE status IN ('STARTED', 'RUNNING');
|
||||
|
||||
-- ========================================================================
|
||||
-- [6] 통계 업데이트
|
||||
-- 대량 데이터 적재 후에는 수동으로 ANALYZE 호출 권장.
|
||||
-- 운영에서는 autovacuum 보조용으로 주요 테이블만 명시.
|
||||
-- ========================================================================
|
||||
ANALYZE agent;
|
||||
ANALYZE contract;
|
||||
ANALYZE recruit_ledger;
|
||||
ANALYZE maintain_ledger;
|
||||
ANALYZE settle_master;
|
||||
ANALYZE chargeback;
|
||||
ANALYZE payment;
|
||||
|
||||
-- ========================================================================
|
||||
-- [참고] EXPLAIN ANALYZE 권장 시나리오
|
||||
-- 운영 적재 후 다음 쿼리들의 실행계획을 확인할 것:
|
||||
--
|
||||
-- -- (1) 설계사 목록 (org 하위 포함)
|
||||
-- EXPLAIN ANALYZE
|
||||
-- SELECT * FROM agent a
|
||||
-- JOIN organization o ON o.org_id = a.org_id
|
||||
-- WHERE a.org_id IN (WITH RECURSIVE d AS (
|
||||
-- SELECT org_id FROM organization WHERE org_id = 1
|
||||
-- UNION ALL
|
||||
-- SELECT c.org_id FROM organization c JOIN d ON c.parent_org_id = d.org_id
|
||||
-- ) SELECT org_id FROM d)
|
||||
-- AND a.status = 'ACTIVE';
|
||||
-- 기대: Index Scan idx_agent_status_org + CTE Scan
|
||||
--
|
||||
-- -- (2) 정산 집계
|
||||
-- EXPLAIN ANALYZE
|
||||
-- SELECT settle_month, agent_id, SUM(net_amount)
|
||||
-- FROM settle_master WHERE settle_month BETWEEN '202401' AND '202412'
|
||||
-- GROUP BY settle_month, agent_id;
|
||||
-- 기대: Index Only Scan idx_sm_month_status
|
||||
--
|
||||
-- -- (3) 환수 위험 (V15 v_chargeback_risk 가정)
|
||||
-- EXPLAIN ANALYZE SELECT * FROM v_chargeback_risk LIMIT 100;
|
||||
-- ========================================================================
|
||||
@@ -1,188 +0,0 @@
|
||||
<?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.common.upload.UploadMapper">
|
||||
|
||||
<!-- ============================================================
|
||||
upload_template
|
||||
============================================================ -->
|
||||
<sql id="tplColumns">
|
||||
template_id, 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, updated_at, updated_by
|
||||
</sql>
|
||||
|
||||
<select id="selectTemplateByCode" resultType="com.ga.common.upload.UploadTemplateVO">
|
||||
SELECT <include refid="tplColumns"/> FROM upload_template WHERE template_code = #{code}
|
||||
</select>
|
||||
|
||||
<select id="selectTemplateById" resultType="com.ga.common.upload.UploadTemplateVO">
|
||||
SELECT <include refid="tplColumns"/> FROM upload_template WHERE template_id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectTemplateList" resultType="com.ga.common.upload.UploadTemplateVO">
|
||||
SELECT <include refid="tplColumns"/>
|
||||
FROM upload_template
|
||||
WHERE is_active = 'Y'
|
||||
<if test="category != null and category != ''">
|
||||
AND category = #{category}
|
||||
</if>
|
||||
<if test="companyCode != null and companyCode != ''">
|
||||
AND (company_code = #{companyCode} OR company_code IS NULL)
|
||||
</if>
|
||||
ORDER BY category, template_name
|
||||
</select>
|
||||
|
||||
<insert id="insertTemplate" parameterType="com.ga.common.upload.UploadTemplateVO"
|
||||
useGeneratedKeys="true" keyProperty="templateId" keyColumn="template_id">
|
||||
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_by
|
||||
) VALUES (
|
||||
#{templateCode}, #{templateName}, #{category}, #{targetTable}, #{companyCode},
|
||||
#{headerRow}, #{dataStartRow}, #{sheetIndex}, #{sheetName}, #{fileEncoding}, #{delimiter},
|
||||
#{skipEmptyRow}, #{maxErrorCount}, #{batchSize}, #{description}, COALESCE(#{isActive}, 'Y'),
|
||||
#{createdBy}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateTemplate" parameterType="com.ga.common.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="deleteTemplate">
|
||||
DELETE FROM upload_template WHERE template_id = #{id}
|
||||
</delete>
|
||||
|
||||
<!-- ============================================================
|
||||
upload_template_column
|
||||
============================================================ -->
|
||||
<select id="selectColumnsByTemplateId" resultType="com.ga.common.upload.UploadTemplateColumnVO">
|
||||
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
|
||||
</select>
|
||||
|
||||
<insert id="insertColumn" parameterType="com.ga.common.upload.UploadTemplateColumnVO"
|
||||
useGeneratedKeys="true" keyProperty="columnId" keyColumn="column_id">
|
||||
INSERT INTO upload_template_column (
|
||||
template_id, sort_order,
|
||||
source_type, source_column, source_header,
|
||||
target_field, target_type,
|
||||
transform_rule, transform_param, date_format, number_divide, number_multiply,
|
||||
fixed_value, default_value,
|
||||
lookup_table, lookup_source_field, lookup_target_field,
|
||||
code_map_group,
|
||||
is_required, validation_regex, validation_message,
|
||||
description
|
||||
) VALUES (
|
||||
#{templateId}, #{sortOrder},
|
||||
COALESCE(#{sourceType}, 'COLUMN'), #{sourceColumn}, #{sourceHeader},
|
||||
#{targetField}, COALESCE(#{targetType}, 'STRING'),
|
||||
#{transformRule}, #{transformParam}, #{dateFormat}, #{numberDivide}, #{numberMultiply},
|
||||
#{fixedValue}, #{defaultValue},
|
||||
#{lookupTable}, #{lookupSourceField}, #{lookupTargetField},
|
||||
#{codeMapGroup},
|
||||
COALESCE(#{isRequired}, 'N'), #{validationRegex}, #{validationMessage},
|
||||
#{description}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<delete id="deleteColumnsByTemplateId">
|
||||
DELETE FROM upload_template_column WHERE template_id = #{templateId}
|
||||
</delete>
|
||||
|
||||
<!-- ============================================================
|
||||
upload_history
|
||||
============================================================ -->
|
||||
<insert id="insertHistory" parameterType="com.ga.common.upload.UploadHistoryVO"
|
||||
useGeneratedKeys="true" keyProperty="historyId" keyColumn="history_id">
|
||||
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}, COALESCE(#{status}, 'PROCESSING'), #{errorMessage},
|
||||
COALESCE(#{startedAt}, NOW()), #{finishedAt}, #{durationSec}, #{createdBy}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateHistory" parameterType="com.ga.common.upload.UploadHistoryVO">
|
||||
UPDATE upload_history
|
||||
SET total_count = #{totalCount},
|
||||
success_count = #{successCount},
|
||||
error_count = #{errorCount},
|
||||
skip_count = #{skipCount},
|
||||
error_file_id = #{errorFileId},
|
||||
status = #{status},
|
||||
error_message = #{errorMessage},
|
||||
finished_at = #{finishedAt},
|
||||
duration_sec = #{durationSec}
|
||||
WHERE history_id = #{historyId}
|
||||
</update>
|
||||
|
||||
<select id="selectHistoryByTemplate" resultType="com.ga.common.upload.UploadHistoryVO">
|
||||
SELECT history_id, 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
|
||||
FROM upload_history
|
||||
WHERE template_id = #{templateId}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- ============================================================
|
||||
동적 배치 INSERT
|
||||
주의: ${}는 SQL Injection 위험. 호출 측에서 화이트리스트 검증 필수.
|
||||
============================================================ -->
|
||||
<insert id="dynamicBatchInsert">
|
||||
INSERT INTO ${targetTable} (
|
||||
<foreach collection="fields" item="field" separator=",">${field}</foreach>
|
||||
) VALUES
|
||||
<foreach collection="rows" item="row" separator=",">
|
||||
(<foreach collection="fields" item="field" separator=",">#{row[${field}]}</foreach>)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<!-- ============================================================
|
||||
LOOKUP 캐시 로드
|
||||
============================================================ -->
|
||||
<select id="selectLookupAll" resultType="java.util.Map">
|
||||
SELECT ${sourceField} AS source_value, ${targetField} AS target_value
|
||||
FROM ${table}
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -1,32 +0,0 @@
|
||||
# Common messages (English)
|
||||
|
||||
# Common
|
||||
common.success=Processed successfully.
|
||||
common.confirm=Do you want to continue?
|
||||
common.delete.confirm=Are you sure you want to delete?
|
||||
|
||||
# Validation
|
||||
validation.required={0} is required.
|
||||
validation.maxLength={0} must be at most {1} characters.
|
||||
validation.minLength={0} must be at least {1} characters.
|
||||
validation.email=Invalid email format.
|
||||
validation.phone=Invalid phone number format.
|
||||
validation.bizNo=Invalid business registration number.
|
||||
validation.residentNo=Invalid resident registration number.
|
||||
validation.accountNo=Invalid bank account number.
|
||||
validation.numeric={0} must be numeric.
|
||||
validation.amount=Invalid amount.
|
||||
validation.date=Invalid date format.
|
||||
validation.range={0} must be between {1} and {2}.
|
||||
|
||||
# Upload
|
||||
upload.template.notFound=No upload template registered.
|
||||
upload.template.duplicate=A template with the same code already exists.
|
||||
upload.column.duplicate=A mapping with the same order already exists.
|
||||
upload.preview.fail=Failed to preview the Excel file.
|
||||
upload.processing=Upload in progress.
|
||||
upload.complete={0} rows uploaded successfully ({1} failed)
|
||||
|
||||
# Permission/Audit
|
||||
auth.permission.required=You do not have permission for this action.
|
||||
audit.changed={0} change(s) have been recorded.
|
||||
@@ -1,33 +0,0 @@
|
||||
# 공통 메시지 (한국어)
|
||||
# ErrorCode enum의 message는 enum에서 직접 사용하고, 여기는 화면/검증 메시지 위주.
|
||||
|
||||
# 공통
|
||||
common.success=처리되었습니다.
|
||||
common.confirm=계속 진행하시겠습니까?
|
||||
common.delete.confirm=정말 삭제하시겠습니까?
|
||||
|
||||
# 검증
|
||||
validation.required={0}은(는) 필수입니다.
|
||||
validation.maxLength={0}은(는) {1}자 이내로 입력하세요.
|
||||
validation.minLength={0}은(는) {1}자 이상 입력하세요.
|
||||
validation.email=이메일 형식이 올바르지 않습니다.
|
||||
validation.phone=전화번호 형식이 올바르지 않습니다.
|
||||
validation.bizNo=사업자번호 형식이 올바르지 않습니다.
|
||||
validation.residentNo=주민등록번호 형식이 올바르지 않습니다.
|
||||
validation.accountNo=계좌번호 형식이 올바르지 않습니다.
|
||||
validation.numeric={0}은(는) 숫자만 입력 가능합니다.
|
||||
validation.amount=금액이 올바르지 않습니다.
|
||||
validation.date=날짜 형식이 올바르지 않습니다.
|
||||
validation.range={0}은(는) {1} ~ {2} 범위여야 합니다.
|
||||
|
||||
# 업로드
|
||||
upload.template.notFound=등록된 업로드 템플릿이 없습니다.
|
||||
upload.template.duplicate=이미 동일한 코드의 템플릿이 존재합니다.
|
||||
upload.column.duplicate=동일한 순서의 매핑이 존재합니다.
|
||||
upload.preview.fail=엑셀 미리보기에 실패했습니다.
|
||||
upload.processing=업로드 처리 중입니다.
|
||||
upload.complete={0}건 업로드 완료 (실패 {1}건)
|
||||
|
||||
# 권한/감사
|
||||
auth.permission.required=해당 작업에 필요한 권한이 없습니다.
|
||||
audit.changed={0}건의 변경 이력이 기록되었습니다.
|
||||
@@ -16,6 +16,13 @@ import UserList from '@/pages/system/UserList';
|
||||
import RoleList from '@/pages/system/RoleList';
|
||||
import CodeList from '@/pages/system/CodeList';
|
||||
|
||||
// 신규: 수수료규정/데이터수신
|
||||
import CommissionRateList from '@/pages/rule/CommissionRateList';
|
||||
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
|
||||
import ProfileList from '@/pages/receive/ProfileList';
|
||||
import ErrorList from '@/pages/receive/ErrorList';
|
||||
import FieldMappingEditor from '@/pages/receive/FieldMappingEditor';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
@@ -46,6 +53,16 @@ export default function App() {
|
||||
<Route path="settle/batch" element={<BatchRun />} />
|
||||
<Route path="payments" element={<PaymentList />} />
|
||||
|
||||
{/* 수수료 규정 */}
|
||||
<Route path="rules/commission-rates" element={<CommissionRateList />} />
|
||||
<Route path="rules/payout" element={<PayoutRuleList />} />
|
||||
|
||||
{/* 데이터수신 */}
|
||||
<Route path="receive/profiles" element={<ProfileList />} />
|
||||
<Route path="receive/errors" element={<ErrorList />} />
|
||||
<Route path="receive/mapping/:companyCode/:targetTable" element={<FieldMappingEditor />} />
|
||||
<Route path="receive/mapping/:companyCode" element={<FieldMappingEditor />} />
|
||||
|
||||
{/* 시스템관리 */}
|
||||
<Route path="system/users" element={<UserList />} />
|
||||
<Route path="system/roles" element={<RoleList />} />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { request } from './request';
|
||||
|
||||
export const receiveApi = {
|
||||
// company_profile
|
||||
listProfiles: (params: any) => request.get('/api/receive/profiles', { params }),
|
||||
profileDetail: (companyCode: string) => request.get(`/api/receive/profiles/${companyCode}`),
|
||||
saveProfile: (data: any) => request.post('/api/receive/profiles', data),
|
||||
|
||||
// field_mapping
|
||||
listFieldMappings: (companyCode: string, targetTable?: string) =>
|
||||
request.get(`/api/mapping/${companyCode}/fields`, { params: { targetTable } }),
|
||||
createFieldMapping: (data: any) => request.post('/api/mapping/fields', data),
|
||||
updateFieldMapping: (id: number, data: any) => request.put(`/api/mapping/fields/${id}`, data),
|
||||
deleteFieldMapping: (id: number) => request.delete(`/api/mapping/fields/${id}`),
|
||||
bulkSaveFieldMappings: (companyCode: string, rows: any[]) =>
|
||||
request.post(`/api/mapping/${companyCode}/fields/bulk`, { rows }),
|
||||
|
||||
// code_mapping
|
||||
listCodeMappings: (params: any) => request.get('/api/mapping/codes', { params }),
|
||||
createCodeMapping: (data: any) => request.post('/api/mapping/codes', data),
|
||||
updateCodeMapping: (id: number, data: any) => request.put(`/api/mapping/codes/${id}`, data),
|
||||
deleteCodeMapping: (id: number) => request.delete(`/api/mapping/codes/${id}`),
|
||||
|
||||
// raw / errors
|
||||
listRaw: (params: any) => request.get('/api/receive/raw', { params }),
|
||||
listErrors: (params: any) => request.get('/api/receive/errors', { params }),
|
||||
resolveError: (id: number, data: { status: string; note: string }) =>
|
||||
request.put(`/api/receive/errors/${id}/resolve`, data),
|
||||
|
||||
// upload
|
||||
uploadFile: (templateCode: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return request.post(`/api/upload-templates/${templateCode}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
previewFile: (templateCode: string, file: File, rows = 5) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('rows', String(rows));
|
||||
return request.post(`/api/upload-templates/${templateCode}/preview`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { request } from './request';
|
||||
|
||||
export const ruleApi = {
|
||||
// commission_rate
|
||||
listCommission: (params: any) => request.get('/api/rules/commission-rates', { params }),
|
||||
createCommission: (data: any) => request.post('/api/rules/commission-rates', data),
|
||||
updateCommission: (id: number, data: any) => request.put(`/api/rules/commission-rates/${id}`, data),
|
||||
deleteCommission: (id: number) => request.delete(`/api/rules/commission-rates/${id}`),
|
||||
|
||||
// payout_rule
|
||||
listPayout: (params: any) => request.get('/api/rules/payout', { params }),
|
||||
createPayout: (data: any) => request.post('/api/rules/payout', data),
|
||||
updatePayout: (id: number, data: any) => request.put(`/api/rules/payout/${id}`, data),
|
||||
deletePayout: (id: number) => request.delete(`/api/rules/payout/${id}`),
|
||||
|
||||
// override_rule
|
||||
listOverride: (params: any) => request.get('/api/rules/override', { params }),
|
||||
createOverride: (data: any) => request.post('/api/rules/override', data),
|
||||
updateOverride: (id: number, data: any) => request.put(`/api/rules/override/${id}`, data),
|
||||
deleteOverride: (id: number) => request.delete(`/api/rules/override/${id}`),
|
||||
|
||||
// chargeback_rule
|
||||
listChargeback: (params: any) => request.get('/api/rules/chargeback', { params }),
|
||||
createChargeback: (data: any) => request.post('/api/rules/chargeback', data),
|
||||
updateChargeback: (id: number, data: any) => request.put(`/api/rules/chargeback/${id}`, data),
|
||||
deleteChargeback: (id: number) => request.delete(`/api/rules/chargeback/${id}`),
|
||||
|
||||
// exception_type_code
|
||||
listExceptionCodes: (params: any) => request.get('/api/rules/exception-codes', { params }),
|
||||
createExceptionCode: (data: any) => request.post('/api/rules/exception-codes', data),
|
||||
updateExceptionCode: (code: string, data: any) =>
|
||||
request.put(`/api/rules/exception-codes/${code}`, data),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
|
||||
export interface PageState {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface Props<T> extends Omit<TableProps<T>, 'pagination' | 'dataSource'> {
|
||||
data?: { list: T[]; total: number } | null;
|
||||
page: PageState;
|
||||
onPageChange: (p: PageState) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design Table 래퍼.
|
||||
* 서버사이드 페이징 + total 표시 + 100건 미만 데이터에 사용.
|
||||
* (대량 데이터/셀편집은 DataGrid 사용)
|
||||
*/
|
||||
export default function DataTable<T extends object>({ data, page, onPageChange, ...rest }: Props<T>) {
|
||||
return (
|
||||
<Table<T>
|
||||
rowKey={(row: any) => row.id ?? row.agentId ?? row.userId ?? row.companyId ?? row.productId ?? row.ledgerId ?? row.settleId ?? row.paymentId ?? JSON.stringify(row).slice(0, 30)}
|
||||
dataSource={data?.list ?? []}
|
||||
pagination={{
|
||||
current: page.pageNum,
|
||||
pageSize: page.pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `총 ${t.toLocaleString()}건`,
|
||||
onChange: (pageNum, pageSize) => onPageChange({ pageNum, pageSize }),
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Modal, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import { request } from '@/api/request';
|
||||
|
||||
interface Props {
|
||||
templateCode: string;
|
||||
label?: string;
|
||||
onComplete?: (result: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 버튼.
|
||||
* 1. 템플릿 코드로 미리보기 (첫 5행) → 사용자가 매핑 확인
|
||||
* 2. 확인 후 본 업로드 실행
|
||||
* 3. 결과 표시 (성공/실패/스킵)
|
||||
*/
|
||||
export default function ExcelImportButton({ templateCode, label = '엑셀 업로드', onComplete }: Props) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewRows, setPreviewRows] = useState<any[]>([]);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('rows', '5');
|
||||
const r = await request.post(`/api/upload-templates/${templateCode}/preview`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setPreviewRows(r as any);
|
||||
setPendingFile(file);
|
||||
setPreviewOpen(true);
|
||||
} catch (e: any) {
|
||||
message.error('미리보기 실패: ' + (e.message ?? ''));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!pendingFile) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', pendingFile);
|
||||
const r: any = await request.post(`/api/upload-templates/${templateCode}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
message.success(`${r.successCount}건 업로드 완료 (실패 ${r.errorCount}건)`);
|
||||
setPreviewOpen(false);
|
||||
onComplete?.(r);
|
||||
} catch (e: any) {
|
||||
message.error('업로드 실패: ' + (e.message ?? ''));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Upload beforeUpload={beforeUpload} showUploadList={false} accept=".xlsx,.xls,.csv">
|
||||
<Button icon={<UploadOutlined />}>{label}</Button>
|
||||
</Upload>
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title="업로드 미리보기 (첫 5행)"
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
onOk={confirmUpload}
|
||||
okText="업로드 실행"
|
||||
confirmLoading={busy}
|
||||
width={900}
|
||||
>
|
||||
{previewRows.length === 0 ? (
|
||||
<div>표시할 데이터가 없습니다.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{Object.keys(previewRows[0] ?? {}).map((k) => (
|
||||
<th key={k} style={{ border: '1px solid #ddd', padding: 4, background: '#fafafa' }}>{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{Object.keys(previewRows[0] ?? {}).map((k) => (
|
||||
<td key={k} style={{ border: '1px solid #ddd', padding: 4 }}>{String(row[k] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, Modal, Row, Select, message } from 'antd';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** code_mapping 등록/수정. */
|
||||
export default function CodeMappingModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.codeId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isActive: 'Y' });
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
if (isEdit) await receiveApi.updateCodeMapping(initial.codeId, v);
|
||||
else await receiveApi.createCodeMapping(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '코드 매핑 수정' : '코드 매핑 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="코드 유형" name="codeType" rules={[{ required: true }]}
|
||||
extra="예: INSURANCE_TYPE / PAY_CYCLE / CONTRACT_STATUS">
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="원본 코드" name="sourceCode" rules={[{ required: true }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="원본 명" name="sourceName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="표준 코드 (ga 시스템)" name="targetCode" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="표준 명" name="targetName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="활성" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
|
||||
export default function ErrorList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['receive-errors', param],
|
||||
queryFn: () => receiveApi.listErrors(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'companyCode', label: '보험사 코드' },
|
||||
{ type: 'code', name: 'errorType', label: '에러 유형', groupCode: 'ERROR_TYPE' },
|
||||
{ type: 'code', name: 'resolveStatus', label: '처리 상태', groupCode: 'RESOLVE_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사', dataIndex: 'companyName' },
|
||||
{ title: '에러 유형', dataIndex: 'errorTypeName',
|
||||
render: (v: string) => <Tag color="red">{v}</Tag> },
|
||||
{ title: '필드', dataIndex: 'errorField' },
|
||||
{ title: '값', dataIndex: 'errorValue', ellipsis: true },
|
||||
{ title: '메시지', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{ title: '처리상태', dataIndex: 'resolveStatusName',
|
||||
render: (v: string, r: any) => v === '처리완료'
|
||||
? <Tag color="green">{v}</Tag>
|
||||
: <Tag color="orange">{v}</Tag> },
|
||||
{ title: '발생시각', dataIndex: 'createdAt',
|
||||
render: (v: string) => v && dayjs(v).format('YYYY-MM-DD HH:mm') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Input, Select, Space, Table, message } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface MappingRow {
|
||||
mappingId?: number;
|
||||
sourceField: string;
|
||||
sourceColIndex?: number;
|
||||
targetTable: string;
|
||||
targetField: string;
|
||||
dataType?: string; // STRING / NUMBER / DECIMAL / DATE / INTEGER
|
||||
transformRule?: string; // TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP / DEFAULT
|
||||
defaultValue?: string;
|
||||
isRequired?: string;
|
||||
sortOrder: number;
|
||||
__dirty?: boolean;
|
||||
}
|
||||
|
||||
const TARGET_TABLES = [
|
||||
{ value: 'recruit_ledger', label: '모집수수료원장' },
|
||||
{ value: 'maintain_ledger', label: '유지수수료원장' },
|
||||
];
|
||||
|
||||
const DATA_TYPES = ['STRING', 'NUMBER', 'DECIMAL', 'DATE', 'INTEGER'].map((v) => ({ value: v, label: v }));
|
||||
|
||||
const TRANSFORM_RULES = [
|
||||
{ value: '', label: '(없음)' },
|
||||
{ value: 'TRIM', label: '공백제거' },
|
||||
{ value: 'UPPER', label: '대문자' },
|
||||
{ value: 'LOWER', label: '소문자' },
|
||||
{ value: 'DATE', label: '날짜변환' },
|
||||
{ value: 'DIVIDE', label: '나누기' },
|
||||
{ value: 'MULTIPLY', label: '곱하기' },
|
||||
{ value: 'CODE_MAP', label: '코드매핑' },
|
||||
{ value: 'DEFAULT', label: '기본값' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 보험사별 필드 매핑 편집기.
|
||||
* URL: /receive/mapping/:companyCode/:targetTable
|
||||
*/
|
||||
export default function FieldMappingEditor() {
|
||||
const { companyCode = '', targetTable: tt } = useParams<{ companyCode: string; targetTable?: string }>();
|
||||
const [targetTable, setTargetTable] = useState<string>(tt ?? 'recruit_ledger');
|
||||
const [rows, setRows] = useState<MappingRow[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { refetch, isLoading } = useQuery({
|
||||
queryKey: ['fieldMappings', companyCode, targetTable],
|
||||
queryFn: async () => {
|
||||
const r: any = await receiveApi.listFieldMappings(companyCode, targetTable);
|
||||
const list: MappingRow[] = (Array.isArray(r) ? r : (r?.list ?? [])).map((x: any, i: number) => ({
|
||||
...x,
|
||||
sortOrder: x.sortOrder ?? i + 1,
|
||||
}));
|
||||
setRows(list);
|
||||
return list;
|
||||
},
|
||||
enabled: !!companyCode && !!targetTable,
|
||||
});
|
||||
|
||||
const addRow = () => {
|
||||
setRows([
|
||||
...rows,
|
||||
{
|
||||
sourceField: '',
|
||||
targetTable,
|
||||
targetField: '',
|
||||
dataType: 'STRING',
|
||||
sortOrder: rows.length + 1,
|
||||
isRequired: 'N',
|
||||
__dirty: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const next = rows.filter((_, i) => i !== idx).map((r, i) => ({ ...r, sortOrder: i + 1, __dirty: true }));
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const move = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
setRows(next.map((r, i) => ({ ...r, sortOrder: i + 1, __dirty: true })));
|
||||
};
|
||||
|
||||
const setCell = (idx: number, field: keyof MappingRow, value: any) => {
|
||||
const next = [...rows];
|
||||
next[idx] = { ...next[idx], [field]: value, __dirty: true };
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
// 검증
|
||||
for (const r of rows) {
|
||||
if (!r.sourceField || !r.targetField) {
|
||||
message.error('원본/타겟 필드는 모두 채워야 합니다');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await receiveApi.bulkSaveFieldMappings(companyCode, rows);
|
||||
message.success('저장 완료');
|
||||
refetch();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '순서', width: 80, render: (_: any, _r: MappingRow, idx: number) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => move(idx, -1)} />
|
||||
<Button size="small" icon={<ArrowDownOutlined />} disabled={idx === rows.length - 1} onClick={() => move(idx, 1)} />
|
||||
<span>{idx + 1}</span>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '원본 필드명', width: 180, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input value={r.sourceField} onChange={(e) => setCell(idx, 'sourceField', e.target.value)} />
|
||||
)},
|
||||
{ title: '컬럼 인덱스', width: 110, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input type="number" value={r.sourceColIndex} onChange={(e) =>
|
||||
setCell(idx, 'sourceColIndex', e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||
)},
|
||||
{ title: '타겟 필드명', width: 180, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input value={r.targetField} onChange={(e) => setCell(idx, 'targetField', e.target.value)} />
|
||||
)},
|
||||
{ title: '데이터 타입', width: 120, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.dataType}
|
||||
onChange={(v) => setCell(idx, 'dataType', v)} options={DATA_TYPES} />
|
||||
)},
|
||||
{ title: '변환 규칙', width: 120, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.transformRule ?? ''}
|
||||
onChange={(v) => setCell(idx, 'transformRule', v || undefined)} options={TRANSFORM_RULES} />
|
||||
)},
|
||||
{ title: '기본값', width: 140, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input size="small" value={r.defaultValue ?? ''} onChange={(e) => setCell(idx, 'defaultValue', e.target.value || undefined)} />
|
||||
)},
|
||||
{ title: '필수', width: 70, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.isRequired ?? 'N'}
|
||||
onChange={(v) => setCell(idx, 'isRequired', v)}
|
||||
options={[{ value: 'Y', label: 'Y' }, { value: 'N', label: 'N' }]} />
|
||||
)},
|
||||
{ title: '', width: 60, render: (_: any, _r: MappingRow, idx: number) => (
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => removeRow(idx)} />
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={`필드 매핑 — ${companyCode}`}>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<span>대상 테이블:</span>
|
||||
<Select value={targetTable} onChange={setTargetTable} options={TARGET_TABLES} style={{ width: 220 }} />
|
||||
<Button onClick={() => refetch()}>새로고침</Button>
|
||||
<Button onClick={() => navigate(-1)}>뒤로</Button>
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={addRow}>행 추가</Button>
|
||||
<PermissionButton menu="MAPPING" perm="UPDATE" type="primary"
|
||||
icon={<SaveOutlined />} onClick={save}>저장</PermissionButton>
|
||||
{rows.some((r) => r.__dirty) && <span style={{ color: '#fa8c16' }}>● 변경됨</span>}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey={(r, i) => `${r.mappingId ?? 'new'}_${i}`}
|
||||
dataSource={rows}
|
||||
columns={columns as any}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Space, Tag } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import ProfileModal from './ProfileModal';
|
||||
|
||||
export default function ProfileList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['receive-profiles', param],
|
||||
queryFn: () => receiveApi.listProfiles(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const goMapping = (companyCode: string) => navigate(`/receive/mapping/${companyCode}/recruit_ledger`);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '보험사 코드/명' },
|
||||
{ type: 'code', name: 'dataFormat', label: '데이터 형식', groupCode: 'DATA_FORMAT' },
|
||||
{ type: 'code', name: 'receiveMethod', label: '수신 방식', groupCode: 'RECEIVE_METHOD' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RECEIVE_PROFILE" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사 코드', dataIndex: 'companyCode' },
|
||||
{ title: '보험사명', dataIndex: 'companyName' },
|
||||
{ title: '형식', dataIndex: 'dataFormatName' },
|
||||
{ title: '수신방식', dataIndex: 'receiveMethodName' },
|
||||
{ title: '인코딩', dataIndex: 'fileEncoding' },
|
||||
{ title: '필드매핑', dataIndex: 'fieldMappingCount', align: 'right' as const,
|
||||
render: (v: number) => <Tag color={v > 0 ? 'blue' : 'default'}>{v}</Tag> },
|
||||
{ title: '활성', dataIndex: 'isActive',
|
||||
render: (v: string) => v === 'Y' ? <Tag color="green">사용</Tag> : <Tag>미사용</Tag> },
|
||||
{ title: '', width: 220, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RECEIVE_PROFILE" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Button size="small" onClick={() => goMapping(r.companyCode)}>필드매핑</Button>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<ProfileModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, InputNumber, Modal, Row, Select, message } from 'antd';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** company_profile 등록/수정 (UPSERT). companyCode를 PK로 사용. */
|
||||
export default function ProfileModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.companyCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
dataFormat: 'EXCEL', receiveMethod: 'MANUAL',
|
||||
fileEncoding: 'UTF-8', headerRow: 1, dataStartRow: 2, isActive: 'Y',
|
||||
});
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
await receiveApi.saveProfile(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const dataFormat = Form.useWatch('dataFormat', form);
|
||||
const receiveMethod = Form.useWatch('receiveMethod', form);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '보험사 프로파일 수정' : '보험사 프로파일 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '저장' : '등록'}
|
||||
width={760}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true, max: 20 }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="활성" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="데이터 형식" name="dataFormat" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="DATA_FORMAT" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수신 방식" name="receiveMethod" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="RECEIVE_METHOD" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{(dataFormat === 'EXCEL' || dataFormat === 'CSV') && (
|
||||
<>
|
||||
<Col span={8}>
|
||||
<Form.Item label="인코딩" name="fileEncoding">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="구분자(CSV)" name="delimiter" extra="예: , 또는 \\t">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="시트명(EXCEL)" name="sheetName">
|
||||
<Input placeholder="비우면 첫 시트" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="헤더 행" name="headerRow">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="데이터 시작 행" name="dataStartRow">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="날짜 포맷" name="dateFormat" extra="예: yyyyMMdd / yyyy-MM-dd">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="금액 포맷" name="amountFormat" extra="예: WON(원), JEON(전)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
|
||||
{receiveMethod === 'API' && (
|
||||
<Col span={24}>
|
||||
<Form.Item label="API URL" name="apiUrl" extra="{settleMonth} placeholder 사용 가능">
|
||||
<Input placeholder="https://api.company.com/commission?month={settleMonth}" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{(receiveMethod === 'FTP' || receiveMethod === 'SFTP') && (
|
||||
<>
|
||||
<Col span={12}>
|
||||
<Form.Item label="FTP 호스트" name="ftpHost">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="FTP 경로" name="ftpPath">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Item label="수신 스케줄(cron)" name="receiveSchedule" extra="예: 0 0 2 * * ? = 매일 2시">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, Input, InputNumber, Modal, Row, Select, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function ChargebackRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.cbRuleId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
cbRate: initial.cbRate != null ? initial.cbRate * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ includeOverride: 'N', installmentAllowed: 'N' });
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
cbRate: v.cbRate != null ? v.cbRate / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateChargeback(initial.cbRuleId, body);
|
||||
else await ruleApi.createChargeback(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '환수 규정 수정' : '환수 규정 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true }]}
|
||||
extra="빈값 허용 X — 공통 적용은 'ALL' 추후 예약">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종" name="insuranceType" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실효 시작 개월" name="lapseMonthFrom" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실효 종료 개월" name="lapseMonthTo" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="환수율 (%)" name="cbRate" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="오버라이드 포함" name="includeOverride">
|
||||
<Select options={[{ value: 'Y', label: '포함' }, { value: 'N', label: '미포함' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="분납 허용" name="installmentAllowed">
|
||||
<Select options={[{ value: 'Y', label: '허용' }, { value: 'N', label: '불허' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="최대 분납 회수" name="maxInstallments">
|
||||
<InputNumber min={1} max={36} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Popconfirm, Space, Tag, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import CommissionRateModal from './CommissionRateModal';
|
||||
|
||||
export default function CommissionRateList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['commission-rates', param],
|
||||
queryFn: () => ruleApi.listCommission(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await ruleApi.deleteCommission(id); message.success('삭제 완료'); refetch(); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '상품명/코드' },
|
||||
{ type: 'text', name: 'commissionYear', label: '연차' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사', dataIndex: 'companyName' },
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{ title: '연차', dataIndex: 'commissionYear', width: 70, align: 'right' as const },
|
||||
{
|
||||
title: '수수료율',
|
||||
dataIndex: 'ratePct',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => v != null ? <Tag color="blue">{(v * 100).toFixed(2)}%</Tag> : null,
|
||||
},
|
||||
{ title: '최소보험료', dataIndex: 'minPremium', align: 'right' as const, render: (v: number) => <MoneyText value={v} /> },
|
||||
{ title: '효력시작', dataIndex: 'effectiveFrom', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '효력종료', dataIndex: 'effectiveTo', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'right' as const },
|
||||
{ title: '', width: 140, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Popconfirm title="삭제하시겠습니까?" onConfirm={() => handleDelete(r.rateId)}>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="DELETE" size="small" danger>삭제</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<CommissionRateModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any; // 수정 시 row 데이터
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** commission_rate 등록/수정 Modal. */
|
||||
export default function CommissionRateModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.rateId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
ratePct: initial.ratePct != null ? initial.ratePct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
ratePct: v.ratePct != null ? v.ratePct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateCommission(initial.rateId, body);
|
||||
else await ruleApi.createCommission(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
// request 인터셉터에서 처리
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '수수료율 수정' : '수수료율 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상품 ID" name="productId" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료 연차" name="commissionYear" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료율 (%)" name="ratePct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="최소 보험료" name="minPremium">
|
||||
<InputNumber min={0} step={1000} style={{ width: '100%' }} formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="납입 조건" name="payMethodCond" extra="예: REGULAR / LUMP / 빈값=공통">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, Modal, Row, Select, message } from 'antd';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function ExceptionTypeCodeModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.exceptionCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
direction: 'PLUS', autoCalcYn: 'N', approvalRequired: 'Y',
|
||||
taxIncluded: 'Y', isActive: 'Y',
|
||||
});
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateExceptionCode(initial.exceptionCode, v);
|
||||
else await ruleApi.createExceptionCode(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '예외코드 수정' : '예외코드 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="예외 코드" name="exceptionCode" rules={[{ required: true, max: 30 }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="예외 명" name="exceptionName" rules={[{ required: true, max: 100 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="카테고리" name="category" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: BONUS / PENALTY / ADJUST" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="방향" name="direction" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'PLUS', label: '+ (가산)' }, { value: 'MINUS', label: '- (차감)' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="자동 계산" name="autoCalcYn">
|
||||
<Select options={[{ value: 'Y', label: '자동' }, { value: 'N', label: '수동' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="승인 필요" name="approvalRequired">
|
||||
<Select options={[{ value: 'Y', label: '필요' }, { value: 'N', label: '불필요' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="세금 포함" name="taxIncluded">
|
||||
<Select options={[{ value: 'Y', label: '포함' }, { value: 'N', label: '미포함' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="설명" name="description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="사용" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function OverrideRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.overrideId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
overridePct: initial.overridePct != null ? initial.overridePct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
overridePct: v.overridePct != null ? v.overridePct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateOverride(initial.overrideId, body);
|
||||
else await ruleApi.createOverride(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '오버라이드 수정' : '오버라이드 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="하위 직급 (from_grade)" name="fromGrade" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상위 직급 (to_grade)" name="toGrade" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="오버라이드율 (%)" name="overridePct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="계산 방식" name="calcType" extra="예: AGENT_AMT / COMPANY_AMT">
|
||||
<CodeSelect groupCode="OVERRIDE_CALC_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종 조건" name="insuranceTypeCond" extra="빈값=공통">
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" includeAll />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상한 금액" name="capAmount">
|
||||
<InputNumber min={0} step={10000} style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Popconfirm, Space, Tag, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import PayoutRuleModal from './PayoutRuleModal';
|
||||
|
||||
export default function PayoutRuleList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['payout-rules', param],
|
||||
queryFn: () => ruleApi.listPayout(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await ruleApi.deletePayout(id); message.success('삭제 완료'); refetch(); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'code', name: 'insuranceType', label: '보종', groupCode: 'INSURANCE_TYPE' },
|
||||
{ type: 'text', name: 'commissionYear', label: '연차' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '직급', dataIndex: 'gradeName' },
|
||||
{ title: '보종', dataIndex: 'insuranceTypeName' },
|
||||
{ title: '연차', dataIndex: 'commissionYear', width: 70, align: 'right' as const },
|
||||
{
|
||||
title: '지급률',
|
||||
dataIndex: 'payoutPct',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => v != null ? <Tag color="green">{(v * 100).toFixed(2)}%</Tag> : null,
|
||||
},
|
||||
{ title: '실적등급', dataIndex: 'performanceGrade' },
|
||||
{ title: '효력시작', dataIndex: 'effectiveFrom', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'right' as const },
|
||||
{ title: '', width: 140, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Popconfirm title="삭제하시겠습니까?" onConfirm={() => handleDelete(r.ruleId)}>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="DELETE" size="small" danger>삭제</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<PayoutRuleModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function PayoutRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.ruleId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
payoutPct: initial.payoutPct != null ? initial.payoutPct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
payoutPct: v.payoutPct != null ? v.payoutPct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updatePayout(initial.ruleId, body);
|
||||
else await ruleApi.createPayout(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '지급률 수정' : '지급률 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="직급 ID" name="gradeId" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종" name="insuranceType" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료 연차" name="commissionYear" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="지급률 (%)" name="payoutPct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실적 등급" name="performanceGrade" extra="예: S / A / B / 빈값=공통">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="납입 조건" name="payMethodCond">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 메뉴 코드 → React 컴포넌트 매핑.
|
||||
* 백엔드 menu 테이블의 component_path 와 1:1 대응.
|
||||
* 새 화면 추가 시 이 맵에만 등록하면 자동 라우팅된다.
|
||||
*/
|
||||
const componentMap: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
|
||||
// 조직/인사
|
||||
'org/AgentList': lazy(() => import('@/pages/org/AgentList')),
|
||||
'org/AgentDetail': lazy(() => import('@/pages/org/AgentDetail')),
|
||||
'org/AgentForm': lazy(() => import('@/pages/org/AgentForm')),
|
||||
|
||||
// 계약
|
||||
'product/ContractList': lazy(() => import('@/pages/product/ContractList')),
|
||||
|
||||
// 수수료규정
|
||||
'rule/CommissionRateList': lazy(() => import('@/pages/rule/CommissionRateList')),
|
||||
'rule/PayoutRuleList': lazy(() => import('@/pages/rule/PayoutRuleList')),
|
||||
|
||||
// 데이터수신
|
||||
'receive/ProfileList': lazy(() => import('@/pages/receive/ProfileList')),
|
||||
'receive/ErrorList': lazy(() => import('@/pages/receive/ErrorList')),
|
||||
|
||||
// 원장
|
||||
'ledger/RecruitLedger': lazy(() => import('@/pages/ledger/RecruitLedger')),
|
||||
'ledger/ExceptionLedger': lazy(() => import('@/pages/ledger/ExceptionLedger')),
|
||||
|
||||
// 정산/지급/배치
|
||||
'settle/SettleList': lazy(() => import('@/pages/settle/SettleList')),
|
||||
'settle/PaymentList': lazy(() => import('@/pages/settle/PaymentList')),
|
||||
'settle/BatchRun': lazy(() => import('@/pages/settle/BatchRun')),
|
||||
|
||||
// 시스템관리
|
||||
'system/UserList': lazy(() => import('@/pages/system/UserList')),
|
||||
'system/RoleList': lazy(() => import('@/pages/system/RoleList')),
|
||||
'system/CodeList': lazy(() => import('@/pages/system/CodeList')),
|
||||
};
|
||||
|
||||
export interface MenuRoute {
|
||||
menuPath: string; // 라우트 경로 (예: 'rule/commission-rates')
|
||||
componentPath: string; // componentMap 키 (예: 'rule/CommissionRateList')
|
||||
}
|
||||
|
||||
/** 메뉴 목록을 받아 React Route 배열로 변환. */
|
||||
export function buildDynamicRoutes(menus: MenuRoute[]) {
|
||||
return menus
|
||||
.map((m) => {
|
||||
const Cmp = componentMap[m.componentPath];
|
||||
if (!Cmp) {
|
||||
console.warn(`[router] component not found: ${m.componentPath}`);
|
||||
return null;
|
||||
}
|
||||
const path = m.menuPath.replace(/^\//, '');
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={
|
||||
<Suspense fallback={<Spin />}>
|
||||
<Cmp />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
Reference in New Issue
Block a user