feat: MappingEngine + Override 실제 로직 + 프론트 주요 화면

Backend:
- MappingEngine: company_field_mapping 기반 동적 변환 구현
  - raw_content(JSON) → 표준 원장 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP)
  - policy_no 로 contract 매칭 → contract_id/agent_id 자동
  - 실패 시 parse_error_log 자동 적재
- Recruit/Maintain Mapper.sumByAgentMonth 추가
- OrganizationMapper.selectAncestorIds (CTE 재귀 상향) 추가
- CalcOverrideStep 실제 동작:
  * lower agent 의 모집+유지 합계를 base 로 사용
  * 자기 조직 → 상위 조직 순으로 to_grade 직급 ACTIVE 설계사 탐색
  * (lowerOrgId, toGrade) 캐싱

Frontend (주요 화면):
- 공통 컴포넌트:
  * CodeSelect (공통코드 드롭다운, React Query 캐시)
  * CodeBadge (상태 색상 자동 매핑)
  * MoneyText (천단위 콤마, 음수 빨강, 부호 옵션)
  * PermissionButton (권한 없으면 미렌더)
  * SearchForm (조건 배열 → 자동 폼)
  * ExcelExportButton (blob 다운로드)
  * PageContainer
- 훅: useCommonCodes, usePermission (메뉴별 boolean)
- API 모듈: agent, contract, ledger, settle, code
- 페이지: AgentList, ContractList, RecruitLedger, SettleList
  * SettleList: 월 요약 카드 + 확정/보류 액션 + 권한 체크
- 라우터: /org/agents, /contracts, /ledger/recruit, /settle

