From 6747692dfffe5ff659d3bd22a477fca35a7baab2 Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 14:45:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?LOAN=20=EC=A7=80=EA=B8=89=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=ED=82=A4=EC=9B=8C=EB=93=9C=EB=A5=BC=20common=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=8B=A8=EC=9D=BC=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/LoanDisbursementKeywords.java | 43 ++++++++++ .../domain/LoanDisbursementKeywordsTest.java | 79 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 common/src/main/java/com/ntropy/common/domain/LoanDisbursementKeywords.java create mode 100644 common/src/test/java/com/ntropy/common/domain/LoanDisbursementKeywordsTest.java diff --git a/common/src/main/java/com/ntropy/common/domain/LoanDisbursementKeywords.java b/common/src/main/java/com/ntropy/common/domain/LoanDisbursementKeywords.java new file mode 100644 index 00000000..046b4366 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/domain/LoanDisbursementKeywords.java @@ -0,0 +1,43 @@ +package com.ntropy.common.domain; + +import java.util.List; +import java.util.Locale; + +/** + * LOAN 거래구분명에서 대출금 지급(신규·실행·증액) 거래를 판정하는 단일 규칙입니다. + * account-service의 월간 소비 집계·금융 납입 예정 조회(SQL)와 ai-service의 + * 일간 소비 분류 배치(Java)가 이 키워드 목록과 판정 의미를 함께 사용합니다. + * + * 판정 의미는 "공백 정규화 후 부분 문자열 포함"입니다. "실행"이 "대출실행"의 + * 부분 문자열이므로 "대출실행"은 목록에서 제외해도 판정 결과가 같습니다. + */ +public final class LoanDisbursementKeywords { + + public static final List KEYWORDS = List.of( + "신규", + "실행", + "증액" + ); + + private LoanDisbursementKeywords() { + } + + /** + * loanTransactionTypeName이 대출금 지급 거래를 나타내는 키워드를 포함하는지 판정합니다. + * null/빈 문자열은 제외 사유가 없는 것으로 보고 false(정상 상환 후보)를 반환합니다. + */ + public static boolean matches(String loanTransactionTypeName) { + String normalized = normalize(loanTransactionTypeName); + return KEYWORDS.stream().anyMatch(normalized::contains); + } + + private static String normalize(String value) { + if (value == null) { + return ""; + } + + return value + .replaceAll("\\s+", "") + .toUpperCase(Locale.ROOT); + } +} diff --git a/common/src/test/java/com/ntropy/common/domain/LoanDisbursementKeywordsTest.java b/common/src/test/java/com/ntropy/common/domain/LoanDisbursementKeywordsTest.java new file mode 100644 index 00000000..a6a7eb4e --- /dev/null +++ b/common/src/test/java/com/ntropy/common/domain/LoanDisbursementKeywordsTest.java @@ -0,0 +1,79 @@ +package com.ntropy.common.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class LoanDisbursementKeywordsTest { + + @Test + void keywordListIsNeverEmpty() { + assertFalse( + LoanDisbursementKeywords.KEYWORDS.isEmpty(), + "MyBatis 가 빈 목록으로는 유효한 조건을 생성하지 못하므로 " + + "목록이 비어 있으면 안 됩니다" + ); + } + + @Test + void nullIsNotDisbursement() { + assertFalse(LoanDisbursementKeywords.matches(null)); + } + + @Test + void blankIsNotDisbursement() { + assertFalse(LoanDisbursementKeywords.matches("")); + assertFalse(LoanDisbursementKeywords.matches(" ")); + } + + @Test + void normalRepaymentIsNotDisbursement() { + assertFalse(LoanDisbursementKeywords.matches("정상상환")); + } + + @Test + void newLoanIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("신규")); + } + + @Test + void executionIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("실행")); + } + + @Test + void increaseIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("증액")); + } + + @Test + void loanExecutionIsDisbursementBecauseItContainsExecution() { + assertTrue(LoanDisbursementKeywords.matches("대출실행")); + } + + @Test + void loanExecutionWithInternalSpaceIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("대출 실행")); + } + + @Test + void newLoanWithInternalSpaceIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("신 규")); + } + + @Test + void executionWithInternalSpaceIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("실 행")); + } + + @Test + void increaseWithInternalSpaceIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("증 액")); + } + + @Test + void keywordInMiddleOfStringIsDisbursement() { + assertTrue(LoanDisbursementKeywords.matches("2026년 신규 대출 실행분")); + } +} From 52235160161cd43f42c4da18c4bab981b279de60 Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 14:45:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?LOAN=20=EC=A7=80=EA=B8=89=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20common=20LoanDisbursementKeywords=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TransactionPreClassificationService.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java b/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java index 9a30b288..166e0ad6 100644 --- a/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java +++ b/services/ai-service/src/main/java/com/ntropy/ai/service/TransactionPreClassificationService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; +import com.ntropy.common.domain.LoanDisbursementKeywords; import com.ntropy.common.dto.account.DailyClassificationTargetTransaction; import com.ntropy.common.dto.account.TransactionAnalysisSaveItem; @@ -16,14 +17,6 @@ @Service public class TransactionPreClassificationService { - private static final List LOAN_DISBURSEMENT_KEYWORDS = - List.of( - "신규", - "실행", - "대출실행", - "증액" - ); - private static final List FINANCIAL_TRANSFER_KEYWORDS = List.of( "정기예금", @@ -87,14 +80,17 @@ public Optional classify( /* * LOAN 신규·실행·증액은 대출금 지급 거래이므로 비소비입니다. + * 판정 키워드는 LoanDisbursementKeywords(common)를 MonthlyExpenseMapper· + * FinancialCommitmentMapper의 SQL 판정과 공유합니다. * - * 그 외 LOAN 거래는 정상 상환으로 보고 총상환액 전체를 - * FINANCE / FIXED 소비로 처리합니다. + * 그 외 LOAN 거래는 정상 상환으로 보고 FINANCE / FIXED 소비로 분류합니다. + * 여기서는 분류 정보(is_consumption/category/expense_type)만 저장하며 금액은 + * 저장하지 않습니다. 월간 집계 금액(원금 제외, 이자만 소비)은 MonthlyExpenseMapper가 + * ACCOUNT_TRANSACTION에서 별도로 결정적으로 계산합니다. */ if ("LOAN".equals(transactionCategory)) { - if (containsAny( - transaction.getLoanTransactionTypeName(), - LOAN_DISBURSEMENT_KEYWORDS + if (LoanDisbursementKeywords.matches( + transaction.getLoanTransactionTypeName() )) { return Optional.of( nonConsumption(transaction.getTransactionId()) From 6d88800724fe9d5c89c206e4a8c7654fcc5a1646 Mon Sep 17 00:00:00 2001 From: kskim Date: Wed, 19 Aug 2026 14:45:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?LOAN=20=EC=A7=80=EA=B8=89=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20INSTALLME?= =?UTF-8?q?NT=20=EC=86=8C=EB=B9=84=20=EC=A7=91=EA=B3=84=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/FinancialCommitmentMapper.java | 9 +- .../account/mapper/MonthlyExpenseMapper.java | 11 +- .../service/FinancialCommitmentService.java | 4 +- .../service/MonthlyExpenseService.java | 10 +- .../account/FinancialCommitmentMapper.xml | 28 +++-- .../mapper/account/MonthlyExpenseMapper.xml | 109 ++++++++++++------ ...ancialCommitmentQueryClientWiringTest.java | 3 +- ...eMapperLoanRuleManualVerificationTest.java | 102 +++++++++++++--- ...FinancialCommitmentMapperContractTest.java | 34 +++++- .../MonthlyExpenseMapperContractTest.java | 62 +++++++--- .../FinancialCommitmentServiceTest.java | 3 +- .../service/MonthlyExpenseServiceTest.java | 9 +- 12 files changed, 285 insertions(+), 99 deletions(-) diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialCommitmentMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialCommitmentMapper.java index 0ca2e810..2cdce597 100644 --- a/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialCommitmentMapper.java +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialCommitmentMapper.java @@ -16,7 +16,14 @@ public interface FinancialCommitmentMapper { List findSavingCommitmentCandidates(@Param("userId") Long userId); - List findLoanCommitmentCandidates(@Param("userId") Long userId); + /** + * loanDisbursementKeywords는 LOAN 신규·실행·증액(대출금 지급) 판정에 사용되며, + * 호출 측은 com.ntropy.common.domain.LoanDisbursementKeywords.KEYWORDS를 전달해야 합니다. + */ + List findLoanCommitmentCandidates( + @Param("userId") Long userId, + @Param("loanDisbursementKeywords") List loanDisbursementKeywords + ); List findInsuranceOutflowCandidates( @Param("userId") Long userId, diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java index d3bf0eaa..51410ae1 100644 --- a/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/MonthlyExpenseMapper.java @@ -16,11 +16,14 @@ public interface MonthlyExpenseMapper { /** * 특정 기간의 총소비 금액을 조회합니다. + * loanDisbursementKeywords는 LOAN 신규·실행·증액(대출금 지급) 판정에 사용되며, + * 호출 측은 com.ntropy.common.domain.LoanDisbursementKeywords.KEYWORDS를 전달해야 합니다. */ Long findTotalExpense( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate + @Param("endDate") LocalDate endDate, + @Param("loanDisbursementKeywords") List loanDisbursementKeywords ); /** @@ -29,7 +32,8 @@ Long findTotalExpense( List findCategoryExpenses( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate + @Param("endDate") LocalDate endDate, + @Param("loanDisbursementKeywords") List loanDisbursementKeywords ); /** @@ -38,6 +42,7 @@ List findCategoryExpenses( Long findFixedExpense( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate + @Param("endDate") LocalDate endDate, + @Param("loanDisbursementKeywords") List loanDisbursementKeywords ); } \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/service/FinancialCommitmentService.java b/services/account-service/src/main/java/com/ntropy/account/service/FinancialCommitmentService.java index b460e519..31794291 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/FinancialCommitmentService.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/FinancialCommitmentService.java @@ -20,6 +20,7 @@ import com.ntropy.account.mapper.projection.InsuranceOutflowRow; import com.ntropy.account.mapper.projection.LoanCommitmentCandidateRow; import com.ntropy.account.mapper.projection.SavingCommitmentCandidateRow; +import com.ntropy.common.domain.LoanDisbursementKeywords; import com.ntropy.common.dto.account.FinancialCommitmentSummary; import com.ntropy.common.exception.ServiceException; @@ -87,7 +88,8 @@ private List buildSavingCommitments(Long userId, Loc private List buildLoanCommitments(Long userId, LocalDate fromDate, LocalDate toDate) { List result = new ArrayList<>(); - for (LoanCommitmentCandidateRow row : financialCommitmentMapper.findLoanCommitmentCandidates(userId)) { + for (LoanCommitmentCandidateRow row : financialCommitmentMapper.findLoanCommitmentCandidates( + userId, LoanDisbursementKeywords.KEYWORDS)) { if (!withinRangeOrUnknown(row.getNextPaymentDate(), fromDate, toDate)) { continue; } diff --git a/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java b/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java index d2f62746..6bfd3d5e 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/MonthlyExpenseService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import com.ntropy.account.mapper.MonthlyExpenseMapper; +import com.ntropy.common.domain.LoanDisbursementKeywords; import com.ntropy.common.dto.account.CategoryExpenseAmount; import com.ntropy.common.dto.account.MonthlyExpenseSummary; @@ -58,21 +59,24 @@ public MonthlyExpenseSummary findMonthlyExpense( monthlyExpenseMapper.findTotalExpense( userId, startDate, - endDate + endDate, + LoanDisbursementKeywords.KEYWORDS ); Long fixedExpense = monthlyExpenseMapper.findFixedExpense( userId, startDate, - endDate + endDate, + LoanDisbursementKeywords.KEYWORDS ); List rows = monthlyExpenseMapper.findCategoryExpenses( userId, startDate, - endDate + endDate, + LoanDisbursementKeywords.KEYWORDS ); Map categoryExpenses = diff --git a/services/account-service/src/main/resources/mapper/account/FinancialCommitmentMapper.xml b/services/account-service/src/main/resources/mapper/account/FinancialCommitmentMapper.xml index 04a8ae45..75a3b16b 100644 --- a/services/account-service/src/main/resources/mapper/account/FinancialCommitmentMapper.xml +++ b/services/account-service/src/main/resources/mapper/account/FinancialCommitmentMapper.xml @@ -30,10 +30,24 @@ AND account_row.deposit_type_code = '12' + + + + REGEXP_REPLACE( + COALESCE(loan_row.loan_transaction_type_name, ''), + '[[:space:]]+', + '' + ) NOT LIKE CONCAT('%', #{keyword}, '%') + + +