feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)

This commit is contained in:
forge-bot 2026-07-18 15:27:41 +00:00
commit 0f6e3acfe6
92 changed files with 4320 additions and 0 deletions

38
modules/reconcile/pom.xml Normal file
View 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>

View file

@ -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;
}
}

View file

@ -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) {
}
}

View file

@ -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();
}
}

View file

@ -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("금액 불일치");
}
}