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:
@@ -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();
|
||||
/**
|
||||
* 자기 조직부터 상향 탐색하며 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(1);
|
||||
List<com.ga.core.vo.org.AgentResp> upper = agentMapper.selectList(p);
|
||||
return upper.isEmpty() ? null : upper.get(0).getAgentId();
|
||||
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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
-- V16: 관리자 계정 + 메뉴 상세 + 권한 매트릭스 초기화
|
||||
-- admin 계정 비밀번호: admin1234! (BCrypt $2b$10)
|
||||
|
||||
-- 0. PAYMENT 메뉴는 V12에서 level 1 directory 로 생성되었으나
|
||||
-- PaymentController 에서 menu="PAYMENT" 로 직접 권한 체크하므로 PAGE 로 변경.
|
||||
UPDATE menu SET menu_type = 'PAGE',
|
||||
menu_path = '/payments',
|
||||
component_path = 'settle/PaymentList'
|
||||
WHERE menu_code = 'PAYMENT';
|
||||
|
||||
-- 1. 자식 메뉴 (V10에서 정의된 menu_code 와 컨트롤러 @RequirePermission 매칭)
|
||||
WITH parent AS (
|
||||
SELECT menu_id, menu_code FROM menu WHERE menu_level = 1
|
||||
@@ -32,7 +39,6 @@ JOIN (VALUES
|
||||
-- 정산/지급
|
||||
('SETTLE', 'SETTLE_LIST', '정산 결과', '/settle', 'settle/SettleList', 10),
|
||||
('SETTLE', 'BATCH_RUN', '정산 배치', '/settle/batch', 'settle/BatchRun', 20),
|
||||
('PAYMENT', 'PAYMENT', '지급 관리', '/payments', 'settle/PaymentList', 10),
|
||||
-- 시스템
|
||||
('SYSTEM', 'SYSTEM_USER', '사용자 관리', '/system/users', 'system/UserList', 10),
|
||||
('SYSTEM', 'SYSTEM_ROLE', '역할 관리', '/system/roles', 'system/RoleList', 20),
|
||||
|
||||
@@ -24,4 +24,6 @@ public interface MaintainLedgerMapper {
|
||||
int deleteBySettleMonth(@Param("settleMonth") String settleMonth);
|
||||
List<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
|
||||
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
|
||||
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
|
||||
@Param("settleMonth") String settleMonth);
|
||||
}
|
||||
|
||||
@@ -27,4 +27,7 @@ public interface RecruitLedgerMapper {
|
||||
List<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
|
||||
/** 정산월 합계 */
|
||||
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
|
||||
/** 정산월 + 설계사 합계 */
|
||||
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
|
||||
@Param("settleMonth") String settleMonth);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public interface OrganizationMapper {
|
||||
OrganizationVO selectById(@Param("orgId") Long orgId);
|
||||
/** 자기 자신 + 모든 하위 조직 ID (CTE 재귀) */
|
||||
List<Long> selectDescendantIds(@Param("orgId") Long orgId);
|
||||
/** 자기 자신 + 모든 상위 조직 ID (root 까지, 가까운 순) */
|
||||
List<Long> selectAncestorIds(@Param("orgId") Long orgId);
|
||||
int insert(OrganizationVO vo);
|
||||
int update(OrganizationVO vo);
|
||||
int deleteById(@Param("orgId") Long orgId);
|
||||
|
||||
@@ -107,4 +107,9 @@
|
||||
SELECT COALESCE(SUM(agent_amount), 0) FROM maintain_ledger WHERE settle_month = #{settleMonth}
|
||||
</select>
|
||||
|
||||
<select id="sumByAgentMonth" resultType="java.math.BigDecimal">
|
||||
SELECT COALESCE(SUM(agent_amount), 0) FROM maintain_ledger
|
||||
WHERE agent_id = #{agentId} AND settle_month = #{settleMonth}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -124,4 +124,9 @@
|
||||
SELECT COALESCE(SUM(agent_amount), 0) FROM recruit_ledger WHERE settle_month = #{settleMonth}
|
||||
</select>
|
||||
|
||||
<select id="sumByAgentMonth" resultType="java.math.BigDecimal">
|
||||
SELECT COALESCE(SUM(agent_amount), 0) FROM recruit_ledger
|
||||
WHERE agent_id = #{agentId} AND settle_month = #{settleMonth}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -49,6 +49,17 @@
|
||||
SELECT org_id FROM descendants
|
||||
</select>
|
||||
|
||||
<!-- 자기 자신 + 모든 상위 조직 (가까운 순) -->
|
||||
<select id="selectAncestorIds" resultType="long">
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT org_id, parent_org_id, 0 AS depth FROM organization WHERE org_id = #{orgId}
|
||||
UNION ALL
|
||||
SELECT o.org_id, o.parent_org_id, a.depth + 1
|
||||
FROM organization o JOIN ancestors a ON a.parent_org_id = o.org_id
|
||||
)
|
||||
SELECT org_id FROM ancestors ORDER BY depth
|
||||
</select>
|
||||
|
||||
<insert id="insert" parameterType="com.ga.core.vo.org.OrganizationVO" useGeneratedKeys="true" keyProperty="orgId">
|
||||
INSERT INTO organization (parent_org_id, org_name, org_type, org_level, region,
|
||||
effective_from, effective_to, is_active, created_at, created_by)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import AgentList from '@/pages/org/AgentList';
|
||||
import ContractList from '@/pages/product/ContractList';
|
||||
import RecruitLedger from '@/pages/ledger/RecruitLedger';
|
||||
import SettleList from '@/pages/settle/SettleList';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
@@ -14,7 +18,10 @@ export default function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{/* TODO: 추가 페이지는 동적 라우팅으로 확장 */}
|
||||
<Route path="org/agents" element={<AgentList />} />
|
||||
<Route path="contracts" element={<ContractList />} />
|
||||
<Route path="ledger/recruit" element={<RecruitLedger />} />
|
||||
<Route path="settle" element={<SettleList />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface AgentResp {
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
licenseNo?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
orgId?: number;
|
||||
orgName?: string;
|
||||
gradeId?: number;
|
||||
gradeName?: string;
|
||||
joinDate?: string;
|
||||
contractCount?: number;
|
||||
totalCommission?: number;
|
||||
}
|
||||
|
||||
export interface AgentSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
searchKeyword?: string;
|
||||
status?: string;
|
||||
orgId?: number;
|
||||
gradeId?: number;
|
||||
}
|
||||
|
||||
export interface AgentSaveReq {
|
||||
agentName: string;
|
||||
orgId: number;
|
||||
gradeId: number;
|
||||
residentNo?: string;
|
||||
licenseNo?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
bankCode?: string;
|
||||
accountNo?: string;
|
||||
joinDate?: string;
|
||||
}
|
||||
|
||||
export const agentApi = {
|
||||
list: (p: AgentSearchParam) =>
|
||||
unwrap<PageResponse<AgentResp>>(api.get('/api/agents', { params: p })),
|
||||
detail: (id: number) => unwrap<AgentResp>(api.get(`/api/agents/${id}`)),
|
||||
create: (req: AgentSaveReq) => unwrap<number>(api.post('/api/agents', req)),
|
||||
update: (id: number, req: AgentSaveReq) => unwrap<void>(api.put(`/api/agents/${id}`, req)),
|
||||
changeStatus: (id: number, status: string) =>
|
||||
unwrap<void>(api.put(`/api/agents/${id}/status`, { status })),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface CommonCode {
|
||||
codeId: number;
|
||||
groupCode: string;
|
||||
code: string;
|
||||
codeName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const codeApi = {
|
||||
byGroup: (groupCode: string) =>
|
||||
unwrap<CommonCode[]>(api.get(`/api/common/codes/${groupCode}`)),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface ContractResp {
|
||||
contractId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
orgName?: string;
|
||||
productCode?: string;
|
||||
productName?: string;
|
||||
companyCode?: string;
|
||||
companyName?: string;
|
||||
insuranceType?: string;
|
||||
policyNo: string;
|
||||
contractorName?: string;
|
||||
insuredName?: string;
|
||||
premium?: number;
|
||||
payCycle?: string;
|
||||
payCycleName?: string;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
contractDate: string;
|
||||
}
|
||||
|
||||
export interface ContractSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
searchKeyword?: string;
|
||||
agentId?: number;
|
||||
companyId?: number;
|
||||
insuranceType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export const contractApi = {
|
||||
list: (p: ContractSearchParam) =>
|
||||
unwrap<PageResponse<ContractResp>>(api.get('/api/contracts', { params: p })),
|
||||
detail: (id: number) => unwrap<ContractResp>(api.get(`/api/contracts/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface LedgerResp {
|
||||
ledgerId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
orgName?: string;
|
||||
policyNo?: string;
|
||||
contractorName?: string;
|
||||
contractDate?: string;
|
||||
companyCode?: string;
|
||||
companyName?: string;
|
||||
productCode?: string;
|
||||
productName?: string;
|
||||
insuranceType?: string;
|
||||
premium?: number;
|
||||
companyRate?: number;
|
||||
companyAmount?: number;
|
||||
payoutRate?: number;
|
||||
agentAmount?: number;
|
||||
taxAmount?: number;
|
||||
reconcileStatus?: string;
|
||||
settleMonth: string;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
}
|
||||
|
||||
export interface LedgerSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
settleMonth?: string;
|
||||
agentId?: number;
|
||||
orgId?: number;
|
||||
companyCode?: string;
|
||||
insuranceType?: string;
|
||||
policyNo?: string;
|
||||
searchKeyword?: string;
|
||||
}
|
||||
|
||||
export const ledgerApi = {
|
||||
recruit: (p: LedgerSearchParam) =>
|
||||
unwrap<PageResponse<LedgerResp>>(api.get('/api/ledger/recruit', { params: p })),
|
||||
maintain: (p: LedgerSearchParam) =>
|
||||
unwrap<PageResponse<LedgerResp>>(api.get('/api/ledger/maintain', { params: p })),
|
||||
exception: (p: LedgerSearchParam) =>
|
||||
unwrap<PageResponse<unknown>>(api.get('/api/ledger/exception', { params: p })),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface SettleResp {
|
||||
settleId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
orgName?: string;
|
||||
gradeName?: string;
|
||||
settleMonth: string;
|
||||
recruitTotal?: number;
|
||||
maintainTotal?: number;
|
||||
exceptionPlus?: number;
|
||||
exceptionMinus?: number;
|
||||
overrideTotal?: number;
|
||||
chargebackTotal?: number;
|
||||
grossAmount?: number;
|
||||
taxAmount?: number;
|
||||
netAmount?: number;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
holdReason?: string;
|
||||
}
|
||||
|
||||
export interface SettleSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
settleMonth?: string;
|
||||
agentId?: number;
|
||||
orgId?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const settleApi = {
|
||||
list: (p: SettleSearchParam) =>
|
||||
unwrap<PageResponse<SettleResp>>(api.get('/api/settle', { params: p })),
|
||||
summary: (settleMonth: string) =>
|
||||
unwrap<Record<string, number>>(api.get('/api/settle/summary', { params: { settleMonth } })),
|
||||
orgSummary: (settleMonth: string) =>
|
||||
unwrap<Array<Record<string, unknown>>>(api.get('/api/settle/org-summary', { params: { settleMonth } })),
|
||||
confirm: (settleId: number) =>
|
||||
unwrap<void>(api.put(`/api/settle/${settleId}/confirm`)),
|
||||
hold: (settleId: number, reason: string) =>
|
||||
unwrap<void>(api.put(`/api/settle/${settleId}/hold`, { reason })),
|
||||
release: (settleId: number) =>
|
||||
unwrap<void>(api.put(`/api/settle/${settleId}/release`)),
|
||||
};
|
||||
|
||||
export interface BatchJobLog {
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
settleMonth?: string;
|
||||
status: string;
|
||||
currentStep?: string;
|
||||
progressPct?: number;
|
||||
errorMessage?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
export const batchApi = {
|
||||
run: (settleMonth: string) =>
|
||||
unwrap<number>(api.post('/api/batch/settlement/run', { settleMonth })),
|
||||
status: () =>
|
||||
unwrap<BatchJobLog>(api.get('/api/batch/status')),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Tag } from 'antd';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
|
||||
interface Props {
|
||||
groupCode: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
ACTIVE: 'green', CONFIRMED: 'green', APPROVED: 'green', PAID: 'green', MATCHED: 'green', DONE: 'green',
|
||||
PENDING: 'blue', CALCULATED: 'blue', SENT: 'blue',
|
||||
HOLD: 'red', SUSPEND: 'red', REJECTED: 'red', FAIL: 'red', LAPSE: 'red', DIFF: 'red', LOCKED: 'red',
|
||||
DRAFT: 'default', NEW: 'default', LEAVE: 'default', RETIRED: 'default',
|
||||
};
|
||||
|
||||
export default function CodeBadge({ groupCode, value }: Props) {
|
||||
const { data = [] } = useCommonCodes(groupCode);
|
||||
if (!value) return null;
|
||||
const code = data.find((c) => c.code === value);
|
||||
const label = code?.codeName ?? value;
|
||||
const color = COLOR_MAP[value] ?? 'default';
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
|
||||
interface Props extends Omit<SelectProps, 'options'> {
|
||||
groupCode: string;
|
||||
includeAll?: boolean;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
export default function CodeSelect({ groupCode, includeAll, allLabel = '전체', ...rest }: Props) {
|
||||
const { data = [], isLoading } = useCommonCodes(groupCode);
|
||||
const options = [
|
||||
...(includeAll ? [{ value: '', label: allLabel }] : []),
|
||||
...data.map((c) => ({ value: c.code, label: c.codeName })),
|
||||
];
|
||||
return <Select loading={isLoading} options={options} allowClear {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Button, ButtonProps, message } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import api from '@/api/request';
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'onClick'> {
|
||||
url: string;
|
||||
params?: Record<string, unknown>;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대용량 엑셀 다운로드. axios responseType=blob, content-disposition 무시하고 fileName 강제.
|
||||
*/
|
||||
export default function ExcelExportButton({ url, params, fileName, ...rest }: Props) {
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const res = await api.get(url, { params, responseType: 'blob' });
|
||||
const blob = new Blob([res.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${fileName}.xlsx`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
} catch {
|
||||
message.error('엑셀 다운로드 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button icon={<DownloadOutlined />} onClick={handleClick} {...rest}>
|
||||
{rest.children ?? '엑셀'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface Props {
|
||||
value?: number | string | null;
|
||||
/** 양음 부호 표시 (true: +1,234 / -1,234) */
|
||||
showSign?: boolean;
|
||||
/** 소수점 자리 (default 0) */
|
||||
fraction?: number;
|
||||
}
|
||||
|
||||
export default function MoneyText({ value, showSign, fraction = 0 }: Props) {
|
||||
if (value === null || value === undefined || value === '') return <span>-</span>;
|
||||
const n = typeof value === 'string' ? Number(value) : value;
|
||||
if (Number.isNaN(n)) return <span>-</span>;
|
||||
const formatted = n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fraction,
|
||||
maximumFractionDigits: fraction,
|
||||
});
|
||||
const sign = n < 0 ? '' : showSign ? '+' : '';
|
||||
const color = n < 0 ? '#cf1322' : n > 0 && showSign ? '#1677ff' : 'inherit';
|
||||
return <span style={{ color, fontVariantNumeric: 'tabular-nums' }}>{sign}{formatted}</span>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageContainer({ title, extra, children }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>{title}</Title>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button, ButtonProps } from 'antd';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface Props extends ButtonProps {
|
||||
menuCode: string;
|
||||
permCode: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'EXPORT';
|
||||
}
|
||||
|
||||
/** 권한 없으면 렌더링 자체를 안 함 */
|
||||
export default function PermissionButton({ menuCode, permCode, children, ...rest }: Props) {
|
||||
const perms = usePermission(menuCode);
|
||||
const allowed =
|
||||
(permCode === 'READ' && perms.canRead) ||
|
||||
(permCode === 'CREATE' && perms.canCreate) ||
|
||||
(permCode === 'UPDATE' && perms.canUpdate) ||
|
||||
(permCode === 'DELETE' && perms.canDelete) ||
|
||||
(permCode === 'APPROVE' && perms.canApprove) ||
|
||||
(permCode === 'EXPORT' && perms.canExport);
|
||||
if (!allowed) return null;
|
||||
return <Button {...rest}>{children}</Button>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button, Col, DatePicker, Form, Input, Row, Space } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from './CodeSelect';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export interface SearchCondition {
|
||||
type: 'text' | 'code' | 'dateRange' | 'month';
|
||||
name: string;
|
||||
label: string;
|
||||
groupCode?: string; // type=code 일 때
|
||||
placeholder?: string;
|
||||
span?: number; // 24그리드 기준 너비 (default 6)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conditions: SearchCondition[];
|
||||
initialValues?: Record<string, unknown>;
|
||||
onSearch: (values: Record<string, unknown>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 배열만 넘기면 자동 렌더링되는 검색 폼.
|
||||
* dateRange → startDate/endDate 로 분해, month → settleMonth(yyyyMM)
|
||||
*/
|
||||
export default function SearchForm({ conditions, initialValues, onSearch, onReset }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleSearch = (raw: Record<string, unknown>) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const c of conditions) {
|
||||
const v = raw[c.name];
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
if (c.type === 'dateRange' && Array.isArray(v) && v.length === 2) {
|
||||
out.startDate = (v[0] as dayjs.Dayjs).format('YYYY-MM-DD');
|
||||
out.endDate = (v[1] as dayjs.Dayjs).format('YYYY-MM-DD');
|
||||
} else if (c.type === 'month' && v) {
|
||||
out[c.name] = (v as dayjs.Dayjs).format('YYYYMM');
|
||||
} else {
|
||||
out[c.name] = v;
|
||||
}
|
||||
}
|
||||
onSearch(out);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSearch}
|
||||
style={{ background: '#fff', padding: 16, marginBottom: 16, borderRadius: 4 }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
{conditions.map((c) => (
|
||||
<Col span={c.span ?? 6} key={c.name}>
|
||||
<Form.Item name={c.name} label={c.label}>
|
||||
{renderField(c)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 24 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">검색</Button>
|
||||
<Button onClick={handleReset}>초기화</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(c: SearchCondition) {
|
||||
switch (c.type) {
|
||||
case 'text':
|
||||
return <Input placeholder={c.placeholder} allowClear />;
|
||||
case 'code':
|
||||
return <CodeSelect groupCode={c.groupCode!} placeholder={c.placeholder} includeAll />;
|
||||
case 'dateRange':
|
||||
return <RangePicker style={{ width: '100%' }} />;
|
||||
case 'month':
|
||||
return <DatePicker picker="month" style={{ width: '100%' }} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { codeApi } from '@/api/code';
|
||||
|
||||
/**
|
||||
* 공통코드 그룹별 목록을 React Query 로 캐싱하여 반환.
|
||||
* staleTime: 1시간 (서버 변경 후 페이지 새로고침이면 충분)
|
||||
*/
|
||||
export function useCommonCodes(groupCode: string) {
|
||||
return useQuery({
|
||||
queryKey: ['commonCode', groupCode],
|
||||
queryFn: () => codeApi.byGroup(groupCode),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
enabled: !!groupCode,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { menuApi, MenuResp } from '@/api/menu';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface MenuPermissions {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* menuCode 별 사용자 권한 보유 여부.
|
||||
* 메뉴 트리에서 해당 menuCode 의 permissions 배열을 평탄화하여 boolean 으로 반환.
|
||||
*/
|
||||
export function usePermission(menuCode: string): MenuPermissions {
|
||||
const { data: tree = [] } = useQuery({
|
||||
queryKey: ['menu', 'my'],
|
||||
queryFn: menuApi.myMenus,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const perms = findPermissions(tree, menuCode);
|
||||
return {
|
||||
canRead: perms.includes('READ'),
|
||||
canCreate: perms.includes('CREATE'),
|
||||
canUpdate: perms.includes('UPDATE'),
|
||||
canDelete: perms.includes('DELETE'),
|
||||
canApprove: perms.includes('APPROVE'),
|
||||
canExport: perms.includes('EXPORT'),
|
||||
};
|
||||
}, [tree, menuCode]);
|
||||
}
|
||||
|
||||
function findPermissions(nodes: MenuResp[], code: string): string[] {
|
||||
for (const n of nodes) {
|
||||
if (n.menuCode === code) return n.permissions ?? [];
|
||||
const found = findPermissions(n.children ?? [], code);
|
||||
if (found.length) return found;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger';
|
||||
|
||||
export default function RecruitLedger() {
|
||||
const [param, setParam] = useState<LedgerSearchParam>({ pageNum: 1, pageSize: 50 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ledger', 'recruit', param],
|
||||
queryFn: () => ledgerApi.recruit(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="모집수수료 원장"
|
||||
extra={
|
||||
<ExcelExportButton url="/api/ledger/recruit/export" params={param} fileName="모집수수료원장" />
|
||||
}
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월' },
|
||||
{ type: 'text', name: 'policyNo', label: '증권번호' },
|
||||
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
|
||||
{ type: 'code', name: 'reconcileStatus', label: '대사상태', groupCode: 'RECON_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
onReset={() => setParam({ pageNum: 1, pageSize: 50 })}
|
||||
/>
|
||||
|
||||
<Table<LedgerResp>
|
||||
rowKey="ledgerId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
size="small"
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
columns={[
|
||||
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
|
||||
{ title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left' },
|
||||
{ title: '설계사', dataIndex: 'agentName', width: 100 },
|
||||
{ title: '소속', dataIndex: 'orgName', width: 160 },
|
||||
{ title: '보험사', dataIndex: 'companyName', width: 120 },
|
||||
{ title: '상품', dataIndex: 'productName', width: 200 },
|
||||
{ title: '보험종류', dataIndex: 'insuranceType', width: 100,
|
||||
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} /> },
|
||||
{ title: '보험료', dataIndex: 'premium', width: 110, align: 'right',
|
||||
render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right',
|
||||
render: (v) => <MoneyText value={v} fraction={2} /> },
|
||||
{ title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right',
|
||||
render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right',
|
||||
render: (v) => <MoneyText value={v} fraction={2} /> },
|
||||
{ title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right',
|
||||
render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right',
|
||||
render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '대사상태', dataIndex: 'reconcileStatus', width: 100,
|
||||
render: (v) => <CodeBadge groupCode="RECON_STATUS" value={v} /> },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import { agentApi, AgentResp, AgentSearchParam } from '@/api/agent';
|
||||
|
||||
export default function AgentList() {
|
||||
const navigate = useNavigate();
|
||||
const [param, setParam] = useState<AgentSearchParam>({ pageNum: 1, pageSize: 20 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agent', 'list', param],
|
||||
queryFn: () => agentApi.list(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="설계사 관리"
|
||||
extra={
|
||||
<div>
|
||||
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
|
||||
onClick={() => navigate('/org/agents/new')} style={{ marginRight: 8 }}>
|
||||
등록
|
||||
</PermissionButton>
|
||||
<ExcelExportButton url="/api/agents/export" params={param} fileName="설계사목록" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
|
||||
/>
|
||||
|
||||
<Table<AgentResp>
|
||||
rowKey="agentId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
pagination={{
|
||||
current: param.pageNum,
|
||||
pageSize: param.pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => setParam({ ...param, pageNum: page, pageSize: size }),
|
||||
}}
|
||||
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
|
||||
columns={[
|
||||
{ title: '설계사명', dataIndex: 'agentName', width: 120 },
|
||||
{ title: '사번', dataIndex: 'licenseNo', width: 120 },
|
||||
{ title: '소속', dataIndex: 'orgName', width: 200 },
|
||||
{ title: '직급', dataIndex: 'gradeName', width: 100 },
|
||||
{ title: '전화번호', dataIndex: 'phone', width: 140 },
|
||||
{
|
||||
title: '상태', dataIndex: 'status', width: 100,
|
||||
render: (v) => <CodeBadge groupCode="AGENT_STATUS" value={v} />,
|
||||
},
|
||||
{
|
||||
title: '계약수', dataIndex: 'contractCount', align: 'right', width: 90,
|
||||
render: (v) => <MoneyText value={v} />,
|
||||
},
|
||||
{
|
||||
title: '누적수수료', dataIndex: 'totalCommission', align: 'right', width: 130,
|
||||
render: (v) => <MoneyText value={v} />,
|
||||
},
|
||||
{ title: '입사일', dataIndex: 'joinDate', width: 110 },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import { contractApi, ContractResp, ContractSearchParam } from '@/api/contract';
|
||||
|
||||
export default function ContractList() {
|
||||
const [param, setParam] = useState<ContractSearchParam>({ pageNum: 1, pageSize: 20 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract', 'list', param],
|
||||
queryFn: () => contractApi.list(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer title="계약 관리">
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '계약자/증권번호' },
|
||||
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'CONTRACT_STATUS' },
|
||||
{ type: 'dateRange', name: 'dateRange', label: '계약일' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
|
||||
/>
|
||||
|
||||
<Table<ContractResp>
|
||||
rowKey="contractId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
columns={[
|
||||
{ title: '증권번호', dataIndex: 'policyNo', width: 140 },
|
||||
{ title: '계약자', dataIndex: 'contractorName', width: 100 },
|
||||
{ title: '설계사', dataIndex: 'agentName', width: 100 },
|
||||
{ title: '보험사', dataIndex: 'companyName', width: 120 },
|
||||
{ title: '상품', dataIndex: 'productName', width: 220 },
|
||||
{
|
||||
title: '보험종류', dataIndex: 'insuranceType', width: 100,
|
||||
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} />,
|
||||
},
|
||||
{
|
||||
title: '보험료', dataIndex: 'premium', width: 120, align: 'right',
|
||||
render: (v) => <MoneyText value={v} />,
|
||||
},
|
||||
{
|
||||
title: '납입주기', dataIndex: 'payCycle', width: 90,
|
||||
render: (v) => <CodeBadge groupCode="PAY_CYCLE" value={v} />,
|
||||
},
|
||||
{
|
||||
title: '상태', dataIndex: 'status', width: 90,
|
||||
render: (v) => <CodeBadge groupCode="CONTRACT_STATUS" value={v} />,
|
||||
},
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 110 },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, message, Modal, Input, Space } from 'antd';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { settleApi, SettleResp, SettleSearchParam } from '@/api/settle';
|
||||
|
||||
export default function SettleList() {
|
||||
const qc = useQueryClient();
|
||||
const [param, setParam] = useState<SettleSearchParam>({ pageNum: 1, pageSize: 30 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['settle', 'list', param],
|
||||
queryFn: () => settleApi.list(param),
|
||||
});
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['settle', 'summary', param.settleMonth],
|
||||
queryFn: () => settleApi.summary(param.settleMonth!),
|
||||
enabled: !!param.settleMonth,
|
||||
});
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['settle'] });
|
||||
|
||||
const onConfirm = (id: number) =>
|
||||
Modal.confirm({
|
||||
title: '정산 확정', content: '확정 후에는 변경할 수 없습니다. 진행하시겠습니까?',
|
||||
onOk: async () => { await settleApi.confirm(id); message.success('확정 완료'); refresh(); },
|
||||
});
|
||||
|
||||
const onHold = (id: number) => {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '정산 보류',
|
||||
content: <Input placeholder="보류 사유" onChange={(e) => { reason = e.target.value; }} />,
|
||||
onOk: async () => { await settleApi.hold(id, reason); message.success('보류 처리'); refresh(); },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="정산 결과">
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월' },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'SETTLE_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
|
||||
/>
|
||||
|
||||
{summary && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}><Card><Statistic title="대상 설계사" value={Number(summary.agentCount ?? 0)} suffix="명" /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="총 지급액(net)" value={Number(summary.netAmount ?? 0)} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="총 세액" value={Number(summary.taxAmount ?? 0)} valueStyle={{ color: '#cf1322' }} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="확정 건수" value={Number(summary.confirmedCount ?? 0)} suffix="건" /></Card></Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Table<SettleResp>
|
||||
rowKey="settleId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
size="small"
|
||||
scroll={{ x: 1600 }}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
columns={[
|
||||
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
|
||||
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' },
|
||||
{ title: '소속', dataIndex: 'orgName', width: 160 },
|
||||
{ title: '직급', dataIndex: 'gradeName', width: 80 },
|
||||
{ title: '모집', dataIndex: 'recruitTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '유지', dataIndex: 'maintainTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '예외(+)', dataIndex: 'exceptionPlus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '예외(-)', dataIndex: 'exceptionMinus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '오버라이드', dataIndex: 'overrideTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '환수', dataIndex: 'chargebackTotal', width: 100, align: 'right',
|
||||
render: (v) => <MoneyText value={v ? -Math.abs(Number(v)) : 0} /> },
|
||||
{ title: '총액', dataIndex: 'grossAmount', width: 120, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
|
||||
{ title: '실지급', dataIndex: 'netAmount', width: 130, align: 'right',
|
||||
render: (v) => <strong><MoneyText value={v} /></strong> },
|
||||
{ title: '상태', dataIndex: 'status', width: 100, fixed: 'right',
|
||||
render: (v) => <CodeBadge groupCode="SETTLE_STATUS" value={v} /> },
|
||||
{
|
||||
title: '액션', dataIndex: 'settleId', width: 200, fixed: 'right',
|
||||
render: (id, r) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
|
||||
type="primary" onClick={() => onConfirm(id)}
|
||||
disabled={r.status === 'CONFIRMED' || r.status === 'PAID'}>
|
||||
확정
|
||||
</PermissionButton>
|
||||
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
|
||||
danger onClick={() => onHold(id)}
|
||||
disabled={r.status === 'PAID'}>
|
||||
보류
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user