diff --git a/.gitignore b/.gitignore index 55608a8..f88cea6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ HELP.md .gradle build/ +.gradle-user/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/build.gradle b/build.gradle index 1f2c06b..7d307c2 100644 --- a/build.gradle +++ b/build.gradle @@ -25,8 +25,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation "org.springframework.boot:spring-boot-starter-validation" implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' diff --git a/src/main/java/apptive/fin/FinApplication.java b/src/main/java/apptive/fin/FinApplication.java index d644335..49b8991 100644 --- a/src/main/java/apptive/fin/FinApplication.java +++ b/src/main/java/apptive/fin/FinApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing +@EnableCaching @ConfigurationPropertiesScan public class FinApplication { diff --git a/src/main/java/apptive/fin/category/entity/CategoryOption.java b/src/main/java/apptive/fin/category/entity/CategoryOption.java index b58ac7d..45f0750 100644 --- a/src/main/java/apptive/fin/category/entity/CategoryOption.java +++ b/src/main/java/apptive/fin/category/entity/CategoryOption.java @@ -13,6 +13,8 @@ public class CategoryOption { private String value; + private String code; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category ; diff --git a/src/main/java/apptive/fin/category/repository/CategoryOptionRepository.java b/src/main/java/apptive/fin/category/repository/CategoryOptionRepository.java new file mode 100644 index 0000000..8a124ce --- /dev/null +++ b/src/main/java/apptive/fin/category/repository/CategoryOptionRepository.java @@ -0,0 +1,9 @@ +package apptive.fin.category.repository; + +import apptive.fin.category.entity.CategoryOption; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CategoryOptionRepository extends JpaRepository { +} diff --git a/src/main/java/apptive/fin/category/service/CategoryOptionService.java b/src/main/java/apptive/fin/category/service/CategoryOptionService.java new file mode 100644 index 0000000..546623a --- /dev/null +++ b/src/main/java/apptive/fin/category/service/CategoryOptionService.java @@ -0,0 +1,40 @@ +package apptive.fin.category.service; + +import apptive.fin.category.entity.CategoryOption; +import apptive.fin.category.repository.CategoryOptionRepository; +import apptive.fin.category.repository.CategoryRepository; +import apptive.fin.search.KeywordValueEnum; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CategoryOptionService { + + private final CategoryOptionRepository categoryOptionRepository; + + @Cacheable(cacheNames = "keywordOptionMap") + public Map getOptionMap() { + List allOptions = categoryOptionRepository.findAll(); + Map map = new HashMap<>(); + + for (CategoryOption option : allOptions) { + if (option.getCode() != null && !option.getCode().isBlank()) { + KeywordValueEnum keyword = KeywordValueEnum.from(option.getCode()); + if (keyword != null) { + map.put(option.getId(), keyword); + } + } + } + + return map; + } + +} diff --git a/src/main/java/apptive/fin/category/service/CategoryService.java b/src/main/java/apptive/fin/category/service/CategoryService.java index ba9b909..4c91f78 100644 --- a/src/main/java/apptive/fin/category/service/CategoryService.java +++ b/src/main/java/apptive/fin/category/service/CategoryService.java @@ -4,6 +4,7 @@ import apptive.fin.category.repository.CategoryRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; diff --git a/src/main/java/apptive/fin/search/KeywordValueEnum.java b/src/main/java/apptive/fin/search/KeywordValueEnum.java new file mode 100644 index 0000000..73c64dc --- /dev/null +++ b/src/main/java/apptive/fin/search/KeywordValueEnum.java @@ -0,0 +1,63 @@ +package apptive.fin.search; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum KeywordValueEnum { + + // 1. 거주 지역 + REGION_SEOUL("REGION_SEOUL"), + REGION_BUSAN("REGION_BUSAN"), + REGION_DAEGU("REGION_DAEGU"), + REGION_INCHEON("REGION_INCHEON"), + REGION_GWANGJU("REGION_GWANGJU"), + REGION_DAEJEON("REGION_DAEJEON"), + REGION_ULSAN("REGION_ULSAN"), + REGION_SEJONG("REGION_SEJONG"), + REGION_GYEONGGI("REGION_GYEONGGI"), + REGION_GANGWON("REGION_GANGWON"), + REGION_CHUNGBUK("REGION_CHUNGBUK"), + REGION_CHUNGNAM("REGION_CHUNGNAM"), + REGION_JEONBUK("REGION_JEONBUK"), + REGION_JEONNAM("REGION_JEONNAM"), + REGION_GYEONGBUK("REGION_GYEONGBUK"), + REGION_GYEONGNAM("REGION_GYEONGNAM"), + REGION_JEJU("REGION_JEJU"), + + // 2. 현재 신분 + STATUS_UNEMPLOYED("STATUS_UNEMPLOYED"), + STATUS_PART_TIME("STATUS_PART_TIME"), + STATUS_SME_WORKER("STATUS_SME_WORKER"), + STATUS_MILITARY("STATUS_MILITARY"), + + // 3. 저축 기간 + TERM_OVER_5_YEARS("TERM_OVER_5_YEARS"), + TERM_2_TO_3_YEARS("TERM_2_TO_3_YEARS"), + TERM_AROUND_1_YEAR("TERM_AROUND_1_YEAR"), + + // 4. 핵심 혜택 (핵심 기간) + BENEFIT_MAX_INTEREST("BENEFIT_MAX_INTEREST"), + BENEFIT_TAX_FREE("BENEFIT_TAX_FREE"), + BENEFIT_EASY_CONDITION("BENEFIT_EASY_CONDITION"), + BENEFIT_GOV_SUBSIDY("BENEFIT_GOV_SUBSIDY"), + + // 5. 상품 관심사 + INTEREST_SAVINGS("INTEREST_SAVINGS"), + INTEREST_LOAN("INTEREST_LOAN"), + + // 6. 은행 거래 + BANK_FIRST_TRANSACTION("BANK_FIRST_TRANSACTION"), + BANK_SALARY_TRANSFER("BANK_SALARY_TRANSFER"), + BANK_CARD_USAGE("BANK_CARD_USAGE"); + + private final String code; + + public static KeywordValueEnum from(String code) { + try { + return KeywordValueEnum.valueOf(code); + } + catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/apptive/fin/search/controller/SearchController.java b/src/main/java/apptive/fin/search/controller/SearchController.java new file mode 100644 index 0000000..ce9df2a --- /dev/null +++ b/src/main/java/apptive/fin/search/controller/SearchController.java @@ -0,0 +1,34 @@ +package apptive.fin.search.controller; + +import apptive.fin.search.dto.DynamicFormResponseDto; +import apptive.fin.search.dto.OptionRequestDto; +import apptive.fin.search.dto.SearchRequestDto; +import apptive.fin.search.service.DynamicFormService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/search") +public class SearchController { + + private final DynamicFormService dynamicFormService; + + @PostMapping("/dynamic-form") + public DynamicFormResponseDto dynamicForm(@Valid @RequestBody SearchRequestDto searchRequestDto) { + return dynamicFormService.calcFormCondition(searchRequestDto); + } + + @PostMapping + public SearchRequestDto search(@Valid @RequestBody SearchRequestDto searchRequestDto) { + return searchRequestDto; + } + +} diff --git a/src/main/java/apptive/fin/search/dto/DetailedOptionsDto.java b/src/main/java/apptive/fin/search/dto/DetailedOptionsDto.java new file mode 100644 index 0000000..acdbafe --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/DetailedOptionsDto.java @@ -0,0 +1,19 @@ +package apptive.fin.search.dto; + +import java.time.LocalDate; +import java.util.List; + +public record DetailedOptionsDto( + LocalDate birthdate, + Long annualIncome, + Integer householdSize, + Integer householdIncomePercent, + Integer tenureMonths, + Boolean isFirstJob, + Boolean isHomeless, + Boolean isHouseholder, // 세대주 여부 + Long monthlySavingsGoal, + List mainBanks, + List selectedInterestRateOptions +) { +} diff --git a/src/main/java/apptive/fin/search/dto/DynamicFormResponseDto.java b/src/main/java/apptive/fin/search/dto/DynamicFormResponseDto.java new file mode 100644 index 0000000..f1a4eaa --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/DynamicFormResponseDto.java @@ -0,0 +1,26 @@ +package apptive.fin.search.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record DynamicFormResponseDto( + Boolean showTenure, + Integer ageBound, + Integer yearlyEarnDefault, + Boolean showBankInterestRateCheckList, + MedianIncomesDto medianIncomes, + List preferentialInterestRateOptions +) { + + public DynamicFormResponseDto { + if (showTenure == null) showTenure = true; + if (ageBound == null) ageBound = 34; + // if (yearlyEarnDefault == null); + if (showBankInterestRateCheckList == null) showBankInterestRateCheckList = false; + // if (medianIncomes == null) medianIncomes = null; + if (preferentialInterestRateOptions == null) preferentialInterestRateOptions = List.of(); + } + +} diff --git a/src/main/java/apptive/fin/search/dto/MedianIncomesDto.java b/src/main/java/apptive/fin/search/dto/MedianIncomesDto.java new file mode 100644 index 0000000..a16a6f3 --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/MedianIncomesDto.java @@ -0,0 +1,43 @@ +package apptive.fin.search.dto; + +import apptive.fin.search.entity.MedianIncome; +import lombok.Builder; + +import java.util.List; + +@Builder +public record MedianIncomesDto( + Integer year, + Integer householdSize, + Integer p60, + Integer p80, + Integer p100, + Integer p120, + Integer p150, + Integer p180 +) { + public static MedianIncomesDto from(List medianIncomes) { + MedianIncomesDtoBuilder builder = MedianIncomesDto.builder(); + + builder.year(medianIncomes.getFirst().getYear()); + builder.householdSize(medianIncomes.getFirst().getHouseholdSize()); + + for (MedianIncome income : medianIncomes) { + switch (income.getEarnPercent()) { + case 60 -> builder.p60(income.getMonthlyIncome()); + case 80 -> builder.p80(income.getMonthlyIncome()); + case 100 -> builder.p100(income.getMonthlyIncome()); + case 120 -> builder.p120(income.getMonthlyIncome()); + case 150 -> builder.p150(income.getMonthlyIncome()); + case 180 -> builder.p180(income.getMonthlyIncome()); + } + } + + return builder.build(); + } + + public boolean isEmpty() { + return p60 == 0 && p80 == 0 && p100 == 0 + && p120 == 0 && p150 == 0 && p180 == 0; + } +} diff --git a/src/main/java/apptive/fin/search/dto/OptionRequestDto.java b/src/main/java/apptive/fin/search/dto/OptionRequestDto.java new file mode 100644 index 0000000..ebf4c8b --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/OptionRequestDto.java @@ -0,0 +1,9 @@ +package apptive.fin.search.dto; + +import jakarta.validation.constraints.NotNull; + +public record OptionRequestDto( + @NotNull Long categoryId, + @NotNull Long optionId +) { +} diff --git a/src/main/java/apptive/fin/search/dto/PreferentialInterestRateOption.java b/src/main/java/apptive/fin/search/dto/PreferentialInterestRateOption.java new file mode 100644 index 0000000..9dbd3c7 --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/PreferentialInterestRateOption.java @@ -0,0 +1,4 @@ +package apptive.fin.search.dto; + +public record PreferentialInterestRateOption() { +} diff --git a/src/main/java/apptive/fin/search/dto/SearchRequestDto.java b/src/main/java/apptive/fin/search/dto/SearchRequestDto.java new file mode 100644 index 0000000..1c0d4db --- /dev/null +++ b/src/main/java/apptive/fin/search/dto/SearchRequestDto.java @@ -0,0 +1,12 @@ +package apptive.fin.search.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record SearchRequestDto( + @NotNull List<@Valid OptionRequestDto> options, + @NotNull DetailedOptionsDto detailedOptions +) { +} diff --git a/src/main/java/apptive/fin/search/entity/MedianIncome.java b/src/main/java/apptive/fin/search/entity/MedianIncome.java new file mode 100644 index 0000000..17a26fb --- /dev/null +++ b/src/main/java/apptive/fin/search/entity/MedianIncome.java @@ -0,0 +1,36 @@ +package apptive.fin.search.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "median_incomes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class MedianIncome { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "year", nullable = false) + private Integer year; + + @Column(name = "household_size", nullable = false) + private Integer householdSize; + + @Column(name = "earn_percent", nullable = false) + private Integer earnPercent; + + @Column(name = "monthly_income", nullable = false) + private Integer monthlyIncome; + + @Builder + public MedianIncome(int year, int householdSize, int earnPercent, int monthlyIncome) { + this.year = year; + this.householdSize = householdSize; + this.earnPercent = earnPercent; + this.monthlyIncome = monthlyIncome; + } + + +} diff --git a/src/main/java/apptive/fin/search/repository/MedianIncomeRepository.java b/src/main/java/apptive/fin/search/repository/MedianIncomeRepository.java new file mode 100644 index 0000000..ccf7368 --- /dev/null +++ b/src/main/java/apptive/fin/search/repository/MedianIncomeRepository.java @@ -0,0 +1,12 @@ +package apptive.fin.search.repository; + + +import apptive.fin.search.dto.MedianIncomesDto; +import apptive.fin.search.entity.MedianIncome; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MedianIncomeRepository extends JpaRepository { + List findAllByYearAndHouseholdSize(int year, int householdSize); +} diff --git a/src/main/java/apptive/fin/search/service/DynamicFormService.java b/src/main/java/apptive/fin/search/service/DynamicFormService.java new file mode 100644 index 0000000..fdd8dc2 --- /dev/null +++ b/src/main/java/apptive/fin/search/service/DynamicFormService.java @@ -0,0 +1,70 @@ +package apptive.fin.search.service; + +import apptive.fin.category.service.CategoryOptionService; +import apptive.fin.search.KeywordValueEnum; +import apptive.fin.search.dto.DynamicFormResponseDto; +import apptive.fin.search.dto.OptionRequestDto; +import apptive.fin.search.dto.SearchRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +@Service +@RequiredArgsConstructor +public class DynamicFormService { + private final MedianIncomeService medianIncomeService; + private final CategoryOptionService categoryOptionService; + + public DynamicFormResponseDto calcFormCondition(SearchRequestDto searchRequestDto) { + + List keywords = optionsToKeywords(searchRequestDto.options()); + var builder = DynamicFormResponseDto.builder(); + + for (KeywordValueEnum keyword : keywords) { + switch (keyword) { + // 현재 신분이 미취업이면 연소득 기본값을 0으로, 근속연수 메뉴를 숨기도록 설정한다. + case KeywordValueEnum.STATUS_UNEMPLOYED -> builder.yearlyEarnDefault(0).showTenure(false); + // 현재 신분이 군복무이면 생년월일 상한을 39로 확장한다. + case KeywordValueEnum.STATUS_MILITARY -> builder.ageBound(39); + } + } + + // 사용자가 입력한 가구원 수에 따라 중위소득 데이터를 반환한다 + if (searchRequestDto.detailedOptions().householdSize() != null) + builder.medianIncomes( + medianIncomeService.getMedianIncomesDto( + LocalDateTime.now().getYear(), + searchRequestDto.detailedOptions().householdSize() + ) + ); + + // 주거래 은행을 선택하면 은행의 우대금리 목록을 노출한다 + if (searchRequestDto.detailedOptions().mainBanks() != null && + !searchRequestDto.detailedOptions().mainBanks().isEmpty() + ) + builder.showBankInterestRateCheckList(true); + + + // 추후 은행 상품으로 확장시 우대금리 조건 추가... +// if (searchRequestDto.detailedOptions().mainBanks() != null && +// !searchRequestDto.detailedOptions().mainBanks().isEmpty()) { +// +// } + return builder.build(); + } + + private List optionsToKeywords(List options) { + Map mapping = categoryOptionService.getOptionMap(); + + return options.stream() + .map((e)->mapping.get(e.optionId())) + .filter(Objects::nonNull) + .toList(); + } + +} diff --git a/src/main/java/apptive/fin/search/service/MedianIncomeService.java b/src/main/java/apptive/fin/search/service/MedianIncomeService.java new file mode 100644 index 0000000..f5f8c6f --- /dev/null +++ b/src/main/java/apptive/fin/search/service/MedianIncomeService.java @@ -0,0 +1,34 @@ +package apptive.fin.search.service; + +import apptive.fin.search.dto.MedianIncomesDto; +import apptive.fin.search.entity.MedianIncome; +import apptive.fin.search.repository.MedianIncomeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MedianIncomeService { + private final MedianIncomeRepository medianIncomeRepository; + + @Cacheable(value = "medianIncome", unless = "#result.isEmpty()") + public MedianIncomesDto getMedianIncomesDto(int year, int householdSize) { + List medianIncomes = medianIncomeRepository.findAllByYearAndHouseholdSize(year, householdSize); + + if (medianIncomes.isEmpty()) { + return MedianIncomesDto.builder() + .year(year) + .householdSize(householdSize) + .p60(0).p80(0).p100(0).p120(0).p150(0).p180(0) + .build(); + } + + return MedianIncomesDto.from(medianIncomes); + } + +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index ec6fcdf..132c724 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -35,6 +35,66 @@ CREATE TABLE refresh_tokens ( CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); +CREATE TABLE median_incomes ( + id BIGSERIAL PRIMARY KEY, + year INT NOT NULL CHECK (year > 0), + household_size INT NOT NULL CHECK (household_size > 0), + earn_percent INT NOT NULL CHECK (earn_percent > 0), + monthly_income INT NOT NULL CHECK (monthly_income >= 0), + + CONSTRAINT uq_year_household_size_earn_percent UNIQUE (year, household_size, earn_percent) +); + +INSERT INTO median_incomes (year, household_size, earn_percent, monthly_income) VALUES +-- 1인 가구 +(2026, 1, 60, 154), +(2026, 1, 80, 205), +(2026, 1, 100, 256), +(2026, 1, 120, 308), +(2026, 1, 150, 385), +(2026, 1, 180, 462), + +-- 2인 가구 +(2026, 2, 60, 252), +(2026, 2, 80, 336), +(2026, 2, 100, 420), +(2026, 2, 120, 504), +(2026, 2, 150, 630), +(2026, 2, 180, 756), + +-- 3인 가구 +(2026, 3, 60, 322), +(2026, 3, 80, 429), +(2026, 3, 100, 536), +(2026, 3, 120, 643), +(2026, 3, 150, 804), +(2026, 3, 180, 965), + +-- 4인 가구 +(2026, 4, 60, 390), +(2026, 4, 80, 520), +(2026, 4, 100, 649), +(2026, 4, 120, 779), +(2026, 4, 150, 974), +(2026, 4, 180, 1169), + +-- 5인 가구 +(2026, 5, 60, 453), +(2026, 5, 80, 605), +(2026, 5, 100, 756), +(2026, 5, 120, 907), +(2026, 5, 150, 1134), +(2026, 5, 180, 1360), + +-- 6인 이상 가구 +(2026, 6, 60, 513), +(2026, 6, 80, 684), +(2026, 6, 100, 856), +(2026, 6, 120, 1027), +(2026, 6, 150, 1283), +(2026, 6, 180, 1540); + + CREATE TABLE terms ( id BIGSERIAL PRIMARY KEY, code VARCHAR(100) NOT NULL UNIQUE, @@ -225,6 +285,7 @@ INSERT INTO term_versions ( '2026-03-12 00:00:00' ); + DROP TABLE IF EXISTS category_option; DROP TABLE IF EXISTS category; @@ -239,6 +300,7 @@ CREATE TABLE IF NOT EXISTS category_option ( id BIGSERIAL PRIMARY KEY, category_id BIGINT NOT NULL, value VARCHAR(100) NOT NULL, + code VARCHAR(100) NOT NULL, FOREIGN KEY (category_id) REFERENCES category(id) ); @@ -254,53 +316,52 @@ INSERT INTO category (name) VALUES -- 키워드 value 삽입 -- 1) 거주 지역 -INSERT INTO category_option (category_id, value) VALUES -(1, '서울'), -(1, '부산'), -(1, '대구'), -(1, '인천'), -(1, '광주'), -(1, '대전'), -(1, '울산'), -(1, '세종'), -(1, '경기'), -(1, '강원'), -(1, '충북'), -(1, '충남'), -(1, '전북'), -(1, '전남'), -(1, '경북'), -(1, '경남'), -(1, '제주'); +INSERT INTO category_option (category_id, value, code) VALUES + (1, '서울', 'REGION_SEOUL'), + (1, '부산', 'REGION_BUSAN'), + (1, '대구', 'REGION_DAEGU'), + (1, '인천', 'REGION_INCHEON'), + (1, '광주', 'REGION_GWANGJU'), + (1, '대전', 'REGION_DAEJEON'), + (1, '울산', 'REGION_ULSAN'), + (1, '세종', 'REGION_SEJONG'), + (1, '경기', 'REGION_GYEONGGI'), + (1, '강원', 'REGION_GANGWON'), + (1, '충북', 'REGION_CHUNGBUK'), + (1, '충남', 'REGION_CHUNGNAM'), + (1, '전북', 'REGION_JEONBUK'), + (1, '전남', 'REGION_JEONNAM'), + (1, '경북', 'REGION_GYEONGBUK'), + (1, '경남', 'REGION_GYEONGNAM'), + (1, '제주', 'REGION_JEJU'); -- 2) 현재 신분 -INSERT INTO category_option (category_id, value) VALUES -(2, '미취업'), -(2, '알바/프리랜서'), -(2, '중소기업 재직'), -(2, '군복무'); +INSERT INTO category_option (category_id, value, code) VALUES + (2, '미취업', 'STATUS_UNEMPLOYED'), + (2, '알바/프리랜서', 'STATUS_PART_TIME'), + (2, '중소기업 재직', 'STATUS_SME_WORKER'), + (2, '군복무', 'STATUS_MILITARY'); -- 3) 저축 기간 -INSERT INTO category_option (category_id, value) VALUES -(3, '5년 이상'), -(3, '2~3년'), -(3, '1년 내외'); - --- 4) 핵심 기간 -INSERT INTO category_option (category_id, value) VALUES -(4, '최고이율 중심'), -(4, '비과세'), -(4, '우대조건 간편'), -(4, '정부기여금'); +INSERT INTO category_option (category_id, value, code) VALUES + (3, '5년 이상', 'TERM_OVER_5_YEARS'), + (3, '2~3년', 'TERM_2_TO_3_YEARS'), + (3, '1년 내외', 'TERM_AROUND_1_YEAR'); + +-- 4) 핵심 혜택 +INSERT INTO category_option (category_id, value, code) VALUES + (4, '최고이율 중심', 'BENEFIT_MAX_INTEREST'), + (4, '비과세', 'BENEFIT_TAX_FREE'), + (4, '우대조건 간편', 'BENEFIT_EASY_CONDITION'), + (4, '정부기여금', 'BENEFIT_GOV_SUBSIDY'); -- 5) 상품 관심사 -INSERT INTO category_option (category_id, value) VALUES -(5, '저축'), -(5, '대출'); +INSERT INTO category_option (category_id, value, code) VALUES + (5, '저축', 'INTEREST_SAVINGS'), + (5, '대출', 'INTEREST_LOAN'); -- 6) 은행 거래 -INSERT INTO category_option (category_id, value) VALUES -(6, '첫거래 고객'), -(6, '급여이체 가능'), -(6, '카드실적 연동'); - +INSERT INTO category_option (category_id, value, code) VALUES + (6, '첫거래 고객', 'BANK_FIRST_TRANSACTION'), + (6, '급여이체 가능', 'BANK_SALARY_TRANSFER'), + (6, '카드실적 연동', 'BANK_CARD_USAGE'); diff --git a/src/test/java/apptive/fin/category/CategoryOptionServiceTest.java b/src/test/java/apptive/fin/category/CategoryOptionServiceTest.java new file mode 100644 index 0000000..a6155e4 --- /dev/null +++ b/src/test/java/apptive/fin/category/CategoryOptionServiceTest.java @@ -0,0 +1,71 @@ +package apptive.fin.category; + +import apptive.fin.category.entity.CategoryOption; +import apptive.fin.category.repository.CategoryOptionRepository; +import apptive.fin.category.service.CategoryOptionService; +import apptive.fin.search.KeywordValueEnum; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CategoryOptionServiceTest { + + @Mock + private CategoryOptionRepository categoryOptionRepository; + + @InjectMocks + private CategoryOptionService categoryOptionService; + + @Test + void 유효한_코드가_있는_옵션만_키워드맵으로_반환한다() { + when(categoryOptionRepository.findAll()).thenReturn(List.of( + createOption(1L, "STATUS_UNEMPLOYED"), + createOption(2L, "STATUS_MILITARY"), + createOption(3L, null), + createOption(4L, " "), + createOption(5L, "NOT_EXISTING_KEYWORD") + )); + + Map result = categoryOptionService.getOptionMap(); + + assertThat(result).hasSize(2); + assertThat(result).containsEntry(1L, KeywordValueEnum.STATUS_UNEMPLOYED); + assertThat(result).containsEntry(2L, KeywordValueEnum.STATUS_MILITARY); + assertThat(result).doesNotContainKeys(3L, 4L, 5L); + + verify(categoryOptionRepository).findAll(); + } + + @Test + void 매핑가능한_코드가_없으면_빈_맵을_반환한다() { + when(categoryOptionRepository.findAll()).thenReturn(List.of( + createOption(1L, null), + createOption(2L, ""), + createOption(3L, "INVALID") + )); + + Map result = categoryOptionService.getOptionMap(); + + assertThat(result).isEmpty(); + + verify(categoryOptionRepository).findAll(); + } + + private CategoryOption createOption(Long id, String code) { + CategoryOption option = new CategoryOption(); + ReflectionTestUtils.setField(option, "id", id); + ReflectionTestUtils.setField(option, "code", code); + return option; + } +} diff --git a/src/test/java/apptive/fin/search/DynamicFormServiceTest.java b/src/test/java/apptive/fin/search/DynamicFormServiceTest.java new file mode 100644 index 0000000..424ca1f --- /dev/null +++ b/src/test/java/apptive/fin/search/DynamicFormServiceTest.java @@ -0,0 +1,244 @@ +package apptive.fin.search; + +import apptive.fin.category.service.CategoryOptionService; +import apptive.fin.search.dto.DetailedOptionsDto; +import apptive.fin.search.dto.DynamicFormResponseDto; +import apptive.fin.search.dto.MedianIncomesDto; +import apptive.fin.search.dto.OptionRequestDto; +import apptive.fin.search.dto.SearchRequestDto; +import apptive.fin.search.service.DynamicFormService; +import apptive.fin.search.service.MedianIncomeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DynamicFormServiceTest { + + @Mock + private MedianIncomeService medianIncomeService; + + @Mock + private CategoryOptionService categoryOptionService; + + @InjectMocks + private DynamicFormService dynamicFormService; + + @Test + void 미취업_옵션이면_연소득_기본값을_0으로_근속기간을_숨김으로_설정한다() { + SearchRequestDto request = createRequest( + List.of(new OptionRequestDto(1L, 10L)), + null, + List.of() + ); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of( + 10L, KeywordValueEnum.STATUS_UNEMPLOYED + )); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.yearlyEarnDefault()).isEqualTo(0); + assertThat(result.showTenure()).isEqualTo(false); + assertThat(result.showTenure()).isFalse(); + assertThat(result.ageBound()).isEqualTo(34); + assertThat(result.showBankInterestRateCheckList()).isFalse(); + assertThat(result.medianIncomes()).isNull(); + assertThat(result.preferentialInterestRateOptions()).isEmpty(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 군복무_옵션이면_나이상한을_39로_설정한다() { + SearchRequestDto request = createRequest( + List.of(new OptionRequestDto(1L, 20L)), + null, + List.of() + ); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of( + 20L, KeywordValueEnum.STATUS_MILITARY + )); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.ageBound()).isEqualTo(39); + assertThat(result.yearlyEarnDefault()).isNull(); + assertThat(result.showBankInterestRateCheckList()).isFalse(); + assertThat(result.medianIncomes()).isNull(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 가구원수가_있으면_현재연도와_가구원수로_중위소득을_조회한다() { + int currentYear = LocalDateTime.now().getYear(); + MedianIncomesDto medianIncomesDto = createMedianIncomesDto(currentYear, 3); + SearchRequestDto request = createRequest(List.of(), 3, List.of()); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + when(medianIncomeService.getMedianIncomesDto(currentYear, 3)).thenReturn(medianIncomesDto); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.medianIncomes()).isEqualTo(medianIncomesDto); + assertThat(result.showBankInterestRateCheckList()).isFalse(); + + verify(medianIncomeService).getMedianIncomesDto(currentYear, 3); + } + + @Test + void 가구원수가_없으면_중위소득을_조회하지_않는다() { + SearchRequestDto request = createRequest(List.of(), null, List.of()); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.medianIncomes()).isNull(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 주거래은행이_있으면_우대금리_체크리스트를_노출한다() { + SearchRequestDto request = createRequest(List.of(), null, List.of("KB")); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.showBankInterestRateCheckList()).isTrue(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 주거래은행이_null이면_우대금리_체크리스트를_노출하지_않는다() { + SearchRequestDto request = createRequest(List.of(), null, null); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.showBankInterestRateCheckList()).isFalse(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 키워드로_매핑되지_않는_옵션은_무시한다() { + SearchRequestDto request = createRequest( + List.of(new OptionRequestDto(1L, 999L)), + null, + List.of() + ); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.showTenure()).isTrue(); + assertThat(result.ageBound()).isEqualTo(34); + assertThat(result.yearlyEarnDefault()).isNull(); + assertThat(result.showBankInterestRateCheckList()).isFalse(); + assertThat(result.medianIncomes()).isNull(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 추가조건이_없으면_기본값으로_응답한다() { + SearchRequestDto request = createRequest(List.of(), null, List.of()); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of()); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.showTenure()).isTrue(); + assertThat(result.ageBound()).isEqualTo(34); + assertThat(result.yearlyEarnDefault()).isNull(); + assertThat(result.showBankInterestRateCheckList()).isFalse(); + assertThat(result.medianIncomes()).isNull(); + assertThat(result.preferentialInterestRateOptions()).isEmpty(); + + verifyNoInteractions(medianIncomeService); + } + + @Test + void 복합조건이_들어오면_여러_분기를_동시에_반영한다() { + int currentYear = LocalDateTime.now().getYear(); + MedianIncomesDto medianIncomesDto = createMedianIncomesDto(currentYear, 3); + SearchRequestDto request = createRequest( + List.of( + new OptionRequestDto(1L, 10L), + new OptionRequestDto(2L, 20L) + ), + 3, + List.of("KB") + ); + + when(categoryOptionService.getOptionMap()).thenReturn(Map.of( + 10L, KeywordValueEnum.STATUS_UNEMPLOYED, + 20L, KeywordValueEnum.STATUS_MILITARY + )); + when(medianIncomeService.getMedianIncomesDto(currentYear, 3)).thenReturn(medianIncomesDto); + + DynamicFormResponseDto result = dynamicFormService.calcFormCondition(request); + + assertThat(result.yearlyEarnDefault()).isEqualTo(0); + assertThat(result.ageBound()).isEqualTo(39); + assertThat(result.showBankInterestRateCheckList()).isTrue(); + assertThat(result.medianIncomes()).isEqualTo(medianIncomesDto); + assertThat(result.showTenure()).isFalse(); + assertThat(result.preferentialInterestRateOptions()).isEmpty(); + + verify(medianIncomeService).getMedianIncomesDto(currentYear, 3); + } + + private SearchRequestDto createRequest(List options, Integer householdSize, List mainBanks) { + return new SearchRequestDto(options, createDetailedOptions(householdSize, mainBanks)); + } + + private DetailedOptionsDto createDetailedOptions(Integer householdSize, List mainBanks) { + return new DetailedOptionsDto( + LocalDate.of(2000, 1, 1), + 30_000_000L, + householdSize, + null, + null, + null, + null, + null, + null, + mainBanks, + List.of() + ); + } + + private MedianIncomesDto createMedianIncomesDto(int year, int householdSize) { + return MedianIncomesDto.builder() + .year(year) + .householdSize(householdSize) + .p60(200) + .p80(300) + .p100(400) + .p120(500) + .p150(600) + .p180(700) + .build(); + } +} diff --git a/src/test/java/apptive/fin/search/MedianIncomeServiceTest.java b/src/test/java/apptive/fin/search/MedianIncomeServiceTest.java new file mode 100644 index 0000000..715c5ee --- /dev/null +++ b/src/test/java/apptive/fin/search/MedianIncomeServiceTest.java @@ -0,0 +1,81 @@ +package apptive.fin.search; + +import apptive.fin.search.dto.MedianIncomesDto; +import apptive.fin.search.entity.MedianIncome; +import apptive.fin.search.repository.MedianIncomeRepository; +import apptive.fin.search.service.MedianIncomeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MedianIncomeServiceTest { + + @Mock + private MedianIncomeRepository medianIncomeRepository; + + @InjectMocks + private MedianIncomeService medianIncomeService; + + @Test + void 중위소득_데이터가_없으면_0으로_채운_dto를_반환한다() { + when(medianIncomeRepository.findAllByYearAndHouseholdSize(2026, 2)).thenReturn(List.of()); + + MedianIncomesDto result = medianIncomeService.getMedianIncomesDto(2026, 2); + + assertThat(result.year()).isEqualTo(2026); + assertThat(result.householdSize()).isEqualTo(2); + assertThat(result.p60()).isZero(); + assertThat(result.p80()).isZero(); + assertThat(result.p100()).isZero(); + assertThat(result.p120()).isZero(); + assertThat(result.p150()).isZero(); + assertThat(result.p180()).isZero(); + assertThat(result.isEmpty()).isTrue(); + + verify(medianIncomeRepository).findAllByYearAndHouseholdSize(2026, 2); + } + + @Test + void 중위소득_데이터가_있으면_비율별_금액을_dto로_변환한다() { + when(medianIncomeRepository.findAllByYearAndHouseholdSize(2026, 3)).thenReturn(List.of( + createMedianIncome(2026, 3, 60, 1_200_000), + createMedianIncome(2026, 3, 80, 1_600_000), + createMedianIncome(2026, 3, 100, 2_000_000), + createMedianIncome(2026, 3, 120, 2_400_000), + createMedianIncome(2026, 3, 150, 3_000_000), + createMedianIncome(2026, 3, 180, 3_600_000) + )); + + MedianIncomesDto result = medianIncomeService.getMedianIncomesDto(2026, 3); + + assertThat(result.year()).isEqualTo(2026); + assertThat(result.householdSize()).isEqualTo(3); + assertThat(result.p60()).isEqualTo(1_200_000); + assertThat(result.p80()).isEqualTo(1_600_000); + assertThat(result.p100()).isEqualTo(2_000_000); + assertThat(result.p120()).isEqualTo(2_400_000); + assertThat(result.p150()).isEqualTo(3_000_000); + assertThat(result.p180()).isEqualTo(3_600_000); + assertThat(result.isEmpty()).isFalse(); + + verify(medianIncomeRepository).findAllByYearAndHouseholdSize(2026, 3); + } + + private MedianIncome createMedianIncome(int year, int householdSize, int earnPercent, int monthlyIncome) { + return MedianIncome.builder() + .year(year) + .householdSize(householdSize) + .earnPercent(earnPercent) + .monthlyIncome(monthlyIncome) + .build(); + } +}