diff --git a/build.gradle b/build.gradle index 9c903ca..10dd78c 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ sourceSets { srcDir 'src/devtools/java' } resources { -// srcDir 'src/devtools/resources' + srcDir 'src/devtools/resources' } compileClasspath += sourceSets.main.output + configurations.runtimeClasspath runtimeClasspath += output + compileClasspath + sourceSets.test.resources @@ -57,7 +57,14 @@ tasks.named('test') { tasks.register('ontongYouthFilterReport', JavaExec) { group = 'devtools' - description = 'Writes Ontong Youth financial products filtered from src/test/resources/product_raw.json.' + description = 'Writes Ontong Youth financial products filtered from src/devtools/resources/product_raw.json.' classpath = sourceSets.devtools.runtimeClasspath mainClass = 'apptive.fin.apicollector.devtools.report.OntongYouthFilterReport' } + +tasks.register('keywordExtractorReport', JavaExec) { + group = 'devtools' + description = 'Writes an exploratory KeywordExtractor report from src/devtools/resources/product_raw.json.' + classpath = sourceSets.devtools.runtimeClasspath + mainClass = 'apptive.fin.apicollector.devtools.report.KeywordExtractorReport' +} diff --git a/src/devtools/java/apptive/fin/apicollector/devtools/report/KeywordExtractorReport.java b/src/devtools/java/apptive/fin/apicollector/devtools/report/KeywordExtractorReport.java new file mode 100644 index 0000000..f6405fb --- /dev/null +++ b/src/devtools/java/apptive/fin/apicollector/devtools/report/KeywordExtractorReport.java @@ -0,0 +1,361 @@ +package apptive.fin.apicollector.devtools.report; + +import apptive.fin.apicollector.Mode; +import apptive.fin.apicollector.Source; +import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.devtools.support.DevtoolPaths; +import apptive.fin.apicollector.normalize.OntongYouthPolicyClassifier; +import apptive.fin.apicollector.normalize.OntongYouthProductNormalizer; +import apptive.fin.apicollector.normalize.ProductClassification; +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; +import apptive.fin.apicollector.normalize.extractor.MonthlyLimitExtractor; +import apptive.fin.apicollector.normalize.extractor.keywords.BankKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.BenefitKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.InterestKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.RegionKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.StatusKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.TermKeywordRecognizer; +import apptive.fin.apicollector.product.KeywordValueEnum; +import apptive.fin.apicollector.raw.ProductRaw; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class KeywordExtractorReport { + + private static final Path REPORT_PATH = DevtoolPaths.REPORTS_DIR.resolve( + "keyword-extractor-report.jsonl" + ); + private static final Set REGION_KEYWORDS = EnumSet.of( + KeywordValueEnum.REGION_SEOUL, + KeywordValueEnum.REGION_BUSAN, + KeywordValueEnum.REGION_DAEGU, + KeywordValueEnum.REGION_INCHEON, + KeywordValueEnum.REGION_GWANGJU, + KeywordValueEnum.REGION_DAEJEON, + KeywordValueEnum.REGION_ULSAN, + KeywordValueEnum.REGION_SEJONG, + KeywordValueEnum.REGION_GYEONGGI, + KeywordValueEnum.REGION_GANGWON, + KeywordValueEnum.REGION_CHUNGBUK, + KeywordValueEnum.REGION_CHUNGNAM, + KeywordValueEnum.REGION_JEONBUK, + KeywordValueEnum.REGION_JEONNAM, + KeywordValueEnum.REGION_GYEONGBUK, + KeywordValueEnum.REGION_GYEONGNAM, + KeywordValueEnum.REGION_JEJU + ); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final OntongYouthProductNormalizer normalizer = new OntongYouthProductNormalizer( + objectMapper, + properties(), + new OntongYouthPolicyClassifier(), + new MonthlyLimitExtractor(), + keywordExtractor() + ); + + public static void main(String[] args) throws IOException { + KeywordExtractorReport report = new KeywordExtractorReport(); + ReportResult result = report.analyze(); + report.write(result); + System.out.printf( + "Wrote KeywordExtractor report to %s. saved=%d, skipped=%d, failed=%d%n", + REPORT_PATH, + result.summary.savedProducts(), + result.summary.skippedProducts(), + result.summary.failedProducts() + ); + } + + private ReportResult analyze() throws IOException { + List rawProducts = loadOntongYouthRawProducts(); + List lines = new ArrayList<>(); + Map keywordCounts = new EnumMap<>(KeywordValueEnum.class); + + int savedProducts = 0; + int skippedProducts = 0; + int failedProducts = 0; + int propertiesWithAllRegionFallback = 0; + int propertiesWithoutNonRegionKeywords = 0; + + for (LoadedRawProduct loaded : rawProducts) { + try { + ProductDraft draft = normalizer.normalize(loaded.productRaw()); + if (!draft.shouldSaveProduct()) { + skippedProducts++; + lines.add(skippedLine(loaded, draft.classification())); + continue; + } + + savedProducts++; + for (int propertyIndex = 0; propertyIndex < draft.properties().size(); propertyIndex++) { + ProductPropertyDraft property = draft.properties().get(propertyIndex); + List keywords = sortedKeywords(property.keywords()); + List nonRegionKeywords = keywords.stream() + .filter(keyword -> !REGION_KEYWORDS.contains(keyword)) + .toList(); + boolean allRegionFallback = keywords.containsAll(REGION_KEYWORDS); + boolean noNonRegionKeywords = nonRegionKeywords.isEmpty(); + + if (allRegionFallback) { + propertiesWithAllRegionFallback++; + } + if (noNonRegionKeywords) { + propertiesWithoutNonRegionKeywords++; + } + for (KeywordValueEnum keyword : keywords) { + keywordCounts.merge(keyword, 1, Integer::sum); + } + + lines.add(productLine( + loaded, + draft, + property, + propertyIndex, + allRegionFallback, + noNonRegionKeywords, + keywords, + nonRegionKeywords + )); + } + } + catch (Exception e) { + failedProducts++; + lines.add(new ErrorLine( + "error", + loaded.rawId(), + loaded.productRaw().getExternalId(), + rawText(loaded.raw(), "plcyNm"), + e.getClass().getSimpleName(), + e.getMessage() + )); + } + } + + SummaryLine summary = new SummaryLine( + "summary", + rawProducts.size(), + savedProducts, + skippedProducts, + failedProducts, + propertiesWithAllRegionFallback, + propertiesWithoutNonRegionKeywords, + keywordCountNames(keywordCounts) + ); + return new ReportResult(summary, lines); + } + + private ProductLine productLine( + LoadedRawProduct loaded, + ProductDraft draft, + ProductPropertyDraft property, + int propertyIndex, + boolean allRegionFallback, + boolean noNonRegionKeywords, + List keywords, + List nonRegionKeywords + ) { + JsonNode raw = loaded.raw(); + return new ProductLine( + "product", + loaded.rawId(), + loaded.productRaw().getExternalId(), + draft.productCode(), + draft.productName(), + property.providerName(), + propertyIndex, + rawText(raw, "plcyKywdNm"), + rawText(raw, "lclsfNm"), + rawText(raw, "mclsfNm"), + rawText(raw, "zipCd"), + allRegionFallback, + noNonRegionKeywords, + keywordNames(keywords), + keywordNames(nonRegionKeywords) + ); + } + + private SkippedLine skippedLine(LoadedRawProduct loaded, ProductClassification classification) { + return new SkippedLine( + "skipped", + loaded.rawId(), + loaded.productRaw().getExternalId(), + rawText(loaded.raw(), "plcyNm"), + classification + ); + } + + private void write(ReportResult result) throws IOException { + Files.createDirectories(REPORT_PATH.getParent()); + Files.writeString(REPORT_PATH, toJsonLines(result)); + } + + private String toJsonLines(ReportResult result) throws IOException { + StringBuilder builder = new StringBuilder(); + builder.append(objectMapper.writeValueAsString(result.summary())).append('\n'); + for (Object line : result.lines()) { + builder.append(objectMapper.writeValueAsString(line)).append('\n'); + } + return builder.toString(); + } + + private List loadOntongYouthRawProducts() throws IOException { + JsonNode rows; + try (InputStream inputStream = getClass().getResourceAsStream("/product_raw.json")) { + if (inputStream == null) { + throw new IllegalStateException("product_raw.json not found on classpath"); + } + rows = objectMapper.readTree(inputStream); + } + + List products = new ArrayList<>(); + for (JsonNode row : rows) { + if (!isOntongYouthSource(row.path("source").asString())) { + continue; + } + + String rawJson = row.path("raw_json").asString(); + JsonNode raw = objectMapper.readTree(rawJson); + ProductRaw productRaw = new ProductRaw( + Source.ONTONG, + row.path("external_id").asString(), + row.path("content_hash").asString(), + rawJson + ); + products.add(new LoadedRawProduct(row.path("id").asString(), productRaw, raw)); + } + return products; + } + + private boolean isOntongYouthSource(String source) { + return Source.ONTONG.name().equals(source) || "ONTONG_YOUTH".equals(source); + } + + private static KeywordExtractor keywordExtractor() { + return new KeywordExtractor(List.of( + new BenefitKeywordRecognizer(), + new BankKeywordRecognizer(), + new InterestKeywordRecognizer(), + new RegionKeywordRecognizer(), + new StatusKeywordRecognizer(), + new TermKeywordRecognizer() + )); + } + + private CollectorProperties properties() { + return new CollectorProperties( + true, + Source.ALL, + Mode.NORMALIZE_ONLY, + 3, + 500, + 7, + new CollectorProperties.OntongYouth("http://localhost", "key", 100), + new CollectorProperties.Fss("http://localhost", "key", 100) + ); + } + + private String rawText(JsonNode raw, String fieldName) { + String value = raw.path(fieldName).asString(null); + if (value == null || value.isBlank()) { + return null; + } + return value; + } + + private List sortedKeywords(List keywords) { + return keywords.stream() + .distinct() + .sorted(Comparator.comparing(KeywordValueEnum::name)) + .toList(); + } + + private List keywordNames(List keywords) { + return keywords.stream() + .map(KeywordValueEnum::name) + .toList(); + } + + private Map keywordCountNames(Map keywordCounts) { + Map counts = new LinkedHashMap<>(); + for (KeywordValueEnum keyword : KeywordValueEnum.values()) { + Integer count = keywordCounts.get(keyword); + if (count != null) { + counts.put(keyword.name(), count); + } + } + return counts; + } + + private record ReportResult( + SummaryLine summary, + List lines + ) {} + + private record LoadedRawProduct( + String rawId, + ProductRaw productRaw, + JsonNode raw + ) {} + + private record SummaryLine( + String type, + int ontongYouthRawProducts, + int savedProducts, + int skippedProducts, + int failedProducts, + int propertiesWithAllRegionFallback, + int propertiesWithoutNonRegionKeywords, + Map keywordCounts + ) {} + + private record ProductLine( + String type, + String rawId, + String externalId, + String productCode, + String productName, + String providerName, + int propertyIndex, + String rawPolicyKeywords, + String rawLargeCategory, + String rawMiddleCategory, + String rawZipCodes, + boolean allRegionFallback, + boolean noNonRegionKeywords, + List keywords, + List nonRegionKeywords + ) {} + + private record SkippedLine( + String type, + String rawId, + String externalId, + String rawProductName, + ProductClassification classification + ) {} + + private record ErrorLine( + String type, + String rawId, + String externalId, + String rawProductName, + String errorType, + String errorMessage + ) {} +} diff --git a/src/devtools/java/apptive/fin/apicollector/devtools/report/OntongYouthFilterReport.java b/src/devtools/java/apptive/fin/apicollector/devtools/report/OntongYouthFilterReport.java index df5caae..6d4f2a9 100644 --- a/src/devtools/java/apptive/fin/apicollector/devtools/report/OntongYouthFilterReport.java +++ b/src/devtools/java/apptive/fin/apicollector/devtools/report/OntongYouthFilterReport.java @@ -4,10 +4,17 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; import apptive.fin.apicollector.devtools.support.DevtoolPaths; -import apptive.fin.apicollector.normalize.MonthlyLimitExtractor; import apptive.fin.apicollector.normalize.OntongYouthPolicyClassifier; import apptive.fin.apicollector.normalize.OntongYouthProductNormalizer; import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; +import apptive.fin.apicollector.normalize.extractor.MonthlyLimitExtractor; +import apptive.fin.apicollector.normalize.extractor.keywords.BankKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.BenefitKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.InterestKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.RegionKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.StatusKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.TermKeywordRecognizer; import apptive.fin.apicollector.raw.ProductRaw; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -30,7 +37,8 @@ public class OntongYouthFilterReport { objectMapper, properties(), new OntongYouthPolicyClassifier(), - new MonthlyLimitExtractor() + new MonthlyLimitExtractor(), + keywordExtractor() ); public static void main(String[] args) throws IOException { @@ -51,7 +59,7 @@ private List filterFinancialProducts() throws IOException { .map(draft -> new FilteredProduct( draft.productCode(), draft.productName(), - draft.providerName() + draft.properties().isEmpty() ? null : draft.properties().getFirst().providerName() )) .toList(); } @@ -116,9 +124,9 @@ private List loadOntongYouthRawProducts() throws IOException { List products = new ArrayList<>(); for (JsonNode row : rows) { - if (Source.ONTONG_YOUTH.name().equals(row.path("source").asString())) { + if (isOntongYouthSource(row.path("source").asString())) { products.add(new ProductRaw( - Source.ONTONG_YOUTH, + Source.ONTONG, row.path("external_id").asString(), row.path("content_hash").asString(), row.path("raw_json").asString() @@ -128,6 +136,21 @@ private List loadOntongYouthRawProducts() throws IOException { return products; } + private boolean isOntongYouthSource(String source) { + return Source.ONTONG.name().equals(source) || "ONTONG_YOUTH".equals(source); + } + + private static KeywordExtractor keywordExtractor() { + return new KeywordExtractor(List.of( + new BenefitKeywordRecognizer(), + new BankKeywordRecognizer(), + new InterestKeywordRecognizer(), + new RegionKeywordRecognizer(), + new StatusKeywordRecognizer(), + new TermKeywordRecognizer() + )); + } + private CollectorProperties properties() { return new CollectorProperties( true, diff --git a/src/main/java/apptive/fin/apicollector/Source.java b/src/main/java/apptive/fin/apicollector/Source.java index 15fd363..19fc96c 100644 --- a/src/main/java/apptive/fin/apicollector/Source.java +++ b/src/main/java/apptive/fin/apicollector/Source.java @@ -2,6 +2,6 @@ public enum Source { ALL, - ONTONG_YOUTH, // 온통청년 API + ONTONG, // 온통청년 API FSS // 금감원 API } diff --git a/src/main/java/apptive/fin/apicollector/batch/ProductItemWriter.java b/src/main/java/apptive/fin/apicollector/batch/ProductItemWriter.java index 8ba62ca..f824a70 100644 --- a/src/main/java/apptive/fin/apicollector/batch/ProductItemWriter.java +++ b/src/main/java/apptive/fin/apicollector/batch/ProductItemWriter.java @@ -1,7 +1,7 @@ package apptive.fin.apicollector.batch; import apptive.fin.apicollector.normalize.ProductDraft; -import apptive.fin.apicollector.product.ProductSyncService; +import apptive.fin.apicollector.product.service.ProductSyncService; import lombok.RequiredArgsConstructor; import org.springframework.batch.infrastructure.item.Chunk; import org.springframework.batch.infrastructure.item.ItemWriter; diff --git a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java index ab80cf5..f2480a1 100644 --- a/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java +++ b/src/main/java/apptive/fin/apicollector/batch/SourceDecider.java @@ -21,7 +21,7 @@ public FlowExecutionStatus decide( return switch (properties.source()) { case ALL -> new FlowExecutionStatus("ALL"); case FSS -> new FlowExecutionStatus("FSS"); - case ONTONG_YOUTH -> new FlowExecutionStatus("ONTONG_YOUTH"); + case ONTONG -> new FlowExecutionStatus("ONTONG_YOUTH"); }; } } diff --git a/src/main/java/apptive/fin/apicollector/config/ItemReaderConfig.java b/src/main/java/apptive/fin/apicollector/config/ItemReaderConfig.java index 3c72a18..02b752a 100644 --- a/src/main/java/apptive/fin/apicollector/config/ItemReaderConfig.java +++ b/src/main/java/apptive/fin/apicollector/config/ItemReaderConfig.java @@ -33,7 +33,7 @@ public RawProductItemReader ontongRawProductItemReader( return new RawProductItemReader( repository, properties, - Source.ONTONG_YOUTH + Source.ONTONG ); } } diff --git a/src/main/java/apptive/fin/apicollector/normalize/AbstractProductNormalizer.java b/src/main/java/apptive/fin/apicollector/normalize/AbstractProductNormalizer.java index aea4b51..3b1aec9 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/AbstractProductNormalizer.java +++ b/src/main/java/apptive/fin/apicollector/normalize/AbstractProductNormalizer.java @@ -1,6 +1,9 @@ package apptive.fin.apicollector.normalize; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; import apptive.fin.apicollector.product.KeywordValueEnum; +import apptive.fin.apicollector.product.entity.Product; +import lombok.RequiredArgsConstructor; import tools.jackson.databind.JsonNode; import java.math.BigDecimal; @@ -11,6 +14,27 @@ abstract class AbstractProductNormalizer { + protected ProductDraft extractKeywords( + KeywordExtractor extractor, + ProductDraft draft + + ) { + List productPropertyDrafts = new ArrayList<>(); + for (ProductPropertyDraft property : draft.properties()) { + List keywords = extractor.extract(draft, property); + + productPropertyDrafts.add( + property + .toBuilder() + .keywords(keywords) + .build() + ); + } + return draft.toBuilder() + .properties(productPropertyDrafts) + .build(); + } + protected String text(JsonNode node, String fieldName) { JsonNode value = node.path(fieldName); if (value == null || value.isMissingNode() || value.isNull()) { @@ -94,6 +118,7 @@ protected String blankToNull(String value) { return trimmed.isEmpty() ? null : trimmed; } + protected List keywordsFromText(String... values) { Set keywords = EnumSet.noneOf(KeywordValueEnum.class); List nonBlankValues = new ArrayList<>(); diff --git a/src/main/java/apptive/fin/apicollector/normalize/FssProductNormalizer.java b/src/main/java/apptive/fin/apicollector/normalize/FssProductNormalizer.java index a5308eb..d2220ca 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/FssProductNormalizer.java +++ b/src/main/java/apptive/fin/apicollector/normalize/FssProductNormalizer.java @@ -2,6 +2,7 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.ProductRaw; import lombok.RequiredArgsConstructor; @@ -9,9 +10,7 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; @Component @@ -20,6 +19,7 @@ public class FssProductNormalizer extends AbstractProductNormalizer implements P private final ObjectMapper objectMapper; private final CollectorProperties properties; + private final KeywordExtractor keywordExtractor; @Override public Source source() { @@ -30,37 +30,25 @@ public Source source() { public ProductDraft normalize(ProductRaw rawProduct) { JsonNode raw = read(rawProduct); JsonNode base = raw.path("base"); - List options = options(raw.path("options")); String content = joinContent(base, "join_way", "mtrt_int", "spcl_cnd", "join_member", "etc_note"); String productName = firstText(base, "fin_prdt_nm"); + List propertyDrafts = properties(raw, base, productName, content); - return ProductDraft.builder() - .rawId(rawProduct.getId()) - .rawSource(rawProduct.getSource()) - .normalizerVersion(properties.normalizerVersion()) - .classification(ProductClassification.FINANCIAL_PRODUCT) - .saveProduct(true) - .sourceCode(Source.FSS.name()) - .providerCode(firstText(base, "fin_co_no", "kor_co_nm")) - .providerName(firstText(base, "kor_co_nm", "fin_co_no")) - .type(ProductType.BANK) - .productCode(rawProduct.getExternalId()) - .productName(required(productName, rawProduct)) - .content(content) - .baseRate(max(options, ProductOptionDraft::intrRate)) - .maxRate(max(options, ProductOptionDraft::intrRate2)) - .maxMonthlyLimit(longValue(base, "max_limit")) - .minTenureMonths(maxSaveTerm(options)) - .requiresHomeless(false) - .requiresHouseholder(false) - .options(options) - .keywords(keywordsFromText( - text(raw, "productType"), - text(raw, "financialGroupName"), - productName, - content - )) - .build(); + var draft = ProductDraft.builder() + .rawId(rawProduct.getId()) + .rawSource(rawProduct.getSource()) + .normalizerVersion(properties.normalizerVersion()) + .classification(ProductClassification.FINANCIAL_PRODUCT) + .saveProduct(true) + .sourceCode(Source.FSS.name()) + .type(rawProduct.getType()) + .productCode(rawProduct.getExternalId()) + .productName(required(productName, rawProduct)) + .content(content) + .properties(propertyDrafts) + .build(); + + return extractKeywords(keywordExtractor, draft); } private JsonNode read(ProductRaw rawProduct) { @@ -72,41 +60,51 @@ private JsonNode read(ProductRaw rawProduct) { } } - private List options(JsonNode optionsNode) { - if (optionsNode == null || !optionsNode.isArray()) { - return List.of(); + private List properties( + JsonNode raw, + JsonNode base, + String productName, + String content + ) { + List keywords = keywordsFromText( + text(raw, "productType"), + text(raw, "financialGroupName"), + productName, + content + ); + String providerCode = firstText(base, "fin_co_no", "kor_co_nm"); + String providerName = firstText(base, "kor_co_nm", "fin_co_no"); + Long maxMonthlyLimit = longValue(base, "max_limit"); + JsonNode optionsNode = raw.path("options"); + if (optionsNode == null || !optionsNode.isArray() || optionsNode.isEmpty()) { + return List.of(ProductPropertyDraft.builder() + .providerCode(providerCode) + .providerName(providerName) + .maxMonthlyLimit(maxMonthlyLimit) + .requiresHomeless(false) + .requiresHouseholder(false) + .keywords(keywords) + .build()); } - List options = new ArrayList<>(); + List properties = new ArrayList<>(); for (JsonNode option : optionsNode) { - options.add(new ProductOptionDraft( - firstText(option, "intr_rate_type"), - firstText(option, "intr_rate_type_nm"), - integer(option, "save_trm"), - decimal(option, "intr_rate"), - decimal(option, "intr_rate2") - )); + properties.add(ProductPropertyDraft.builder() + .providerCode(providerCode) + .providerName(providerName) + .intrRateType(firstText(option, "intr_rate_type")) + .intrRateTypeName(firstText(option, "intr_rate_type_nm")) + .saveTerm(integer(option, "save_trm")) + .baseRate(decimal(option, "intr_rate")) + .maxRate(decimal(option, "intr_rate2")) + .maxMonthlyLimit(maxMonthlyLimit) + .minTenureMonths(integer(option, "save_trm")) + .requiresHomeless(false) + .requiresHouseholder(false) + .keywords(keywords) + .build()); } - return options; - } - - private BigDecimal max( - List options, - java.util.function.Function getter - ) { - return options.stream() - .map(getter) - .filter(value -> value != null) - .max(Comparator.naturalOrder()) - .orElse(null); - } - - private Integer maxSaveTerm(List options) { - return options.stream() - .map(ProductOptionDraft::saveTerm) - .filter(value -> value != null) - .max(Comparator.naturalOrder()) - .orElse(null); + return properties; } private String required(String productName, ProductRaw rawProduct) { diff --git a/src/main/java/apptive/fin/apicollector/normalize/OntongYouthPolicyClassifier.java b/src/main/java/apptive/fin/apicollector/normalize/OntongYouthPolicyClassifier.java index 2183a40..96c257a 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/OntongYouthPolicyClassifier.java +++ b/src/main/java/apptive/fin/apicollector/normalize/OntongYouthPolicyClassifier.java @@ -4,7 +4,6 @@ import org.springframework.util.StringUtils; import tools.jackson.databind.JsonNode; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -12,29 +11,8 @@ public class OntongYouthPolicyClassifier extends AbstractProductNormalizer { private static final String FINANCE_CATEGORY = "취약계층 및 금융지원"; - private static final String SUBSIDY_KEYWORD = "보조금"; private static final String LOAN_KEYWORD = "대출"; private static final List LOAN_METHOD_CODES = List.of("42003", "42007"); -// private static final List FINANCIAL_KEYWORDS = List.of( -// "저축", -// "적금", -// "예금", -// "자산형성", -// "매칭", -// "장려금", -// "기여금", -// "적립", -// "내일채움", -// "내일저축", -// "미래적금", -// "납입", -// "목돈", -// "비과세", -// "청약", -// "주택드림", -// "분양", -// "통장" -// ); private static final Map FINANCIAL_KEY_MAP = Map.ofEntries( Map.entry("통장", 60), @@ -58,21 +36,15 @@ public class OntongYouthPolicyClassifier extends AbstractProductNormalizer { Map.entry("분양", 1) ); - - - public ProductClassification classify(JsonNode policy) { -// if (!isFinanceCandidate(policy)) { -// return ProductClassification.EXCLUDED; -// } + if (!isFinanceCandidate(policy)) { + return ProductClassification.EXCLUDED; + } if (isLoan(policy)) { return ProductClassification.LOAN_EXCLUDED; } -// if (hasFinancialKeyword(policy)) { -// return ProductClassification.FINANCIAL_PRODUCT; -// } if (financeScore(policy) >= 6) { return ProductClassification.FINANCIAL_PRODUCT; } @@ -82,10 +54,7 @@ public ProductClassification classify(JsonNode policy) { private boolean isFinanceCandidate(JsonNode policy) { String category = text(policy, "mclsfNm"); - String keywords = text(policy, "plcyKywdNm"); - return FINANCE_CATEGORY.equals(category); -// || contains(keywords, SUBSIDY_KEYWORD); } private boolean isLoan(JsonNode policy) { @@ -96,28 +65,32 @@ private boolean isLoan(JsonNode policy) { || LOAN_METHOD_CODES.stream().anyMatch(code -> hasCode(methodCode, code)); } -// private boolean hasFinancialKeyword(JsonNode policy) { -// String supportContent = text(policy, "plcySprtCn"); -// return FINANCIAL_KEYWORDS.stream().anyMatch(keyword -> contains(supportContent, keyword)); -// } - private int financeScore(JsonNode policy) { - Map keywordsMap = new HashMap<>(); - String supportContent = text(policy, "plcySprtCn"); - String category = text(policy, "mclsfNm"); - String keywords = text(policy, "plcyKywdNm"); - String name = text(policy, "plcyNm"); - -// for (String keyword : LOAN_METHOD_CODES) { -// keywordsMap.put(keyword, StringUtils.countOccurrencesOf(supportContent, keyword)); -// } - return FINANCIAL_KEY_MAP - .keySet() - .stream() - .map((keyword)->StringUtils.countOccurrencesOf(name, keyword) * FINANCIAL_KEY_MAP.get(keyword)) + String value = String.join(" ", + defaultString(text(policy, "plcyNm")), + defaultString(text(policy, "plcySprtCn")), + defaultString(text(policy, "mclsfNm")), + defaultString(text(policy, "plcyKywdNm")) + ); + + int keywordScore = FINANCIAL_KEY_MAP.entrySet().stream() + .map(entry -> StringUtils.countOccurrencesOf(value, entry.getKey()) * entry.getValue()) .reduce(Integer::sum) .orElse(0); + if (hasMonthlyAmount(value)) { + return Math.max(keywordScore, 6); + } + + return keywordScore; + } + + private boolean hasMonthlyAmount(String value) { + return value.matches(".*\\d+.*") && value.contains("만원"); + } + + private String defaultString(String value) { + return value == null ? "" : value; } private boolean hasCode(String rawCode, String targetCode) { diff --git a/src/main/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizer.java b/src/main/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizer.java index 048ebde..81aea32 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizer.java +++ b/src/main/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizer.java @@ -2,6 +2,8 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; +import apptive.fin.apicollector.normalize.extractor.MonthlyLimitExtractor; import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.ProductRaw; import lombok.RequiredArgsConstructor; @@ -17,10 +19,11 @@ public class OntongYouthProductNormalizer extends AbstractProductNormalizer impl private final CollectorProperties properties; private final OntongYouthPolicyClassifier classifier; private final MonthlyLimitExtractor monthlyLimitExtractor; + private final KeywordExtractor keywordExtractor; @Override public Source source() { - return Source.ONTONG_YOUTH; + return Source.ONTONG; } @Override @@ -31,7 +34,7 @@ public ProductDraft normalize(ProductRaw rawProduct) { return skippedDraft(rawProduct, classification); } - String providerName = firstText(raw, "sprvsnInstCdNm", "rgtrInstCdNm", "rgtrUpInstCdNm"); + String providerName = firstText(raw, "rgtrInstCdNm", "sprvsnInstCdNm" , "rgtrUpInstCdNm"); String providerCode = firstText(raw, "sprvsnInstCd", "rgtrInstCd", "rgtrUpInstCd", "sprvsnInstCdNm", "rgtrInstCdNm"); String productName = firstText(raw, "plcyNm"); String supportContent = text(raw, "plcySprtCn"); @@ -47,36 +50,40 @@ public ProductDraft normalize(ProductRaw rawProduct) { "etcMttrCn" ); - return ProductDraft.builder() + var draft = ProductDraft.builder() .rawId(rawProduct.getId()) .rawSource(rawProduct.getSource()) .normalizerVersion(properties.normalizerVersion()) .classification(classification) .saveProduct(true) - .sourceCode(Source.ONTONG_YOUTH.name()) - .providerCode(required(providerCode, "providerCode", rawProduct)) - .providerName(required(providerName, "providerName", rawProduct)) - .type(ProductType.GOVERNMENT) + .sourceCode(Source.ONTONG.name()) + .type(rawProduct.getType()) .productCode(firstText(raw, "plcyNo") != null ? firstText(raw, "plcyNo") : rawProduct.getExternalId()) .productName(required(productName, "productName", rawProduct)) .content(content) - .minAge(integer(raw, "sprtTrgtMinAge")) - .maxAge(integer(raw, "sprtTrgtMaxAge")) - .earnMaxAmt(longValue(raw, "earnMaxAmt")) - .maxMonthlyLimit(monthlyLimitExtractor.extract(productName, supportContent)) - .requiresHomeless(containsAny(content, "무주택")) - .requiresHouseholder(containsAny(content, "세대주")) - .applyUrl(firstText(raw, "aplyUrlAddr", "refUrlAddr1", "refUrlAddr2")) - .options(java.util.List.of()) - .keywords(keywordsFromText( - text(raw, "plcyKywdNm"), - text(raw, "lclsfNm"), - text(raw, "mclsfNm"), - text(raw, "zipCd"), - productName, - content - )) + .properties(java.util.List.of(ProductPropertyDraft.builder() + .providerCode(required(providerCode, "providerCode", rawProduct)) + .providerName(required(providerName, "providerName", rawProduct)) + .minAge(integer(raw, "sprtTrgtMinAge")) + .maxAge(integer(raw, "sprtTrgtMaxAge")) + .earnMaxAmt(longValue(raw, "earnMaxAmt")) + .maxMonthlyLimit(monthlyLimitExtractor.extract(productName, supportContent)) + .requiresHomeless(containsAny(content, "무주택")) + .requiresHouseholder(containsAny(content, "세대주")) + .applyUrl(firstText(raw, "aplyUrlAddr", "refUrlAddr1", "refUrlAddr2")) +// .keywords(keywordsFromText( +// text(raw, "plcyKywdNm"), +// text(raw, "lclsfNm"), +// text(raw, "mclsfNm"), +// text(raw, "zipCd"), +// providerName, +// productName, +// content +// )) + .build())) .build(); + + return extractKeywords(keywordExtractor, draft); } private ProductDraft skippedDraft(ProductRaw rawProduct, ProductClassification classification) { @@ -86,7 +93,7 @@ private ProductDraft skippedDraft(ProductRaw rawProduct, ProductClassification c .normalizerVersion(properties.normalizerVersion()) .classification(classification) .saveProduct(false) - .sourceCode(Source.ONTONG_YOUTH.name()) + .sourceCode(Source.ONTONG.name()) .build(); } diff --git a/src/main/java/apptive/fin/apicollector/normalize/ProductDraft.java b/src/main/java/apptive/fin/apicollector/normalize/ProductDraft.java index 0c3030c..6b05190 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/ProductDraft.java +++ b/src/main/java/apptive/fin/apicollector/normalize/ProductDraft.java @@ -1,14 +1,12 @@ package apptive.fin.apicollector.normalize; import apptive.fin.apicollector.Source; -import apptive.fin.apicollector.product.KeywordValueEnum; import apptive.fin.apicollector.product.ProductType; import lombok.Builder; -import java.math.BigDecimal; import java.util.List; -@Builder +@Builder(toBuilder = true) public record ProductDraft( Long rawId, Source rawSource, @@ -16,34 +14,16 @@ public record ProductDraft( ProductClassification classification, Boolean saveProduct, String sourceCode, - String providerCode, - String providerName, ProductType type, String productCode, String productName, String content, - BigDecimal baseRate, - BigDecimal maxRate, - Long minMonthlyLimit, - Long maxMonthlyLimit, - Integer minAge, - Integer maxAge, - Long earnMaxAmt, - Integer earnPercent, - Integer minTenureMonths, - Boolean requiresHomeless, - Boolean requiresHouseholder, - String applyUrl, - List options, - List keywords + List properties ) { public ProductDraft { classification = classification == null ? ProductClassification.FINANCIAL_PRODUCT : classification; saveProduct = saveProduct == null ? Boolean.TRUE : saveProduct; - options = options == null ? List.of() : List.copyOf(options); - keywords = keywords == null ? List.of() : List.copyOf(keywords); - requiresHomeless = requiresHomeless != null && requiresHomeless; - requiresHouseholder = requiresHouseholder != null && requiresHouseholder; + properties = properties == null ? List.of() : List.copyOf(properties); } public boolean shouldSaveProduct() { diff --git a/src/main/java/apptive/fin/apicollector/normalize/ProductOptionDraft.java b/src/main/java/apptive/fin/apicollector/normalize/ProductOptionDraft.java deleted file mode 100644 index 54a8cd2..0000000 --- a/src/main/java/apptive/fin/apicollector/normalize/ProductOptionDraft.java +++ /dev/null @@ -1,12 +0,0 @@ -package apptive.fin.apicollector.normalize; - -import java.math.BigDecimal; - -public record ProductOptionDraft( - String intrRateType, - String intrRateTypeName, - Integer saveTerm, - BigDecimal intrRate, - BigDecimal intrRate2 -) { -} diff --git a/src/main/java/apptive/fin/apicollector/normalize/ProductPropertyDraft.java b/src/main/java/apptive/fin/apicollector/normalize/ProductPropertyDraft.java new file mode 100644 index 0000000..1d771e4 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/ProductPropertyDraft.java @@ -0,0 +1,36 @@ +package apptive.fin.apicollector.normalize; + +import apptive.fin.apicollector.product.KeywordValueEnum; +import lombok.Builder; + +import java.math.BigDecimal; +import java.util.List; + +@Builder(toBuilder = true) +public record ProductPropertyDraft( + String providerCode, + String providerName, + String intrRateType, + String intrRateTypeName, + Integer saveTerm, + BigDecimal baseRate, + BigDecimal maxRate, + BigDecimal govContributionRate, + Long minMonthlyLimit, + Long maxMonthlyLimit, + Integer minAge, + Integer maxAge, + Long earnMaxAmt, + Integer earnPercent, + Integer minTenureMonths, + Boolean requiresHomeless, + Boolean requiresHouseholder, + String applyUrl, + List keywords +) { + public ProductPropertyDraft { + keywords = keywords == null ? List.of() : List.copyOf(keywords); + requiresHomeless = requiresHomeless != null && requiresHomeless; + requiresHouseholder = requiresHouseholder != null && requiresHouseholder; + } +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/KeywordExtractor.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/KeywordExtractor.java new file mode 100644 index 0000000..ef68070 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/KeywordExtractor.java @@ -0,0 +1,26 @@ +package apptive.fin.apicollector.normalize.extractor; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.normalize.extractor.keywords.KeywordRecognizer; +import apptive.fin.apicollector.product.KeywordValueEnum; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class KeywordExtractor { + private final List keywordRecognizers; + + public List extract(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + List keywords = new ArrayList<>(); + for (KeywordRecognizer keywordRecognizer : keywordRecognizers) { + keywords.addAll(keywordRecognizer.recognize(productDraft, propertyDraft)); + } + return keywords; + } + +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractor.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/MonthlyLimitExtractor.java similarity index 98% rename from src/main/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractor.java rename to src/main/java/apptive/fin/apicollector/normalize/extractor/MonthlyLimitExtractor.java index 21f8303..88799f1 100644 --- a/src/main/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractor.java +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/MonthlyLimitExtractor.java @@ -1,4 +1,4 @@ -package apptive.fin.apicollector.normalize; +package apptive.fin.apicollector.normalize.extractor; import org.springframework.stereotype.Component; diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BankKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BankKeywordRecognizer.java new file mode 100644 index 0000000..c7b0ff7 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BankKeywordRecognizer.java @@ -0,0 +1,32 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class BankKeywordRecognizer implements KeywordRecognizer { + + + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + String content = productDraft.productName() + " " + productDraft.content(); + Set keywords = new HashSet<>(); + addIfContains(keywords, content, KeywordValueEnum.BANK_CARD_USAGE, + "(신용|체크).*카드", "카드결제", "카드사용", "카드.*결제" + ); + addIfContains(keywords, content, KeywordValueEnum.BANK_SALARY_TRANSFER, + "급여.*(입금|이체)" + ); + addIfContains(keywords, content, KeywordValueEnum.BANK_FIRST_TRANSACTION, + "첫거래", "최초거래", "신규고객", "첫고객" + ); + + return keywords.stream().toList(); + } +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BenefitKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BenefitKeywordRecognizer.java new file mode 100644 index 0000000..9b74322 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/BenefitKeywordRecognizer.java @@ -0,0 +1,31 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class BenefitKeywordRecognizer implements KeywordRecognizer { + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + Set keywords = new HashSet<>(); + String content = productDraft.content(); + addIfContains(keywords, content, KeywordValueEnum.BENEFIT_TAX_FREE, + "비과세" + ); + addIfContains(keywords, content, KeywordValueEnum.BENEFIT_HOUSE_PREPARE, + "내집마련", "주택" + ); + addIfContains(keywords, content, KeywordValueEnum.BENEFIT_GOV_SUBSIDY, + "기여금", "지원금", "장려금" + ); + + + return keywords.stream().toList(); + } +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/InterestKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/InterestKeywordRecognizer.java new file mode 100644 index 0000000..db33201 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/InterestKeywordRecognizer.java @@ -0,0 +1,23 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class InterestKeywordRecognizer implements KeywordRecognizer { + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + Set keywords = new HashSet(); + String title = productDraft.productName(); + addIfContains(keywords, title, KeywordValueEnum.INTEREST_SAVINGS, "적금", "예금", "저축"); + addIfContains(keywords, title, KeywordValueEnum.INTEREST_LOAN, "대출"); + + return keywords.stream().toList(); + } +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/KeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/KeywordRecognizer.java new file mode 100644 index 0000000..3f095a0 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/KeywordRecognizer.java @@ -0,0 +1,44 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; + +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public interface KeywordRecognizer { + List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft); + default void addIfContains( + Set keywords, + String value, + KeywordValueEnum keyword, + String... tokens + ) { + if (value == null) { + return; + } + + for (String token : tokens) { + if (matchesToken(value, token)) { + keywords.add(keyword); + return; + } + } + } + default boolean matchesToken(String value, String token) { + if (value.contains(token)) { + return true; + } + + try { + return Pattern.compile(token).matcher(value).find(); + } + catch (PatternSyntaxException e) { + return false; + } + } + +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/RegionKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/RegionKeywordRecognizer.java new file mode 100644 index 0000000..9ea7ec1 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/RegionKeywordRecognizer.java @@ -0,0 +1,68 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class RegionKeywordRecognizer implements KeywordRecognizer { + + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + + String providerName = propertyDraft.providerName(); + Set keywords = new HashSet<>(); + if (providerName != null && !providerName.contains("은행")) { + addIfContains(keywords, providerName, KeywordValueEnum.REGION_SEOUL, "서울"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_BUSAN, "부산"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_DAEGU, "대구"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_INCHEON, "인천"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_GWANGJU, "광주"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_DAEJEON, "대전"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_ULSAN, "울산"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_SEJONG, "세종"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_GYEONGGI, "경기"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_GANGWON, "강원"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_CHUNGBUK, "충북", "충청북도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_CHUNGNAM, "충남", "충청남도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_JEONBUK, "전북", "전라북도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_JEONNAM, "전남", "전라남도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_GYEONGBUK, "경북", "경상북도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_GYEONGNAM, "경남", "경상남도"); + addIfContains(keywords, providerName, KeywordValueEnum.REGION_JEJU, "제주"); + } + + + if (!keywords.isEmpty()) + return keywords.stream().toList(); + + + return List.of( + KeywordValueEnum.REGION_SEOUL, + KeywordValueEnum.REGION_BUSAN, + KeywordValueEnum.REGION_DAEGU, + KeywordValueEnum.REGION_INCHEON, + KeywordValueEnum.REGION_GWANGJU, + KeywordValueEnum.REGION_DAEJEON, + KeywordValueEnum.REGION_ULSAN, + KeywordValueEnum.REGION_SEJONG, + KeywordValueEnum.REGION_GYEONGGI, + KeywordValueEnum.REGION_GANGWON, + KeywordValueEnum.REGION_CHUNGBUK, + KeywordValueEnum.REGION_CHUNGNAM, + KeywordValueEnum.REGION_JEONBUK, + KeywordValueEnum.REGION_JEONNAM, + KeywordValueEnum.REGION_GYEONGBUK, + KeywordValueEnum.REGION_GYEONGNAM, + KeywordValueEnum.REGION_JEJU + ); + } + + + +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/StatusKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/StatusKeywordRecognizer.java new file mode 100644 index 0000000..47aa98b --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/StatusKeywordRecognizer.java @@ -0,0 +1,35 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class StatusKeywordRecognizer implements KeywordRecognizer { + + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + String content = productDraft.content(); + Set keywords = new HashSet<>(); + addIfContains(keywords, content, KeywordValueEnum.STATUS_UNEMPLOYED, + "미취업", "무직" + ); + addIfContains(keywords, content, KeywordValueEnum.STATUS_PART_TIME, + "단시간근로자", "단시간 근로", "시간제근로", "시간제", "일용직" + ); + addIfContains(keywords, content, KeywordValueEnum.STATUS_SME_WORKER, + "중소기업" + ); + addIfContains(keywords, content, KeywordValueEnum.STATUS_MILITARY, + "군인", "군대", "장병", "병사" + ); + + + return keywords.stream().toList(); + } +} diff --git a/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/TermKeywordRecognizer.java b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/TermKeywordRecognizer.java new file mode 100644 index 0000000..de2b1d5 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/normalize/extractor/keywords/TermKeywordRecognizer.java @@ -0,0 +1,34 @@ +package apptive.fin.apicollector.normalize.extractor.keywords; + +import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.KeywordValueEnum; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class TermKeywordRecognizer implements KeywordRecognizer { + @Override + public List recognize(ProductDraft productDraft, ProductPropertyDraft propertyDraft) { + Integer term = propertyDraft.saveTerm(); + if (term == null) + return List.of(); + + Set keywords = new HashSet<>(); + if (term < 24) { + keywords.add(KeywordValueEnum.TERM_AROUND_1_YEAR); + } + else if (term < 37) { + keywords.add(KeywordValueEnum.TERM_2_TO_3_YEARS); + } + else { + keywords.add(KeywordValueEnum.TERM_OVER_5_YEARS); + } + + return keywords.stream().toList(); + } +} diff --git a/src/main/java/apptive/fin/apicollector/product/InterestRateType.java b/src/main/java/apptive/fin/apicollector/product/InterestRateType.java new file mode 100644 index 0000000..b178adc --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/product/InterestRateType.java @@ -0,0 +1,28 @@ +package apptive.fin.apicollector.product; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum InterestRateType { + + SINGLE_INTEREST("S", "단리"), + COMPOUND_INTEREST("M", "복리"); + + private final String code; + private final String value; + + public static InterestRateType fromCode(String code) { + if (code == null || code.isBlank()) { + return null; + } + + for (InterestRateType type : values()) { + if (type.code.equalsIgnoreCase(code)) { + return type; + } + } + return null; + } +} diff --git a/src/main/java/apptive/fin/apicollector/product/KeywordValueEnum.java b/src/main/java/apptive/fin/apicollector/product/KeywordValueEnum.java index 074598b..a52c6ee 100644 --- a/src/main/java/apptive/fin/apicollector/product/KeywordValueEnum.java +++ b/src/main/java/apptive/fin/apicollector/product/KeywordValueEnum.java @@ -40,6 +40,7 @@ public enum KeywordValueEnum { BENEFIT_TAX_FREE("BENEFIT_TAX_FREE"), BENEFIT_EASY_CONDITION("BENEFIT_EASY_CONDITION"), BENEFIT_GOV_SUBSIDY("BENEFIT_GOV_SUBSIDY"), + BENEFIT_HOUSE_PREPARE("BENEFIT_HOUSE_PREPARE"), // 5. 상품 관심사 INTEREST_SAVINGS("INTEREST_SAVINGS"), diff --git a/src/main/java/apptive/fin/apicollector/product/ProductType.java b/src/main/java/apptive/fin/apicollector/product/ProductType.java index a6377cf..22516d8 100644 --- a/src/main/java/apptive/fin/apicollector/product/ProductType.java +++ b/src/main/java/apptive/fin/apicollector/product/ProductType.java @@ -1,5 +1,5 @@ package apptive.fin.apicollector.product; public enum ProductType { - GOVERNMENT, BANK + POLICY, DEPOSIT, SAVING } diff --git a/src/main/java/apptive/fin/apicollector/product/entity/Product.java b/src/main/java/apptive/fin/apicollector/product/entity/Product.java index 8af138c..137abb0 100644 --- a/src/main/java/apptive/fin/apicollector/product/entity/Product.java +++ b/src/main/java/apptive/fin/apicollector/product/entity/Product.java @@ -2,20 +2,17 @@ import apptive.fin.apicollector.global.entity.BaseTimeEntity; import apptive.fin.apicollector.normalize.ProductDraft; -import apptive.fin.apicollector.normalize.ProductOptionDraft; -import apptive.fin.apicollector.product.KeywordValueEnum; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; import apptive.fin.apicollector.product.ProductType; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashSet; import java.util.List; -import java.util.Set; +import java.util.function.Function; @Entity @Getter @@ -31,6 +28,7 @@ ) public class Product extends BaseTimeEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -39,15 +37,10 @@ public class Product extends BaseTimeEntity { @JoinColumn(name = "source_id", nullable = false) private ProductSource source; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "provider_id", nullable = false) - private Provider provider; - @Enumerated(EnumType.STRING) @Column(nullable = false) private ProductType type; - @Column(name = "product_code", nullable = false) private String productCode; @Column(nullable = false) @@ -56,52 +49,17 @@ public class Product extends BaseTimeEntity { @Column(columnDefinition = "TEXT") private String content; - // 공통 - @Column(precision = 5, scale = 2) - private BigDecimal baseRate; - - @Column(precision = 5, scale = 2) - private BigDecimal maxRate; - - private Long minMonthlyLimit; - private Long maxMonthlyLimit; - - // 조건 (파싱 결과) - private Integer minAge; - private Integer maxAge; - private Long earnMaxAmt; - private Integer earnPercent; - private Integer minTenureMonths; - - @Column(nullable = false) - private Boolean requiresHomeless = false; - - @Column(nullable = false) - private Boolean requiresHouseholder = false; - - // url - private String applyUrl; - - // 현재 가입 가능 상품 판단 - @Column(nullable = false) - private Boolean isJoinable = true; - - // 연관관계 - @OneToMany(mappedBy = "product", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private List options = new ArrayList<>(); - + @BatchSize(size = 100) @OneToMany(mappedBy = "product", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private List keywords = new ArrayList<>(); + private List properties = new ArrayList<>(); private Product( ProductSource source, - Provider provider, ProductType type, String productCode, String productName ) { this.source = source; - this.provider = provider; this.type = type; this.productCode = productCode; this.productName = productName; @@ -109,64 +67,34 @@ private Product( public static Product create( ProductSource source, - Provider provider, ProductType type, String productCode, String productName ) { - return new Product(source, provider, type, productCode, productName); + return new Product(source, type, productCode, productName); } - public void updateFrom(ProductDraft draft, Provider provider) { - this.provider = provider; + public void updateFrom(ProductDraft draft) { this.type = draft.type(); this.productName = draft.productName(); this.content = draft.content(); - this.baseRate = draft.baseRate(); - this.maxRate = draft.maxRate(); - this.minMonthlyLimit = draft.minMonthlyLimit(); - this.maxMonthlyLimit = draft.maxMonthlyLimit(); - this.minAge = draft.minAge(); - this.maxAge = draft.maxAge(); - this.earnMaxAmt = draft.earnMaxAmt(); - this.earnPercent = draft.earnPercent(); - this.minTenureMonths = draft.minTenureMonths(); - this.requiresHomeless = draft.requiresHomeless(); - this.requiresHouseholder = draft.requiresHouseholder(); - this.applyUrl = draft.applyUrl(); - } - - public void replaceOptions(List optionDrafts) { - this.options.clear(); - for (ProductOptionDraft optionDraft : optionDrafts) { - this.options.add(ProductOption.create(this, optionDraft)); - } } - public void replaceKeywords(List keywordCodes) { - Set desiredKeywords = keywordCodes == null || keywordCodes.isEmpty() - ? EnumSet.noneOf(KeywordValueEnum.class) - : EnumSet.copyOf(keywordCodes); - - this.keywords.removeIf(keyword -> !desiredKeywords.contains(keyword.getKeywordCode())); - - Set currentKeywords = new HashSet<>(); - for (ProductKeyword keyword : this.keywords) { - currentKeywords.add(keyword.getKeywordCode()); - } - - for (KeywordValueEnum keywordCode : desiredKeywords) { - if (!currentKeywords.contains(keywordCode)) { - this.keywords.add(ProductKeyword.create(this, keywordCode)); - } - } + public void replaceProperties( + List propertyDrafts, + Function providerResolver + ) { + this.properties.clear(); + propertyDrafts.forEach(propertyDraft -> + this.properties.add(ProductProperty.create(this, providerResolver.apply(propertyDraft), propertyDraft)) + ); } public void markUnjoinable() { - this.isJoinable = false; + this.properties.forEach(ProductProperty::markUnjoinable); } public void markJoinable() { - this.isJoinable = true; + this.properties.forEach(ProductProperty::markJoinable); } } diff --git a/src/main/java/apptive/fin/apicollector/product/entity/ProductKeyword.java b/src/main/java/apptive/fin/apicollector/product/entity/ProductKeyword.java index d4966e1..0cdbf7e 100644 --- a/src/main/java/apptive/fin/apicollector/product/entity/ProductKeyword.java +++ b/src/main/java/apptive/fin/apicollector/product/entity/ProductKeyword.java @@ -11,11 +11,11 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( - name = "product_keyword", + name = "product_property_keyword", uniqueConstraints = { @UniqueConstraint( - name = "uk_product_keyword_product_keyword_code", - columnNames = {"product_id", "keyword_code"} + name = "uk_product_property_keyword_property_keyword_code", + columnNames = {"product_property_id", "keyword_code"} ) } ) @@ -26,19 +26,19 @@ public class ProductKeyword { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id", nullable = false) - private Product product; + @JoinColumn(name = "product_property_id", nullable = false) + private ProductProperty productProperty; @Enumerated(EnumType.STRING) @Column(name = "keyword_code", nullable = false) private KeywordValueEnum keywordCode; - private ProductKeyword(Product product, KeywordValueEnum keywordCode) { - this.product = product; + private ProductKeyword(ProductProperty productProperty, KeywordValueEnum keywordCode) { + this.productProperty = productProperty; this.keywordCode = keywordCode; } - public static ProductKeyword create(Product product, KeywordValueEnum keywordCode) { - return new ProductKeyword(product, keywordCode); + public static ProductKeyword create(ProductProperty productProperty, KeywordValueEnum keywordCode) { + return new ProductKeyword(productProperty, keywordCode); } } diff --git a/src/main/java/apptive/fin/apicollector/product/entity/ProductOption.java b/src/main/java/apptive/fin/apicollector/product/entity/ProductOption.java deleted file mode 100644 index a2b228c..0000000 --- a/src/main/java/apptive/fin/apicollector/product/entity/ProductOption.java +++ /dev/null @@ -1,51 +0,0 @@ -package apptive.fin.apicollector.product.entity; - -import apptive.fin.apicollector.normalize.ProductOptionDraft; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name="product_option") -public class ProductOption { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id", nullable = false) - private Product product; - - @Column(nullable = false) - private String intrRateType; - - private String intrRateTypeNm; - - @Column(nullable = false) - private Integer saveTrm; - - @Column(precision = 5, scale = 2) - private BigDecimal intrRate; - - @Column(precision = 5, scale = 2) - private BigDecimal intrRate2; - - private ProductOption(Product product, ProductOptionDraft draft) { - this.product = product; - this.intrRateType = draft.intrRateType(); - this.intrRateTypeNm = draft.intrRateTypeName(); - this.saveTrm = draft.saveTerm(); - this.intrRate = draft.intrRate(); - this.intrRate2 = draft.intrRate2(); - } - - public static ProductOption create(Product product, ProductOptionDraft draft) { - return new ProductOption(product, draft); - } -} diff --git a/src/main/java/apptive/fin/apicollector/product/entity/ProductProperty.java b/src/main/java/apptive/fin/apicollector/product/entity/ProductProperty.java new file mode 100644 index 0000000..c683c34 --- /dev/null +++ b/src/main/java/apptive/fin/apicollector/product/entity/ProductProperty.java @@ -0,0 +1,135 @@ +package apptive.fin.apicollector.product.entity; + +import apptive.fin.apicollector.normalize.ProductPropertyDraft; +import apptive.fin.apicollector.product.InterestRateType; +import apptive.fin.apicollector.product.KeywordValueEnum; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product_properties") +public class ProductProperty { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "provider_id", nullable = false) + private Provider provider; + + @BatchSize(size = 100) + @OneToMany(mappedBy = "productProperty", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List keywords = new ArrayList<>(); + + @Column(precision = 5, scale = 2) + private BigDecimal baseRate; + + @Column(precision = 5, scale = 2) + private BigDecimal maxRate; + + @Column(precision = 5, scale = 2) + private BigDecimal govContributionRate; + + private Long minMonthlyLimit; + private Long maxMonthlyLimit; + + private Integer minAge; + private Integer maxAge; + private Long earnMaxAmt; + private Integer earnPercent; + private Integer minTenureMonths; + + @Column(nullable = false) + private Boolean requiresHomeless = false; + + @Column(nullable = false) + private Boolean requiresHouseholder = false; + + @Column(nullable = false) + private Boolean isJoinable = true; + + private String applyUrl; + + @Enumerated(EnumType.STRING) + private InterestRateType intrRateType; + + private Integer saveTrm; + + private ProductProperty( + Product product, + Provider provider, + ProductPropertyDraft propertyDraft + ) { + this.product = product; + this.provider = provider; + this.baseRate = propertyDraft.baseRate(); + this.maxRate = propertyDraft.maxRate(); + this.govContributionRate = propertyDraft.govContributionRate(); + this.minMonthlyLimit = propertyDraft.minMonthlyLimit(); + this.maxMonthlyLimit = propertyDraft.maxMonthlyLimit(); + this.minAge = propertyDraft.minAge(); + this.maxAge = propertyDraft.maxAge(); + this.earnMaxAmt = propertyDraft.earnMaxAmt(); + this.earnPercent = propertyDraft.earnPercent(); + this.minTenureMonths = propertyDraft.minTenureMonths(); + this.requiresHomeless = propertyDraft.requiresHomeless(); + this.requiresHouseholder = propertyDraft.requiresHouseholder(); + this.isJoinable = true; + this.applyUrl = propertyDraft.applyUrl(); + this.intrRateType = InterestRateType.fromCode(propertyDraft.intrRateType()); + this.saveTrm = propertyDraft.saveTerm(); + replaceKeywords(propertyDraft.keywords()); + } + + public static ProductProperty create( + Product product, + Provider provider, + ProductPropertyDraft propertyDraft + ) { + return new ProductProperty(product, provider, propertyDraft); + } + + public void replaceKeywords(List keywordCodes) { + Set desiredKeywords = keywordCodes == null || keywordCodes.isEmpty() + ? EnumSet.noneOf(KeywordValueEnum.class) + : EnumSet.copyOf(keywordCodes); + + this.keywords.removeIf(keyword -> !desiredKeywords.contains(keyword.getKeywordCode())); + + Set currentKeywords = new HashSet<>(); + for (ProductKeyword keyword : this.keywords) { + currentKeywords.add(keyword.getKeywordCode()); + } + + for (KeywordValueEnum keywordCode : desiredKeywords) { + if (!currentKeywords.contains(keywordCode)) { + this.keywords.add(ProductKeyword.create(this, keywordCode)); + } + } + } + + public void markUnjoinable() { + this.isJoinable = false; + } + + public void markJoinable() { + this.isJoinable = true; + } +} diff --git a/src/main/java/apptive/fin/apicollector/product/ProductRepository.java b/src/main/java/apptive/fin/apicollector/product/repository/ProductRepository.java similarity index 78% rename from src/main/java/apptive/fin/apicollector/product/ProductRepository.java rename to src/main/java/apptive/fin/apicollector/product/repository/ProductRepository.java index 7ad6623..8d0848d 100644 --- a/src/main/java/apptive/fin/apicollector/product/ProductRepository.java +++ b/src/main/java/apptive/fin/apicollector/product/repository/ProductRepository.java @@ -1,4 +1,4 @@ -package apptive.fin.apicollector.product; +package apptive.fin.apicollector.product.repository; import apptive.fin.apicollector.Source; import apptive.fin.apicollector.product.entity.Product; @@ -8,7 +8,6 @@ import org.springframework.data.jpa.repository.Query; import java.time.Instant; -import java.time.LocalDateTime; import java.util.Optional; public interface ProductRepository extends JpaRepository { @@ -16,14 +15,14 @@ public interface ProductRepository extends JpaRepository { Optional findBySourceAndProductCode(ProductSource source, String productCode); @Query(""" - update Product p - set p.isJoinable = false - where p.source = :productSource + update ProductProperty pp + set pp.isJoinable = false + where pp.product.source = :productSource and exists( select pr.id from ProductRaw pr where pr.source = :source - and pr.externalId = p.productCode + and pr.externalId = pp.product.productCode and pr.lastSeenAt < :lastSeen ) """) diff --git a/src/main/java/apptive/fin/apicollector/product/ProductSourceRepository.java b/src/main/java/apptive/fin/apicollector/product/repository/ProductSourceRepository.java similarity index 84% rename from src/main/java/apptive/fin/apicollector/product/ProductSourceRepository.java rename to src/main/java/apptive/fin/apicollector/product/repository/ProductSourceRepository.java index 4e9ccb5..1ae7743 100644 --- a/src/main/java/apptive/fin/apicollector/product/ProductSourceRepository.java +++ b/src/main/java/apptive/fin/apicollector/product/repository/ProductSourceRepository.java @@ -1,4 +1,4 @@ -package apptive.fin.apicollector.product; +package apptive.fin.apicollector.product.repository; import apptive.fin.apicollector.product.entity.ProductSource; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/apptive/fin/apicollector/product/ProviderRepository.java b/src/main/java/apptive/fin/apicollector/product/repository/ProviderRepository.java similarity index 87% rename from src/main/java/apptive/fin/apicollector/product/ProviderRepository.java rename to src/main/java/apptive/fin/apicollector/product/repository/ProviderRepository.java index 2b974e2..811157f 100644 --- a/src/main/java/apptive/fin/apicollector/product/ProviderRepository.java +++ b/src/main/java/apptive/fin/apicollector/product/repository/ProviderRepository.java @@ -1,4 +1,4 @@ -package apptive.fin.apicollector.product; +package apptive.fin.apicollector.product.repository; import apptive.fin.apicollector.product.entity.ProductSource; import apptive.fin.apicollector.product.entity.Provider; diff --git a/src/main/java/apptive/fin/apicollector/product/ProductSyncService.java b/src/main/java/apptive/fin/apicollector/product/service/ProductSyncService.java similarity index 76% rename from src/main/java/apptive/fin/apicollector/product/ProductSyncService.java rename to src/main/java/apptive/fin/apicollector/product/service/ProductSyncService.java index 909d4d9..4e6a8d7 100644 --- a/src/main/java/apptive/fin/apicollector/product/ProductSyncService.java +++ b/src/main/java/apptive/fin/apicollector/product/service/ProductSyncService.java @@ -1,10 +1,14 @@ -package apptive.fin.apicollector.product; +package apptive.fin.apicollector.product.service; import apptive.fin.apicollector.Source; import apptive.fin.apicollector.normalize.ProductDraft; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; import apptive.fin.apicollector.product.entity.Product; import apptive.fin.apicollector.product.entity.ProductSource; import apptive.fin.apicollector.product.entity.Provider; +import apptive.fin.apicollector.product.repository.ProductRepository; +import apptive.fin.apicollector.product.repository.ProductSourceRepository; +import apptive.fin.apicollector.product.repository.ProviderRepository; import apptive.fin.apicollector.raw.ProductRaw; import apptive.fin.apicollector.raw.ProductRawRepository; import lombok.RequiredArgsConstructor; @@ -49,34 +53,33 @@ private void sync(ProductDraft draft) { draft.sourceCode() ))); - Provider provider = providerRepository.findBySourceAndCode(source, draft.providerCode()) - .map(existing -> { - existing.updateName(draft.providerName()); - return existing; - }) - .orElseGet(() -> providerRepository.save(Provider.create( - source, - draft.providerCode(), - draft.providerName() - ))); - Product product = productRepository.findBySourceAndProductCode(source, draft.productCode()) .orElseGet(() -> productRepository.save(Product.create( source, - provider, draft.type(), draft.productCode(), draft.productName() ))); - product.updateFrom(draft, provider); - product.markJoinable(); - product.replaceOptions(draft.options()); - product.replaceKeywords(draft.keywords()); + product.updateFrom(draft); + product.replaceProperties(draft.properties(), propertyDraft -> resolveProvider(source, propertyDraft)); markNormalized(draft); } + private Provider resolveProvider(ProductSource source, ProductPropertyDraft propertyDraft) { + return providerRepository.findBySourceAndCode(source, propertyDraft.providerCode()) + .map(existing -> { + existing.updateName(propertyDraft.providerName()); + return existing; + }) + .orElseGet(() -> providerRepository.save(Provider.create( + source, + propertyDraft.providerCode(), + propertyDraft.providerName() + ))); + } + private void markNormalized(ProductDraft draft) { ProductRaw raw = productRawRepository.findById(draft.rawId()) .orElseThrow(() -> new IllegalStateException("ProductRaw not found. rawId=" + draft.rawId())); diff --git a/src/main/java/apptive/fin/apicollector/raw/ProductRaw.java b/src/main/java/apptive/fin/apicollector/raw/ProductRaw.java index 0e54fcf..b9a2d66 100644 --- a/src/main/java/apptive/fin/apicollector/raw/ProductRaw.java +++ b/src/main/java/apptive/fin/apicollector/raw/ProductRaw.java @@ -2,6 +2,7 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.global.entity.BaseTimeEntity; +import apptive.fin.apicollector.product.ProductType; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -24,6 +25,10 @@ public class ProductRaw extends BaseTimeEntity { @Column(nullable = false, length = 30) private Source source; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProductType type; + @Column(name = "external_id", nullable = false, length = 150) private String externalId; @@ -50,13 +55,15 @@ public ProductRaw( Source source, String externalId, String contentHash, - String rawJson + String rawJson, + ProductType productType ) { this.source = source; this.externalId = externalId; this.contentHash = contentHash; this.rawJson = rawJson; this.lastSeenAt = Instant.now(); + this.type = productType; } public void updateRaw(String contentHash, String rawJson) { diff --git a/src/main/java/apptive/fin/apicollector/raw/RawProductSaveService.java b/src/main/java/apptive/fin/apicollector/raw/RawProductSaveService.java index b686e0f..40ec2b0 100644 --- a/src/main/java/apptive/fin/apicollector/raw/RawProductSaveService.java +++ b/src/main/java/apptive/fin/apicollector/raw/RawProductSaveService.java @@ -1,6 +1,7 @@ package apptive.fin.apicollector.raw; import apptive.fin.apicollector.Source; +import apptive.fin.apicollector.product.ProductType; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,7 +18,7 @@ public class RawProductSaveService { private final ProductRawRepository productRawRepository; private final ObjectMapper objectMapper; - public SaveResult saveOrUpdate(Source source, String externalId, JsonNode raw) { + public SaveResult saveOrUpdate(Source source, String externalId, JsonNode raw, ProductType productType) { String rawJson = toJson(raw); String hash = sha256(rawJson); @@ -32,7 +33,7 @@ public SaveResult saveOrUpdate(Source source, String externalId, JsonNode raw) { return SaveResult.UPDATED; }) .orElseGet(()->{ - productRawRepository.save(new ProductRaw(source, externalId, hash, rawJson)); + productRawRepository.save(new ProductRaw(source, externalId, hash, rawJson, productType)); return SaveResult.INSERTED; }); } diff --git a/src/main/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTasklet.java index 96bdc11..fc789ad 100644 --- a/src/main/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTasklet.java +++ b/src/main/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTasklet.java @@ -2,7 +2,7 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; -import apptive.fin.apicollector.product.ProductSyncService; +import apptive.fin.apicollector.product.service.ProductSyncService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.scope.context.ChunkContext; @@ -40,8 +40,8 @@ public RepeatStatus execute( Instant threshold = Instant.now().minus(properties.unseenDisablePeriod(), ChronoUnit.DAYS); - if (properties.source() == Source.ALL || properties.source() == Source.ONTONG_YOUTH) { - int ontongDeactivated = productSyncService.disableAllUnseenProducts(Source.ONTONG_YOUTH, threshold); + if (properties.source() == Source.ALL || properties.source() == Source.ONTONG) { + int ontongDeactivated = productSyncService.disableAllUnseenProducts(Source.ONTONG, threshold); log.info( "DeactivateMissingProductTasklet: ontong={}", ontongDeactivated diff --git a/src/main/java/apptive/fin/apicollector/tasklet/FetchFssRawTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/FetchFssRawTasklet.java index 4796fba..64585b1 100644 --- a/src/main/java/apptive/fin/apicollector/tasklet/FetchFssRawTasklet.java +++ b/src/main/java/apptive/fin/apicollector/tasklet/FetchFssRawTasklet.java @@ -5,6 +5,7 @@ import apptive.fin.apicollector.client.fss.FssProductType; import apptive.fin.apicollector.client.fss.FssRawProduct; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.RawProductSaveService; import apptive.fin.apicollector.raw.SaveResult; import lombok.RequiredArgsConstructor; @@ -65,7 +66,10 @@ public RepeatStatus execute( SaveResult result = rawProductSaveService.saveOrUpdate( Source.FSS, product.externalId(), - product.raw() + product.raw(), + product.productType() == FssProductType.SAVING ? + ProductType.SAVING : + ProductType.DEPOSIT ); switch (result) { diff --git a/src/main/java/apptive/fin/apicollector/tasklet/FetchOntongYouthRawTasklet.java b/src/main/java/apptive/fin/apicollector/tasklet/FetchOntongYouthRawTasklet.java index 8c20885..5c4acd3 100644 --- a/src/main/java/apptive/fin/apicollector/tasklet/FetchOntongYouthRawTasklet.java +++ b/src/main/java/apptive/fin/apicollector/tasklet/FetchOntongYouthRawTasklet.java @@ -3,6 +3,7 @@ import apptive.fin.apicollector.Source; import apptive.fin.apicollector.client.OntongYouthClient; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.RawProductSaveService; import apptive.fin.apicollector.raw.SaveResult; import lombok.RequiredArgsConstructor; @@ -46,9 +47,10 @@ public RepeatStatus execute( } SaveResult result = rawProductSaveService.saveOrUpdate( - Source.ONTONG_YOUTH, + Source.ONTONG, externalId, - item + item, + ProductType.POLICY ); switch (result) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bdf7e4e..7e569d8 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -25,8 +25,8 @@ spring: collector: enabled: true source: ALL - mode: SYNC - normalizer-version: 2 + mode: sync + normalizer-version: 6 reader-page-size : 500 unseen-disable-period : 1 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 959a626..5d7fa18 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -3,6 +3,7 @@ create table if not exists product_raw id bigint generated by default as identity primary key, source varchar(30) not null, + type varchar(20) not null, external_id varchar(150) not null, content_hash varchar(64) not null, raw_json text not null, @@ -22,84 +23,59 @@ create index if not exists idx_product_raw_source create index if not exists idx_product_raw_normalized on product_raw (source, normalized_at); -create table if not exists product_source -( - id bigint generated by default as identity - primary key, - code varchar(255) not null, - name varchar(255) not null, - constraint uk_product_source_code - unique (code) +CREATE TABLE IF NOT EXISTS product_source ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(50) NOT NULL ); -create table if not exists provider -( - id bigint generated by default as identity - primary key, - source_id bigint not null, - code varchar(255), - name varchar(255) not null, - constraint fk_provider_product_source - foreign key (source_id) references product_source (id), - constraint uk_provider_source_code - unique (source_id, code) +INSERT INTO product_source (code, name) VALUES + ('FSS', '금융감독원'), + ('ONTONG', '온통청년') +ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name; + +CREATE TABLE IF NOT EXISTS provider ( + id BIGSERIAL PRIMARY KEY, + source_id BIGINT NOT NULL REFERENCES product_source(id), + code VARCHAR(100), + name VARCHAR(100) NOT NULL ); -create table if not exists product -( - id bigint generated by default as identity - primary key, - source_id bigint not null, - provider_id bigint not null, - type varchar(255) not null, - product_code varchar(255) not null, - product_name varchar(255) not null, - content text, - base_rate numeric(5, 2), - max_rate numeric(5, 2), - min_monthly_limit bigint, - max_monthly_limit bigint, - min_age integer, - max_age integer, - earn_max_amt bigint, - earn_percent integer, - min_tenure_months integer, - requires_homeless boolean not null, - requires_householder boolean not null, - apply_url varchar(255), - created_at timestamptz not null, - updated_at timestamptz, - is_joinable BOOLEAN not null DEFAULT true, - constraint fk_product_product_source - foreign key (source_id) references product_source (id), - constraint fk_product_provider - foreign key (provider_id) references provider (id), - constraint uk_product_source_product_code - unique (source_id, product_code) +CREATE TABLE IF NOT EXISTS product ( + id BIGSERIAL PRIMARY KEY, + source_id BIGINT NOT NULL REFERENCES product_source(id), + type VARCHAR(20) NOT NULL, + product_code VARCHAR(100), + product_name VARCHAR(200) NOT NULL, + content TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); -create table if not exists product_option -( - id bigint generated by default as identity - primary key, - product_id bigint not null, - intr_rate_type varchar(255) not null, - intr_rate_type_nm varchar(255), - save_trm integer not null, - intr_rate numeric(5, 2), - intr_rate2 numeric(5, 2), - constraint fk_product_option_product - foreign key (product_id) references product (id) +CREATE TABLE IF NOT EXISTS product_properties ( + id BIGSERIAL PRIMARY KEY, + product_id BIGINT NOT NULL REFERENCES product(id) ON DELETE CASCADE, + provider_id BIGINT NOT NULL REFERENCES provider(id), + base_rate DECIMAL(5,2), + max_rate DECIMAL(5,2), + gov_contribution_rate DECIMAL(5,2), + min_monthly_limit BIGINT, + max_monthly_limit BIGINT, + min_age INT, + max_age INT, + earn_max_amt BIGINT, + earn_percent INT, + min_tenure_months INT, + requires_homeless BOOLEAN NOT NULL DEFAULT FALSE, + requires_householder BOOLEAN NOT NULL DEFAULT FALSE, + is_joinable BOOLEAN NOT NULL DEFAULT TRUE, + apply_url VARCHAR(500), + intr_rate_type VARCHAR(30), + save_trm INT ); -create table if not exists product_keyword -( - id bigint generated by default as identity - primary key, - product_id bigint not null, - keyword_code varchar(255) not null, - constraint fk_product_keyword_product - foreign key (product_id) references product (id), - constraint uk_product_keyword_product_keyword_code - unique (product_id, keyword_code) +CREATE TABLE IF NOT EXISTS product_property_keyword ( + id BIGSERIAL PRIMARY KEY, + product_property_id BIGINT NOT NULL REFERENCES product_properties(id) ON DELETE CASCADE, + keyword_code VARCHAR(50) NOT NULL ); diff --git a/src/test/java/apptive/fin/apicollector/normalize/FssProductNormalizerTest.java b/src/test/java/apptive/fin/apicollector/normalize/FssProductNormalizerTest.java index 9995fe3..0c0bd61 100644 --- a/src/test/java/apptive/fin/apicollector/normalize/FssProductNormalizerTest.java +++ b/src/test/java/apptive/fin/apicollector/normalize/FssProductNormalizerTest.java @@ -3,18 +3,31 @@ import apptive.fin.apicollector.Mode; import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; +import apptive.fin.apicollector.normalize.extractor.keywords.BankKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.BenefitKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.InterestKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.RegionKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.StatusKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.TermKeywordRecognizer; import apptive.fin.apicollector.product.KeywordValueEnum; import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.ProductRaw; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class FssProductNormalizerTest { private final ObjectMapper objectMapper = new ObjectMapper(); - private final FssProductNormalizer normalizer = new FssProductNormalizer(objectMapper, properties()); + private final FssProductNormalizer normalizer = new FssProductNormalizer( + objectMapper, + properties(), + keywordExtractor() + ); @Test void normalizesFssRawProduct() { @@ -44,11 +57,13 @@ void normalizesFssRawProduct() { assertThat(draft.type()).isEqualTo(ProductType.BANK); assertThat(draft.productCode()).isEqualTo("FSS:SAVING:001:ABC"); assertThat(draft.productName()).isEqualTo("청년 적금"); - assertThat(draft.baseRate()).isEqualByComparingTo("3.5"); - assertThat(draft.maxRate()).isEqualByComparingTo("4.5"); - assertThat(draft.maxMonthlyLimit()).isEqualTo(300_000L); - assertThat(draft.minTenureMonths()).isEqualTo(24); - assertThat(draft.options()).hasSize(2); + assertThat(draft.properties()).hasSize(2); + assertThat(draft.properties().get(1).providerCode()).isEqualTo("001"); + assertThat(draft.properties().get(1).providerName()).isEqualTo("테스트은행"); + assertThat(draft.properties().get(1).baseRate()).isEqualByComparingTo("3.5"); + assertThat(draft.properties().get(1).maxRate()).isEqualByComparingTo("4.5"); + assertThat(draft.properties().get(1).maxMonthlyLimit()).isEqualTo(300_000L); + assertThat(draft.properties().get(1).minTenureMonths()).isEqualTo(24); assertThat(draft.shouldSaveProduct()).isTrue(); } @@ -64,7 +79,7 @@ void extractsKeywordsFromFssProductJson() { "kor_co_nm": "테스트은행", "fin_prdt_nm": "급여 카드 우대 청년 적금", "join_way": "모바일", - "spcl_cnd": "급여 이체와 카드 사용 시 우대금리 제공", + "spcl_cnd": "급여 이체와 신용/체크카드 사용 시 우대금리 제공", "join_member": "첫거래 고객 우대", "max_limit": 500000 }, @@ -76,7 +91,7 @@ void extractsKeywordsFromFssProductJson() { ProductDraft draft = normalizer.normalize(raw); - assertThat(draft.keywords()) + assertThat(draft.properties().getFirst().keywords()) .contains( KeywordValueEnum.INTEREST_SAVINGS, KeywordValueEnum.BENEFIT_MAX_INTEREST, @@ -98,4 +113,15 @@ private CollectorProperties properties() { new CollectorProperties.Fss("http://localhost", "key", 100) ); } + + private KeywordExtractor keywordExtractor() { + return new KeywordExtractor(List.of( + new BenefitKeywordRecognizer(), + new BankKeywordRecognizer(), + new InterestKeywordRecognizer(), + new RegionKeywordRecognizer(), + new StatusKeywordRecognizer(), + new TermKeywordRecognizer() + )); + } } diff --git a/src/test/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractorTest.java b/src/test/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractorTest.java index a76f51c..277b8d6 100644 --- a/src/test/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractorTest.java +++ b/src/test/java/apptive/fin/apicollector/normalize/MonthlyLimitExtractorTest.java @@ -1,5 +1,6 @@ package apptive.fin.apicollector.normalize; +import apptive.fin.apicollector.normalize.extractor.MonthlyLimitExtractor; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizerTest.java b/src/test/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizerTest.java index 71a6e84..3ee8364 100644 --- a/src/test/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizerTest.java +++ b/src/test/java/apptive/fin/apicollector/normalize/OntongYouthProductNormalizerTest.java @@ -3,12 +3,22 @@ import apptive.fin.apicollector.Mode; import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; +import apptive.fin.apicollector.normalize.extractor.KeywordExtractor; +import apptive.fin.apicollector.normalize.extractor.MonthlyLimitExtractor; +import apptive.fin.apicollector.normalize.extractor.keywords.BankKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.BenefitKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.InterestKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.RegionKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.StatusKeywordRecognizer; +import apptive.fin.apicollector.normalize.extractor.keywords.TermKeywordRecognizer; import apptive.fin.apicollector.product.KeywordValueEnum; import apptive.fin.apicollector.product.ProductType; import apptive.fin.apicollector.raw.ProductRaw; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class OntongYouthProductNormalizerTest { @@ -17,12 +27,13 @@ class OntongYouthProductNormalizerTest { new ObjectMapper(), properties(), new OntongYouthPolicyClassifier(), - new MonthlyLimitExtractor() + new MonthlyLimitExtractor(), + keywordExtractor() ); @Test void normalizesOnlyFinancialPolicy() { - ProductRaw raw = new ProductRaw(Source.ONTONG_YOUTH, "P001", "hash", """ + ProductRaw raw = new ProductRaw(Source.ONTONG, "P001", "hash", """ { "plcyNo": "P001", "plcyNm": "청년 저축 지원", @@ -44,20 +55,21 @@ void normalizesOnlyFinancialPolicy() { assertThat(draft.classification()).isEqualTo(ProductClassification.FINANCIAL_PRODUCT); assertThat(draft.shouldSaveProduct()).isTrue(); - assertThat(draft.sourceCode()).isEqualTo("ONTONG_YOUTH"); - assertThat(draft.type()).isEqualTo(ProductType.GOVERNMENT); + assertThat(draft.sourceCode()).isEqualTo("ONTONG"); + assertThat(draft.type()).isEqualTo(ProductType.POLICY); assertThat(draft.productCode()).isEqualTo("P001"); - assertThat(draft.providerCode()).isEqualTo("ORG001"); - assertThat(draft.maxMonthlyLimit()).isEqualTo(100_000L); - assertThat(draft.minAge()).isEqualTo(19); - assertThat(draft.maxAge()).isEqualTo(34); - assertThat(draft.earnMaxAmt()).isNull(); - assertThat(draft.options()).isEmpty(); + assertThat(draft.properties()).hasSize(1); + ProductPropertyDraft property = draft.properties().getFirst(); + assertThat(property.providerCode()).isEqualTo("ORG001"); + assertThat(property.maxMonthlyLimit()).isEqualTo(100_000L); + assertThat(property.minAge()).isEqualTo(19); + assertThat(property.maxAge()).isEqualTo(34); + assertThat(property.earnMaxAmt()).isNull(); } @Test void returnsSkippedDraftForLoanPolicy() { - ProductRaw raw = new ProductRaw(Source.ONTONG_YOUTH, "P002", "hash", """ + ProductRaw raw = new ProductRaw(Source.ONTONG, "P002", "hash", """ { "plcyNo": "P002", "plcyNm": "청년 대출 지원", @@ -72,12 +84,12 @@ void returnsSkippedDraftForLoanPolicy() { assertThat(draft.classification()).isEqualTo(ProductClassification.LOAN_EXCLUDED); assertThat(draft.shouldSaveProduct()).isFalse(); - assertThat(draft.sourceCode()).isEqualTo("ONTONG_YOUTH"); + assertThat(draft.sourceCode()).isEqualTo("ONTONG"); } @Test void extractsKeywordsFromOntongPolicyJson() { - ProductRaw raw = new ProductRaw(Source.ONTONG_YOUTH, "P003", "hash", """ + ProductRaw raw = new ProductRaw(Source.ONTONG, "P003", "hash", """ { "plcyNo": "P003", "plcyNm": "서울 청년 저축 장려금", @@ -95,7 +107,7 @@ void extractsKeywordsFromOntongPolicyJson() { ProductDraft draft = normalizer.normalize(raw); - assertThat(draft.keywords()) + assertThat(draft.properties().getFirst().keywords()) .contains( KeywordValueEnum.REGION_SEOUL, KeywordValueEnum.BENEFIT_GOV_SUBSIDY, @@ -116,4 +128,15 @@ private CollectorProperties properties() { new CollectorProperties.Fss("http://localhost", "key", 100) ); } + + private KeywordExtractor keywordExtractor() { + return new KeywordExtractor(List.of( + new BenefitKeywordRecognizer(), + new BankKeywordRecognizer(), + new InterestKeywordRecognizer(), + new RegionKeywordRecognizer(), + new StatusKeywordRecognizer(), + new TermKeywordRecognizer() + )); + } } diff --git a/src/test/java/apptive/fin/apicollector/normalize/ProductDraftTest.java b/src/test/java/apptive/fin/apicollector/normalize/ProductDraftTest.java index 9202e37..72348a5 100644 --- a/src/test/java/apptive/fin/apicollector/normalize/ProductDraftTest.java +++ b/src/test/java/apptive/fin/apicollector/normalize/ProductDraftTest.java @@ -15,9 +15,6 @@ void appliesDefaults() { assertThat(draft.classification()).isEqualTo(ProductClassification.FINANCIAL_PRODUCT); assertThat(draft.shouldSaveProduct()).isTrue(); - assertThat(draft.options()).isEmpty(); - assertThat(draft.keywords()).isEmpty(); - assertThat(draft.requiresHomeless()).isFalse(); - assertThat(draft.requiresHouseholder()).isFalse(); + assertThat(draft.properties()).isEmpty(); } } diff --git a/src/test/java/apptive/fin/apicollector/product/entity/ProductTest.java b/src/test/java/apptive/fin/apicollector/product/entity/ProductTest.java index 05922c5..b5ebddd 100644 --- a/src/test/java/apptive/fin/apicollector/product/entity/ProductTest.java +++ b/src/test/java/apptive/fin/apicollector/product/entity/ProductTest.java @@ -1,5 +1,6 @@ package apptive.fin.apicollector.product.entity; +import apptive.fin.apicollector.normalize.ProductPropertyDraft; import apptive.fin.apicollector.product.KeywordValueEnum; import apptive.fin.apicollector.product.ProductType; import org.junit.jupiter.api.Test; @@ -14,28 +15,33 @@ class ProductTest { void replaceKeywordsReusesExistingKeywordsAndAddsOnlyMissingOnes() { ProductSource source = ProductSource.create("ONTONG_YOUTH", "ONTONG_YOUTH"); Provider provider = Provider.create(source, "ORG001", "테스트기관"); - Product product = Product.create(source, provider, ProductType.GOVERNMENT, "P001", "청년 저축 지원"); - - product.replaceKeywords(List.of( + Product product = Product.create(source, ProductType.POLICY, "P001", "청년 저축 지원"); + product.replaceProperties(List.of(ProductPropertyDraft.builder() + .providerCode("ORG001") + .providerName("테스트기관") + .build()), ignored -> provider); + ProductProperty property = product.getProperties().getFirst(); + + property.replaceKeywords(List.of( KeywordValueEnum.BENEFIT_GOV_SUBSIDY, KeywordValueEnum.INTEREST_SAVINGS )); - ProductKeyword existing = product.getKeywords().stream() + ProductKeyword existing = property.getKeywords().stream() .filter(keyword -> keyword.getKeywordCode() == KeywordValueEnum.BENEFIT_GOV_SUBSIDY) .findFirst() .orElseThrow(); - product.replaceKeywords(List.of( + property.replaceKeywords(List.of( KeywordValueEnum.BENEFIT_GOV_SUBSIDY, KeywordValueEnum.REGION_SEOUL )); - assertThat(product.getKeywords()) + assertThat(property.getKeywords()) .extracting(ProductKeyword::getKeywordCode) .containsExactlyInAnyOrder( KeywordValueEnum.BENEFIT_GOV_SUBSIDY, KeywordValueEnum.REGION_SEOUL ); - assertThat(product.getKeywords()).contains(existing); + assertThat(property.getKeywords()).contains(existing); } } diff --git a/src/test/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTaskletTest.java b/src/test/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTaskletTest.java index 221b105..b8ac654 100644 --- a/src/test/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTaskletTest.java +++ b/src/test/java/apptive/fin/apicollector/tasklet/DeactivateMissingProductTaskletTest.java @@ -3,7 +3,7 @@ import apptive.fin.apicollector.Mode; import apptive.fin.apicollector.Source; import apptive.fin.apicollector.config.CollectorProperties; -import apptive.fin.apicollector.product.ProductSyncService; +import apptive.fin.apicollector.product.service.ProductSyncService; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.batch.infrastructure.repeat.RepeatStatus; @@ -37,7 +37,7 @@ void skipsWhenModeIsNormalizeOnly() { void deactivatesOntongYouthOnlyWhenSourceIsOntongYouth() { DeactivateMissingProductTasklet tasklet = new DeactivateMissingProductTasklet( productSyncService, - properties(Source.ONTONG_YOUTH, Mode.SYNC, 7) + properties(Source.ONTONG, Mode.SYNC, 7) ); Instant before = Instant.now().minusSeconds(1); @@ -48,7 +48,7 @@ void deactivatesOntongYouthOnlyWhenSourceIsOntongYouth() { ArgumentCaptor thresholdCaptor = ArgumentCaptor.forClass(Instant.class); verify(productSyncService).disableAllUnseenProducts( - org.mockito.ArgumentMatchers.eq(Source.ONTONG_YOUTH), + org.mockito.ArgumentMatchers.eq(Source.ONTONG), thresholdCaptor.capture() ); verify(productSyncService, never()).disableAllUnseenProducts( @@ -74,7 +74,7 @@ void deactivatesFssOnlyWhenSourceIsFss() { org.mockito.ArgumentMatchers.any() ); verify(productSyncService, never()).disableAllUnseenProducts( - org.mockito.ArgumentMatchers.eq(Source.ONTONG_YOUTH), + org.mockito.ArgumentMatchers.eq(Source.ONTONG), org.mockito.ArgumentMatchers.any() ); } @@ -90,7 +90,7 @@ void deactivatesAllSourcesWhenSourceIsAll() { assertThat(result).isEqualTo(RepeatStatus.FINISHED); verify(productSyncService).disableAllUnseenProducts( - org.mockito.ArgumentMatchers.eq(Source.ONTONG_YOUTH), + org.mockito.ArgumentMatchers.eq(Source.ONTONG), org.mockito.ArgumentMatchers.any() ); verify(productSyncService).disableAllUnseenProducts(