feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)
This commit is contained in:
commit
0f6e3acfe6
92 changed files with 4320 additions and 0 deletions
38
modules/reconcile/pom.xml
Normal file
38
modules/reconcile/pom.xml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.klaro.acquiring</groupId>
|
||||
<artifactId>card-acquiring-boot</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>reconcile</artifactId>
|
||||
<name>reconcile</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.klaro.acquiring</groupId>
|
||||
<artifactId>common-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.klaro.acquiring</groupId>
|
||||
<artifactId>domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.klaro.acquiring</groupId>
|
||||
<artifactId>persistence</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-batch</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.klaro.acquiring.reconcile;
|
||||
|
||||
import com.klaro.acquiring.common.util.AmountUtil;
|
||||
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||
import com.klaro.acquiring.domain.enums.TxStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 대사(rc) 3-way 매칭기 (순수 로직 — 단위 테스트 대상).
|
||||
*
|
||||
* <p>승인금액 vs 매입금액 vs 입금금액을 허용오차 내에서 비교한다.
|
||||
* 매입/입금 누락 또는 금액 불일치 시 불일치(FAILED)로 판정한다.
|
||||
*/
|
||||
@Component
|
||||
public class ReconcileMatcher {
|
||||
|
||||
/** 대사 허용오차(원). 반올림/수수료 조정 흡수용. */
|
||||
public static final BigDecimal DEFAULT_TOLERANCE = new BigDecimal("1");
|
||||
|
||||
public ReconcileResult match(LocalDate bizDate, String apprNo, String merchId,
|
||||
BigDecimal approvalAmount,
|
||||
BigDecimal purchaseAmount, boolean hasPurchase,
|
||||
BigDecimal depositAmount, boolean hasDeposit,
|
||||
BigDecimal tolerance) {
|
||||
ReconcileResult r = new ReconcileResult(bizDate, apprNo, merchId);
|
||||
BigDecimal appr = AmountUtil.nz(approvalAmount);
|
||||
BigDecimal pur = AmountUtil.nz(purchaseAmount);
|
||||
BigDecimal dep = AmountUtil.nz(depositAmount);
|
||||
r.setApprovalAmount(appr);
|
||||
r.setPurchaseAmount(pur);
|
||||
r.setDepositAmount(dep);
|
||||
|
||||
if (!hasPurchase) {
|
||||
r.setMatched(false);
|
||||
r.setStatus(TxStatus.FAILED);
|
||||
r.setReason("매입 누락");
|
||||
r.setDiffAmount(appr);
|
||||
return r;
|
||||
}
|
||||
if (!hasDeposit) {
|
||||
r.setMatched(false);
|
||||
r.setStatus(TxStatus.FAILED);
|
||||
r.setReason("입금 누락");
|
||||
r.setDiffAmount(appr.subtract(pur).abs());
|
||||
return r;
|
||||
}
|
||||
|
||||
BigDecimal d1 = appr.subtract(pur).abs();
|
||||
BigDecimal d2 = pur.subtract(dep).abs();
|
||||
BigDecimal d3 = appr.subtract(dep).abs();
|
||||
BigDecimal maxDiff = d1.max(d2).max(d3);
|
||||
r.setDiffAmount(maxDiff);
|
||||
|
||||
boolean ok = maxDiff.compareTo(AmountUtil.nz(tolerance)) <= 0;
|
||||
if (ok) {
|
||||
r.setMatched(true);
|
||||
r.setStatus(TxStatus.DONE);
|
||||
r.setReason(null);
|
||||
} else {
|
||||
r.setMatched(false);
|
||||
r.setStatus(TxStatus.FAILED);
|
||||
r.setReason("금액 불일치 diff=" + maxDiff);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.klaro.acquiring.reconcile;
|
||||
|
||||
import com.klaro.acquiring.domain.entity.Approval;
|
||||
import com.klaro.acquiring.domain.entity.Deposit;
|
||||
import com.klaro.acquiring.domain.entity.Purchase;
|
||||
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||
import com.klaro.acquiring.persistence.repository.DepositRepository;
|
||||
import com.klaro.acquiring.persistence.repository.PurchaseRepository;
|
||||
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 대사(rc) 서비스. 승인·매입·입금 원장을 승인번호 기준으로 3-way 대사한다.
|
||||
*/
|
||||
@Service
|
||||
public class ReconcileService {
|
||||
|
||||
private final ApprovalRepository approvalRepository;
|
||||
private final PurchaseRepository purchaseRepository;
|
||||
private final DepositRepository depositRepository;
|
||||
private final ReconcileResultRepository reconcileRepository;
|
||||
private final ReconcileMatcher matcher;
|
||||
|
||||
public ReconcileService(ApprovalRepository approvalRepository,
|
||||
PurchaseRepository purchaseRepository,
|
||||
DepositRepository depositRepository,
|
||||
ReconcileResultRepository reconcileRepository,
|
||||
ReconcileMatcher matcher) {
|
||||
this.approvalRepository = approvalRepository;
|
||||
this.purchaseRepository = purchaseRepository;
|
||||
this.depositRepository = depositRepository;
|
||||
this.reconcileRepository = reconcileRepository;
|
||||
this.matcher = matcher;
|
||||
}
|
||||
|
||||
/** 단건 대사 결과 계산(저장 없음) — Batch processor 및 테스트에서 사용. */
|
||||
@Transactional(readOnly = true)
|
||||
public ReconcileResult reconcileOne(Approval approval, BigDecimal tolerance) {
|
||||
Optional<Purchase> purchase = purchaseRepository.findByApprNo(approval.getApprNo());
|
||||
Optional<Deposit> deposit = depositRepository.findByApprNo(approval.getApprNo());
|
||||
return matcher.match(approval.getBizDate(), approval.getApprNo(), approval.getMerchId(),
|
||||
approval.getAmount(),
|
||||
purchase.map(Purchase::getAmount).orElse(BigDecimal.ZERO), purchase.isPresent(),
|
||||
deposit.map(Deposit::getAmount).orElse(BigDecimal.ZERO), deposit.isPresent(),
|
||||
tolerance);
|
||||
}
|
||||
|
||||
/** 영업일 전체 대사(계산+저장). 요약 반환. */
|
||||
@Transactional
|
||||
public ReconcileSummary reconcileDaily(LocalDate bizDate, BigDecimal tolerance) {
|
||||
List<Approval> approvals = approvalRepository.findByBizDate(bizDate);
|
||||
long matched = 0;
|
||||
long unmatched = 0;
|
||||
for (Approval a : approvals) {
|
||||
ReconcileResult r = reconcileOne(a, tolerance);
|
||||
reconcileRepository.save(r);
|
||||
if (r.isMatched()) {
|
||||
matched++;
|
||||
} else {
|
||||
unmatched++;
|
||||
}
|
||||
}
|
||||
return new ReconcileSummary(bizDate, approvals.size(), matched, unmatched);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ReconcileResult> results(LocalDate bizDate) {
|
||||
return reconcileRepository.findByBizDate(bizDate);
|
||||
}
|
||||
|
||||
/** 대사 요약. */
|
||||
public record ReconcileSummary(LocalDate bizDate, long total, long matched, long unmatched) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.klaro.acquiring.reconcile.batch;
|
||||
|
||||
import com.klaro.acquiring.common.util.DateUtil;
|
||||
import com.klaro.acquiring.domain.entity.Approval;
|
||||
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
|
||||
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
|
||||
import com.klaro.acquiring.reconcile.ReconcileMatcher;
|
||||
import com.klaro.acquiring.reconcile.ReconcileService;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.configuration.annotation.StepScope;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.batch.item.support.IteratorItemReader;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 대사 배치 Job (rc). Reader(승인 커서) → Processor(3-way 매칭) → Writer(대사결과 저장).
|
||||
* 레거시 rc_bt_* 커서 배치 대체.
|
||||
*/
|
||||
@Configuration
|
||||
public class ReconcileJobConfig {
|
||||
|
||||
public static final String JOB_NAME = "reconcileJob";
|
||||
|
||||
private final ApprovalRepository approvalRepository;
|
||||
private final ReconcileResultRepository reconcileRepository;
|
||||
private final ReconcileService reconcileService;
|
||||
|
||||
public ReconcileJobConfig(ApprovalRepository approvalRepository,
|
||||
ReconcileResultRepository reconcileRepository,
|
||||
ReconcileService reconcileService) {
|
||||
this.approvalRepository = approvalRepository;
|
||||
this.reconcileRepository = reconcileRepository;
|
||||
this.reconcileService = reconcileService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@StepScope
|
||||
public ItemReader<Approval> reconcileApprovalReader(
|
||||
@Value("#{jobParameters['bizDate']}") String bizDate) {
|
||||
LocalDate date = DateUtil.parse(bizDate);
|
||||
return new IteratorItemReader<>(approvalRepository.findByBizDate(date));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@StepScope
|
||||
public ItemProcessor<Approval, ReconcileResult> reconcileProcessor() {
|
||||
return approval -> reconcileService.reconcileOne(approval, ReconcileMatcher.DEFAULT_TOLERANCE);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ItemWriter<ReconcileResult> reconcileWriter() {
|
||||
return items -> reconcileRepository.saveAll(items);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Step reconcileStep(JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
ItemReader<Approval> reconcileApprovalReader,
|
||||
ItemProcessor<Approval, ReconcileResult> reconcileProcessor,
|
||||
ItemWriter<ReconcileResult> reconcileWriter) {
|
||||
return new StepBuilder("reconcileStep", jobRepository)
|
||||
.<Approval, ReconcileResult>chunk(20, transactionManager)
|
||||
.reader(reconcileApprovalReader)
|
||||
.processor(reconcileProcessor)
|
||||
.writer(reconcileWriter)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job reconcileJob(JobRepository jobRepository, Step reconcileStep) {
|
||||
return new JobBuilder(JOB_NAME, jobRepository)
|
||||
.start(reconcileStep)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.klaro.acquiring.reconcile;
|
||||
|
||||
import com.klaro.acquiring.domain.entity.ReconcileResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ReconcileMatcherTest {
|
||||
|
||||
private final ReconcileMatcher matcher = new ReconcileMatcher();
|
||||
private final LocalDate d = LocalDate.of(2026, 7, 17);
|
||||
private final BigDecimal tol = ReconcileMatcher.DEFAULT_TOLERANCE;
|
||||
|
||||
@Test
|
||||
void matchesWhenAllThreeAgreeWithinTolerance() {
|
||||
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||
new BigDecimal("10000"),
|
||||
new BigDecimal("10000"), true,
|
||||
new BigDecimal("10000"), true, tol);
|
||||
assertThat(r.isMatched()).isTrue();
|
||||
assertThat(r.getDiffAmount()).isEqualByComparingTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toleratesSmallDiff() {
|
||||
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||
new BigDecimal("10000"),
|
||||
new BigDecimal("10000"), true,
|
||||
new BigDecimal("10001"), true, tol);
|
||||
assertThat(r.isMatched()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsPurchaseMissing() {
|
||||
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||
new BigDecimal("10000"),
|
||||
BigDecimal.ZERO, false,
|
||||
BigDecimal.ZERO, false, tol);
|
||||
assertThat(r.isMatched()).isFalse();
|
||||
assertThat(r.getReason()).isEqualTo("매입 누락");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsDepositMissing() {
|
||||
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||
new BigDecimal("10000"),
|
||||
new BigDecimal("10000"), true,
|
||||
BigDecimal.ZERO, false, tol);
|
||||
assertThat(r.isMatched()).isFalse();
|
||||
assertThat(r.getReason()).isEqualTo("입금 누락");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsAmountMismatch() {
|
||||
ReconcileResult r = matcher.match(d, "A1", "M1",
|
||||
new BigDecimal("10000"),
|
||||
new BigDecimal("9000"), true,
|
||||
new BigDecimal("9000"), true, tol);
|
||||
assertThat(r.isMatched()).isFalse();
|
||||
assertThat(r.getReason()).contains("금액 불일치");
|
||||
}
|
||||
}
|
||||
Reference in a new issue