V16 fix:
- PAYMENT 메뉴는 V12 에서 level1 directory 로 생성됨 → V16 에서 PAGE 로 UPDATE 후 권한 매트릭스 적용 (자식으로 또 만들면 unique 충돌)
This commit is contained in:
GA Pro
2026-05-09 22:38:10 +09:00
parent d4433a8e46
commit 98eb231950
28 changed files with 1165 additions and 39 deletions
@@ -2,11 +2,14 @@ package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.org.OrganizationMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam;
import com.ga.core.vo.rule.OverrideRuleVO;
import com.ga.core.vo.settle.OverrideSettleVO;
import lombok.RequiredArgsConstructor;
@@ -21,14 +24,18 @@ import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산
* Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산.
*
* 단순화: 정산월의 모집+유지 합계 × override_pct → 상위 직급에게 분배.
* 실제로는 from_grade → to_grade 매트릭스로 다단계 분배해야 함.
* 정책:
* - 모든 ACTIVE 설계사를 순회하며 본인의 grade 와 일치하는 from_grade 규칙을 찾음
* - 본인 조직 + 모든 상위 조직에서 to_grade 직급의 ACTIVE 설계사 1명을 찾아 분배 (가장 가까운 조직 우선)
* - base = 하위 설계사의 정산월 모집 + 유지 합계
* - amount = base × override_pct (cap 적용)
*/
@Slf4j
@Component
@@ -41,6 +48,8 @@ public class CalcOverrideStep implements Tasklet {
private final AgentMapper agentMapper;
private final OrganizationMapper organizationMapper;
private final OverrideSettleMapper overrideMapper;
private final RecruitLedgerMapper recruitMapper;
private final MaintainLedgerMapper maintainMapper;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
@@ -49,24 +58,31 @@ public class CalcOverrideStep implements Tasklet {
overrideMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<OverrideRuleVO> rules = ruleMapper.selectOverrideRules();
// 정산월의 활성 오버라이드 규칙만 (effective_from 기준)
LocalDate baseDate = LocalDate.parse(ctx.getSettleMonth().substring(0,4) + "-" + ctx.getSettleMonth().substring(4) + "-01");
LocalDate baseDate = LocalDate.parse(
ctx.getSettleMonth().substring(0, 4) + "-" +
ctx.getSettleMonth().substring(4) + "-01");
rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate));
rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate));
List<OverrideSettleVO> records = new ArrayList<>();
// 단순화: 모든 ACTIVE 설계사를 조회하고, 본인의 grade 가 from_grade 인 규칙에 대해 상위 조직장 검색
com.ga.core.vo.org.AgentSearchParam ap = new com.ga.core.vo.org.AgentSearchParam();
AgentSearchParam ap = new AgentSearchParam();
ap.setStatus("ACTIVE");
ap.setPageSize(Integer.MAX_VALUE);
List<com.ga.core.vo.org.AgentResp> agents = agentMapper.selectList(ap);
List<AgentResp> agents = agentMapper.selectList(ap);
for (com.ga.core.vo.org.AgentResp lower : agents) {
Map<String, Long> upperCache = new HashMap<>();
List<OverrideSettleVO> records = new ArrayList<>();
for (AgentResp lower : agents) {
for (OverrideRuleVO rule : rules) {
if (!java.util.Objects.equals(rule.getFromGrade(), lower.getGradeId())) continue;
Long upperAgentId = findUpperAgentByGrade(lower.getOrgId(), rule.getToGrade());
if (!rule.getFromGrade().equals(lower.getGradeId())) continue;
Long upperAgentId = findUpperAgentByGrade(
lower.getOrgId(), rule.getToGrade(), lower.getAgentId(), upperCache);
if (upperAgentId == null) continue;
BigDecimal base = sumLowerCommission(lower.getAgentId(), ctx.getSettleMonth());
if (MoneyUtil.isZero(base)) continue;
BigDecimal amount = MoneyUtil.pct(base, rule.getOverridePct());
if (rule.getCapAmount() != null && amount.compareTo(rule.getCapAmount()) > 0) {
amount = rule.getCapAmount();
@@ -89,22 +105,40 @@ public class CalcOverrideStep implements Tasklet {
return RepeatStatus.FINISHED;
}
/** 하위 설계사의 정산월 모집+유지 합계 (단순화: 모집 원장 합으로 대체) */
/** 하위 설계사의 정산월 모집 + 유지 합계 */
private BigDecimal sumLowerCommission(Long agentId, String settleMonth) {
// 실제로는 recruit + maintain agent별 합계 필요. 여기서는 0 반환 (실제 운영 시 별도 sumByAgentMonth 추가)
return BigDecimal.ZERO;
BigDecimal r = recruitMapper.sumByAgentMonth(agentId, settleMonth);
BigDecimal m = maintainMapper.sumByAgentMonth(agentId, settleMonth);
return MoneyUtil.add(r, m);
}
/** 동일 조직 트리에서 to_grade 직급의 상위 설계사 찾기 (단순화: org 트리 상위 첫 번째) */
private Long findUpperAgentByGrade(Long orgId, Long toGrade) {
if (orgId == null) return null;
// 조직 트리 상향 탐색은 운영 단계에서 추가. 여기서는 동일 조직 내 toGrade 1명 반환.
com.ga.core.vo.org.AgentSearchParam p = new com.ga.core.vo.org.AgentSearchParam();
p.setOrgId(orgId);
p.setGradeId(toGrade);
p.setStatus("ACTIVE");
p.setPageSize(1);
List<com.ga.core.vo.org.AgentResp> upper = agentMapper.selectList(p);
return upper.isEmpty() ? null : upper.get(0).getAgentId();
/**
* 자기 조직부터 상향 탐색하며 to_grade 직급의 ACTIVE 설계사 1명 반환.
* 자기 자신은 제외. 캐시: (lowerOrgId, toGrade) → upperAgentId.
*/
private Long findUpperAgentByGrade(Long lowerOrgId, Long toGrade, Long lowerAgentId,
Map<String, Long> cache) {
if (lowerOrgId == null) return null;
String key = lowerOrgId + ":" + toGrade;
Long cached = cache.get(key);
if (cached != null) return cached.equals(0L) ? null : cached;
List<Long> ancestorIds = organizationMapper.selectAncestorIds(lowerOrgId);
for (Long orgId : ancestorIds) {
AgentSearchParam p = new AgentSearchParam();
p.setOrgId(orgId);
p.setGradeId(toGrade);
p.setStatus("ACTIVE");
p.setPageSize(5);
List<AgentResp> upper = agentMapper.selectList(p);
for (AgentResp a : upper) {
if (!a.getAgentId().equals(lowerAgentId)) {
cache.put(key, a.getAgentId());
return a.getAgentId();
}
}
}
cache.put(key, 0L);
return null;
}
}
@@ -1,28 +1,241 @@
package com.ga.batch.settlement.transform;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractSearchParam;
import com.ga.core.vo.product.ContractVO;
import com.ga.core.vo.receive.CompanyFieldMappingVO;
import com.ga.core.vo.receive.ParseErrorLogVO;
import com.ga.core.vo.receive.RawCommissionDataVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 보험사 raw 데이터 → 표준 원장(recruit_ledger / maintain_ledger / exception_ledger) 으로 변환.
* raw_commission_data → 표준 원장(recruit_ledger / maintain_ledger) 변환.
*
* 본 구현은 단순화된 stub. 실제로는:
* - company_field_mapping 으로 raw_content(JSON 등) 에서 필드를 추출
* - code_mapping 으로 보험사 코드 → 표준 코드 변환
* - data_type 에 따라 모집/유지/예외 원장 선택
* 동작:
* 1. company_field_mapping 의 (source_field → target_field) 규칙 로드
* 2. raw_content (JSON) 에서 source_field 값을 추출
* 3. transform_rule 적용 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP)
* 4. policy_no 로 contract 매칭 → contract_id, agent_id 채움
* 5. data_type 에 따라 recruit_ledger / maintain_ledger 에 INSERT
*
* 실패 시 parse_error_log 에 기록.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MappingEngine {
/** 변환 후 ledger_id 반환 (없으면 null) */
private final ReceiveMapper receiveMapper;
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final MaintainLedgerMapper maintainMapper;
private final ObjectMapper objectMapper;
@Transactional
public Long transform(RawCommissionDataVO raw) {
// TODO: 실제 매핑 로직 — company_field_mapping 기반 동적 변환
// 현재는 ID 미지정으로 처리 (Step4/5 에서 contract 기반으로 재계산)
log.debug("transform raw={}, type={}", raw.getRawId(), raw.getDataType());
return null;
if (raw.getCompanyCode() == null || raw.getRawContent() == null) {
saveError(raw, "INVALID_INPUT", null, null, "company_code 또는 raw_content 누락");
return null;
}
String targetTable = resolveTargetTable(raw.getDataType());
if (targetTable == null) {
saveError(raw, "UNKNOWN_TYPE", "data_type", raw.getDataType(),
"지원하지 않는 data_type: " + raw.getDataType());
return null;
}
List<CompanyFieldMappingVO> mappings = receiveMapper.selectFieldMappings(
raw.getCompanyCode(), targetTable);
if (mappings.isEmpty()) {
saveError(raw, "NO_MAPPING", null, null,
"매핑 정의 없음: " + raw.getCompanyCode() + "." + targetTable);
return null;
}
JsonNode root;
try {
root = objectMapper.readTree(raw.getRawContent());
} catch (Exception e) {
saveError(raw, "JSON_PARSE", null, null, "raw_content JSON 파싱 실패: " + e.getMessage());
return null;
}
Map<String, Object> mapped = new HashMap<>();
for (CompanyFieldMappingVO m : mappings) {
JsonNode v = root.get(m.getSourceField());
String src = (v == null || v.isNull()) ? null : v.asText();
try {
Object converted = applyTransform(src, m, raw.getCompanyCode());
if (converted == null && "Y".equals(m.getIsRequired())) {
saveError(raw, "REQUIRED", m.getTargetField(), src,
"필수 항목 누락: " + m.getSourceField());
return null;
}
mapped.put(m.getTargetField(), converted);
} catch (Exception e) {
saveError(raw, "TRANSFORM_FAIL", m.getTargetField(), src, e.getMessage());
return null;
}
}
String policyNo = (String) mapped.get("policy_no");
if (policyNo == null) {
saveError(raw, "NO_POLICY", "policy_no", null, "policy_no 누락");
return null;
}
ContractVO contract = findContractByPolicyNo(policyNo);
if (contract == null) {
saveError(raw, "CONTRACT_NOT_FOUND", "policy_no", policyNo,
"계약을 찾을 수 없습니다: " + policyNo);
return null;
}
LedgerVO led = buildLedger(mapped, contract, raw);
if ("recruit_ledger".equals(targetTable)) {
recruitMapper.insert(led);
} else {
maintainMapper.insert(led);
}
return led.getLedgerId();
}
private String resolveTargetTable(String dataType) {
if (dataType == null) return null;
return switch (dataType.toUpperCase()) {
case "RECRUIT" -> "recruit_ledger";
case "MAINTAIN" -> "maintain_ledger";
default -> null;
};
}
private Object applyTransform(String value, CompanyFieldMappingVO m, String companyCode) {
if (value == null) {
return convertType(m.getDefaultValue(), m.getDataType());
}
String trimmed = value.trim();
String rule = m.getTransformRule();
if (rule == null || rule.isBlank()) {
return convertType(trimmed, m.getDataType());
}
return switch (rule.toUpperCase()) {
case "TRIM" -> convertType(trimmed, m.getDataType());
case "UPPER" -> trimmed.toUpperCase();
case "LOWER" -> trimmed.toLowerCase();
case "DATE" -> parseDate(trimmed, m.getDefaultValue());
case "DIVIDE" -> divideOrMultiply(trimmed, m.getDefaultValue(), true);
case "MULTIPLY" -> divideOrMultiply(trimmed, m.getDefaultValue(), false);
case "CODE_MAP" -> {
String mappedCode = receiveMapper.findTargetCode(companyCode, m.getDefaultValue(), trimmed);
yield mappedCode != null ? mappedCode : trimmed;
}
default -> convertType(trimmed, m.getDataType());
};
}
private Object convertType(String value, String dataType) {
if (value == null) return null;
if (dataType == null) return value;
return switch (dataType.toUpperCase()) {
case "STRING" -> value;
case "NUMBER", "DECIMAL" -> new BigDecimal(value.replace(",", ""));
case "INT", "INTEGER" -> Integer.parseInt(value.replace(",", ""));
case "LONG" -> Long.parseLong(value.replace(",", ""));
case "DATE" -> parseDate(value, null);
default -> value;
};
}
private LocalDate parseDate(String s, String pattern) {
if (s == null || s.isBlank()) return null;
try {
DateTimeFormatter fmt = (pattern != null && !pattern.isBlank())
? DateTimeFormatter.ofPattern(pattern)
: DateTimeFormatter.ofPattern("yyyyMMdd");
return LocalDate.parse(s, fmt);
} catch (Exception ignored) {
try { return LocalDate.parse(s); } catch (Exception e) { return null; }
}
}
private BigDecimal divideOrMultiply(String value, String factor, boolean divide) {
BigDecimal num = new BigDecimal(value.replace(",", ""));
BigDecimal f = new BigDecimal(factor != null && !factor.isBlank() ? factor : "1");
return divide ? num.divide(f, 2, RoundingMode.HALF_UP) : num.multiply(f);
}
private ContractVO findContractByPolicyNo(String policyNo) {
ContractSearchParam p = new ContractSearchParam();
p.setPolicyNo(policyNo);
p.setPageSize(1);
var list = contractMapper.selectList(p);
if (list.isEmpty()) return null;
return contractMapper.selectById(list.get(0).getContractId());
}
private LedgerVO buildLedger(Map<String, Object> mapped, ContractVO contract, RawCommissionDataVO raw) {
LedgerVO led = new LedgerVO();
led.setContractId(contract.getContractId());
led.setAgentId(contract.getAgentId());
led.setPolicyNo(contract.getPolicyNo());
led.setCompanyCode(raw.getCompanyCode());
led.setProductCode((String) mapped.get("product_code"));
led.setInsuranceType((String) mapped.getOrDefault("insurance_type", "WHOLE_LIFE"));
led.setCommissionYear(toInt(mapped.get("commission_year"), 1));
led.setPremium(toBd(mapped.get("premium"), contract.getPremium()));
led.setPayMethod((String) mapped.getOrDefault("pay_method", contract.getPayCycle()));
led.setCompanyRate(toBd(mapped.get("company_rate"), BigDecimal.ZERO));
led.setCompanyAmount(toBd(mapped.get("company_amount"), BigDecimal.ZERO));
led.setPayoutRate(toBd(mapped.get("payout_rate"), BigDecimal.ZERO));
led.setAgentAmount(toBd(mapped.get("agent_amount"), BigDecimal.ZERO));
led.setTaxAmount(MoneyUtil.tax(led.getAgentAmount()));
led.setSettleMonth(raw.getSettleMonth());
led.setStatus("CALCULATED");
led.setReconcileStatus("PENDING");
return led;
}
private BigDecimal toBd(Object v, BigDecimal fallback) {
if (v == null) return fallback;
if (v instanceof BigDecimal bd) return bd;
try { return new BigDecimal(v.toString()); } catch (Exception e) { return fallback; }
}
private int toInt(Object v, int fallback) {
if (v == null) return fallback;
if (v instanceof Integer i) return i;
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return fallback; }
}
private void saveError(RawCommissionDataVO raw, String type, String field, String value, String msg) {
log.warn("MappingEngine error rawId={} type={} field={} value={}: {}",
raw.getRawId(), type, field, value, msg);
ParseErrorLogVO err = new ParseErrorLogVO();
err.setRawId(raw.getRawId());
err.setCompanyCode(raw.getCompanyCode());
err.setErrorType(type);
err.setErrorField(field);
err.setErrorValue(value);
err.setErrorMessage(msg);
err.setResolveStatus("OPEN");
receiveMapper.insertError(err);
}
}