From d2439b8b9ce9e29f631505d85a8d8f5358474499 Mon Sep 17 00:00:00 2001 From: leejunyeong <140596682+jjuny0326@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:49:57 +0900 Subject: [PATCH] =?UTF-8?q?infra(be):=20develop=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20main=20=EB=B0=98=EC=98=81=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/ReportDetailResponse.java | 36 +++- .../report/service/RiskEvaluationService.java | 38 ++++- .../external/client/RegistryClient.java | 14 ++ .../external/client/RegistryDataParser.java | 160 +++++++++++++++++- .../ReportDetailResponseContractTest.java | 32 ++++ .../service/RiskEvaluationServiceTest.java | 84 +++++++++ .../client/RegistryDataParserTest.java | 80 +++++++++ 7 files changed, 432 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/com/secondzip/backend/report/dto/response/ReportDetailResponse.java b/backend/src/main/java/com/secondzip/backend/report/dto/response/ReportDetailResponse.java index b720f0d..82f522f 100644 --- a/backend/src/main/java/com/secondzip/backend/report/dto/response/ReportDetailResponse.java +++ b/backend/src/main/java/com/secondzip/backend/report/dto/response/ReportDetailResponse.java @@ -6,6 +6,8 @@ import com.secondzip.backend.report.enums.RiskLevel; import lombok.Getter; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.List; import java.util.Objects; @@ -55,6 +57,38 @@ public class ReportDetailResponse { */ private final String basePriceSource; + /** + * 전세가율(전세보증금 / 위험도 판정 기준가). + * 예: 0.75는 75%를 뜻한다. 계산할 가격이 없으면 null이다. + */ + public Double getRatio() { + Long basePrice = resolveBasePrice(); + if (deposit == null || deposit < 0L || basePrice == null || basePrice <= 0L) { + return null; + } + return BigDecimal.valueOf(deposit) + .divide(BigDecimal.valueOf(basePrice), 4, RoundingMode.HALF_UP) + .doubleValue(); + } + + private Long resolveBasePrice() { + if ("RECENT_SALE_PRICE".equals(basePriceSource)) { + return recentSalePrice; + } + if ("OFFICIAL_PRICE_CONVERTED".equals(basePriceSource) + && officialPrice != null && officialPrice > 0L) { + try { + return BigDecimal.valueOf(officialPrice) + .multiply(new BigDecimal("1.4")) + .setScale(0, RoundingMode.DOWN) + .longValueExact(); + } catch (ArithmeticException e) { + return null; + } + } + return null; + } + /** * 실거래가 필드가 추가되기 전 호출부(기존 테스트 등) 호환용 생성자. * 가격 정보 3개를 모두 null로 채움. @@ -155,4 +189,4 @@ public RiskLevel getFraudResult() { .toList(); return levels.isEmpty() ? null : RiskLevel.worstOf(levels); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/secondzip/backend/report/service/RiskEvaluationService.java b/backend/src/main/java/com/secondzip/backend/report/service/RiskEvaluationService.java index d9d7463..3b4bd49 100644 --- a/backend/src/main/java/com/secondzip/backend/report/service/RiskEvaluationService.java +++ b/backend/src/main/java/com/secondzip/backend/report/service/RiskEvaluationService.java @@ -271,7 +271,11 @@ private Map buildingUseEvidence(BuildingData building) { // 프론트 화면에 표시할 정규화된 건축물 유형 evidence.put( "buildingUse", - buildingTypeLabel(buildingType, rawBuildingUse) + buildingTypeLabel( + buildingType, + rawBuildingUse, + hasBuildingLevelNonResidentialUse(building) + ) ); // 건축물대장에서 받은 원래 용도 @@ -291,7 +295,8 @@ private Map buildingUseEvidence(BuildingData building) { private String buildingTypeLabel( String buildingType, - String fallback + String fallback, + boolean hasBuildingLevelNonResidentialUse ) { if (buildingType == null) { return fallback; @@ -299,7 +304,15 @@ private String buildingTypeLabel( return switch (buildingType) { case "APARTMENT" -> "아파트"; - case "OFFICETEL" -> "오피스텔"; + // 건물 전체가 업무시설로 분류됐다는 이유만으로 계약 대상 호까지 + // 업무용이라고 단정하지 않는다. 해당 호 원문에 업무용/비주거가 + // 명시된 경우에만 화면에도 "업무용 오피스텔"로 표시한다. + case "OFFICETEL" -> isExplicitlyNonResidential(fallback) + ? "업무용 오피스텔" + : classifyBuildingUse(fallback) == BuildingUseKind.UNKNOWN + || hasBuildingLevelNonResidentialUse + ? "오피스텔(용도 확인 필요)" + : "오피스텔"; case "MULTI_HOUSEHOLD" -> "연립·다세대주택"; case "MULTI_FAMILY" -> "다가구주택"; case "SINGLE_FAMILY" -> "단독주택"; @@ -318,8 +331,7 @@ private BuildingUseKind classifyBuildingUse(String use) { // 오피스텔은 법정 주용도가 보통 '업무시설'이다. 따라서 명시적인 // 주거/비주거 표기를 먼저 분리하지 않으면 실제 주거용 오피스텔도 // 전부 비주거로 오판한다. - if (normalized.contains("비주거") - || normalized.contains("업무용")) { + if (isExplicitlyNonResidential(normalized)) { return BuildingUseKind.NON_RESIDENTIAL; } @@ -340,9 +352,13 @@ private BuildingUseKind classifyBuildingUse(String use) { if (normalized.contains("주거용오피스텔")) { return BuildingUseKind.RESIDENTIAL; } - // '오피스텔'만으로는 실제 주거용인지 업무용인지 확정할 수 없다. + // 업무용/비주거 표기가 없는 일반 오피스텔은 주거용으로 간주한다. + // 다만 '업무시설, 오피스텔'처럼 업무시설이 함께 적힌 경우에는 + // 실제 전유부 용도를 확정할 수 없으므로 확인 필요로 남긴다. if (normalized.contains("오피스텔")) { - return BuildingUseKind.UNKNOWN; + return normalized.contains("업무시설") + ? BuildingUseKind.UNKNOWN + : BuildingUseKind.RESIDENTIAL; } if (normalized.contains("업무시설")) { return BuildingUseKind.NON_RESIDENTIAL; @@ -357,6 +373,14 @@ private BuildingUseKind classifyBuildingUse(String use) { : BuildingUseKind.UNKNOWN; } + private boolean isExplicitlyNonResidential(String use) { + if (use == null || use.isBlank()) { + return false; + } + String normalized = use.replaceAll("\\s+", ""); + return normalized.contains("비주거") || normalized.contains("업무용"); + } + private enum BuildingUseKind { RESIDENTIAL, NON_RESIDENTIAL, diff --git a/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryClient.java b/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryClient.java index 2d18c01..e14de61 100644 --- a/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryClient.java +++ b/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryClient.java @@ -333,6 +333,20 @@ public synchronized RegistryData getRegistryData( data, detailAddress, requestBody, target.legalDongName()); return null; } + if ((registryData.getOwnerNames() == null + || registryData.getOwnerNames().isEmpty()) + && (registryData.getOwnerName() == null + || registryData.getOwnerName().isBlank())) { + // 이름·주민번호 같은 값은 남기지 않고 key와 자료형만 기록한다. + // 실제 응답에서 새 소유자 필드가 추가돼도 개인정보 노출 없이 + // 파서 보완에 필요한 구조를 확인할 수 있다. + log.warn( + "CODEF 등기부 소유자 파싱 결과가 비어있습니다: " + + "documentType={}, shape={}", + documentType, + describeShape(data, 0) + ); + } // 과금이 끝난 결과이므로 반드시 캐시에 남긴다. putCached(cacheKey, registryData); diff --git a/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryDataParser.java b/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryDataParser.java index 0798751..33b516f 100644 --- a/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryDataParser.java +++ b/backend/src/main/java/com/secondzip/backend/report/service/external/client/RegistryDataParser.java @@ -24,10 +24,10 @@ public class RegistryDataParser { Pattern.compile("채권최고액\\s*금?\\s*([0-9][0-9,]*)\\s*원?"); private static final Pattern MORTGAGE_MARKER = Pattern.compile("채권최고액"); private static final Pattern OWNER_PREFIX = Pattern.compile( - "(? data, String detailAddress) { return null; } - OwnerExtraction owners = extractOwners(currentText, usesHistory); + OwnerExtraction owners = mergeOwners( + extractOwners(currentText, usesHistory), + extractStructuredOwners(entries, "resRegistrationSumList") + ); if (owners.names().isEmpty() && !historyText.isBlank() && !usesHistory) { owners = extractOwners(historyText, true); } @@ -371,6 +374,152 @@ private OwnerExtraction extractOwners(String text, boolean latestOnly) { return accumulator.result(); } + /** + * 주요 등기사항 요약에 소유자명이 별도 JSON 필드로 내려오는 응답을 보완한다. + * + * 기존 파서는 map의 값만 이어 붙인 뒤 "소유자 홍길동" 같은 문장을 찾기 때문에 + * resOwnerName: "홍길동"처럼 역할이 key에만 있는 응답에서는 이름을 + * 놓친다. 현재 유효한 권리만 담는 요약 노드 안에서만 구조화 필드를 읽어 과거 + * 소유자를 현재 소유자로 잘못 합치는 일을 막는다. + */ + private OwnerExtraction extractStructuredOwners(Object node, String targetKey) { + OwnerAccumulator accumulator = new OwnerAccumulator(false); + collectStructuredOwnersFromNamedNode(node, targetKey, accumulator); + return accumulator.result(); + } + + private void collectStructuredOwnersFromNamedNode( + Object node, + String targetKey, + OwnerAccumulator accumulator + ) { + if (node instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (targetKey.equals(String.valueOf(entry.getKey()))) { + collectStructuredOwnerValues(value, null, accumulator); + } else { + collectStructuredOwnersFromNamedNode( + value, + targetKey, + accumulator + ); + } + } + } else if (node instanceof Collection collection) { + collection.forEach(item -> collectStructuredOwnersFromNamedNode( + item, + targetKey, + accumulator + )); + } + } + + private void collectStructuredOwnerValues( + Object node, + String inheritedRole, + OwnerAccumulator accumulator + ) { + if (node instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object value = entry.getValue(); + String explicitRole = ownerRoleFromKey(key); + String role = explicitRole != null ? explicitRole : inheritedRole; + + if (value instanceof Map || value instanceof Collection) { + collectStructuredOwnerValues(value, role, accumulator); + continue; + } + if (role == null || !isStructuredOwnerNameKey(key, explicitRole)) { + continue; + } + String ownerName = cleanOwnerName( + value instanceof CharSequence ? value.toString() : null + ); + if (isPlausibleOwnerName(ownerName)) { + accumulator.add(role, ownerName); + } + } + } else if (node instanceof Collection collection) { + collection.forEach(item -> collectStructuredOwnerValues( + item, + inheritedRole, + accumulator + )); + } + } + + private String ownerRoleFromKey(String rawKey) { + String key = normalizeFieldKey(rawKey); + if (key.contains("수탁자") || key.contains("trustee")) { + return "수탁자"; + } + if (key.contains("공유자") || key.contains("coowner") + || key.contains("jointowner") || key.contains("sharedowner")) { + return "공유자"; + } + if (key.contains("소유자") || key.contains("소유권자") + || key.contains("owner")) { + return "소유자"; + } + return null; + } + + private boolean isStructuredOwnerNameKey( + String rawKey, + String explicitRole + ) { + String key = normalizeFieldKey(rawKey); + if (key.contains("type") || key.contains("code") || key.endsWith("no") + || key.contains("number") || key.contains("id") + || key.contains("address") || key.contains("addr") + || key.contains("ratio") || key.contains("share") + || key.contains("count") || key.endsWith("yn")) { + return false; + } + if (key.endsWith("name") || key.endsWith("nm") + || key.equals("name") || key.equals("nm")) { + return true; + } + // resOwner, 소유자 처럼 key 자체가 역할이자 이름인 경우. + return explicitRole != null + && (key.endsWith("owner") || key.endsWith("trustee") + || key.endsWith("소유자") || key.endsWith("소유권자") + || key.endsWith("공유자") || key.endsWith("수탁자")); + } + + private String normalizeFieldKey(String rawKey) { + return rawKey == null + ? "" + : rawKey.replaceAll("[^A-Za-z가-힣]", "") + .toLowerCase(Locale.ROOT); + } + + private boolean isPlausibleOwnerName(String ownerName) { + if (ownerName == null || ownerName.length() < 2 || ownerName.length() > 100) { + return false; + } + String normalized = ownerName.replaceAll("\\s+", ""); + return !normalized.matches("(?i)(?:true|false|yes|no|individual|corporation)") + && !normalized.matches("[0-9*\\-]+") + && !normalized.matches("[YN]"); + } + + private OwnerExtraction mergeOwners( + OwnerExtraction first, + OwnerExtraction second + ) { + Set names = new TreeSet<>(); + if (first != null) names.addAll(first.names()); + if (second != null) names.addAll(second.names()); + return new OwnerExtraction( + names, + first != null && first.hasTrusteeRole() + || second != null && second.hasTrusteeRole() + ); + } + private String cleanOwnerName(String source) { if (source == null) { return null; @@ -737,7 +886,10 @@ private void add(String role, String name) { if (name == null) { return; } - if (latestOnly && ("소유자".equals(role) || "수탁자".equals(role))) { + if (latestOnly && ("소유자".equals(role) + || "소유권자".equals(role) + || "명의인".equals(role) + || "수탁자".equals(role))) { names.clear(); hasTrusteeRole = false; } diff --git a/backend/src/test/java/com/secondzip/backend/report/dto/response/ReportDetailResponseContractTest.java b/backend/src/test/java/com/secondzip/backend/report/dto/response/ReportDetailResponseContractTest.java index 631b094..7628b81 100644 --- a/backend/src/test/java/com/secondzip/backend/report/dto/response/ReportDetailResponseContractTest.java +++ b/backend/src/test/java/com/secondzip/backend/report/dto/response/ReportDetailResponseContractTest.java @@ -133,6 +133,8 @@ void keepsLegacyReportsAsExplicitNull() throws Exception { assertTrue(json.get("officialPrice").isNull()); assertTrue(json.has("basePriceSource")); assertTrue(json.get("basePriceSource").isNull()); + assertTrue(json.has("ratio")); + assertTrue(json.get("ratio").isNull()); } @Test @@ -168,6 +170,36 @@ void exposesPriceFields() throws Exception { assertTrue(json.has("basePriceSource"), "basePriceSource가 있어야 한다"); assertEquals("RECENT_SALE_PRICE", json.get("basePriceSource").asText()); + + assertTrue(json.has("ratio"), "ratio가 있어야 한다"); + assertEquals(0.5556, json.get("ratio").asDouble(), 0.00001); + } + + @Test + @DisplayName("실거래가가 없으면 공시가격 140% 환산값으로 전세가율을 계산한다") + void calculatesRatioFromConvertedOfficialPrice() throws Exception { + ReportDetailResponse response = new ReportDetailResponse( + 1L, + "서울 강남구 테헤란로 152", + null, + 490_000_000L, + RiskLevel.CAUTION, + false, + "APARTMENT", + false, + List.of(), + List.of(), + List.of(), + null, + 500_000_000L, + "OFFICIAL_PRICE_CONVERTED" + ); + + JsonNode json = objectMapper.readTree( + objectMapper.writeValueAsString(response) + ); + + assertEquals(0.7, json.get("ratio").asDouble(), 0.00001); } @Test diff --git a/backend/src/test/java/com/secondzip/backend/report/service/RiskEvaluationServiceTest.java b/backend/src/test/java/com/secondzip/backend/report/service/RiskEvaluationServiceTest.java index 153bf74..91e58ad 100644 --- a/backend/src/test/java/com/secondzip/backend/report/service/RiskEvaluationServiceTest.java +++ b/backend/src/test/java/com/secondzip/backend/report/service/RiskEvaluationServiceTest.java @@ -299,6 +299,90 @@ void residentialUnitInMixedUseBuildingIsCautionNotDanger() { ); } + @Test + @DisplayName("업무용이 해당 호 원문에 명시된 오피스텔만 업무용으로 표시하고 위험 판정한다") + void explicitlyBusinessOfficetelIsLabeledAndJudgedDanger() { + BuildingData building = cleanApartment(); + building.setBuildingType("OFFICETEL"); + building.setBuildingUse("업무용 오피스텔"); + + CheckResultDTO result = service.evaluate( + cleanRegistry(), building, cleanPrice(), 300_000_000L, + "서울특별시 강남구 테헤란로 152" + ).getCheckResultDTOS().stream() + .filter(item -> item.getCheckType() == CheckType.BUILDING_USE) + .findFirst() + .orElseThrow(); + + assertEquals(RiskLevel.DANGER, result.getRiskLevel()); + assertEquals("업무용 오피스텔", result.getEvidence().get("buildingUse")); + assertEquals("업무용 오피스텔", result.getEvidence().get("buildingUseRaw")); + } + + @Test + @DisplayName("건물 전체에 업무시설이 있어도 해당 호가 업무용으로 명시되지 않으면 오피스텔 주의로 남긴다") + void ambiguousOfficetelInMixedUseBuildingStaysCaution() { + BuildingData building = cleanApartment(); + building.setBuildingType("OFFICETEL"); + building.setBuildingUse("오피스텔, 기계실, 전기실, 통신실"); + building.setBuildingLevelNonResidentialUses("업무시설, 오피스텔및근린생활시설"); + + CheckResultDTO result = service.evaluate( + cleanRegistry(), building, cleanPrice(), 300_000_000L, + "서울특별시 강남구 테헤란로 152" + ).getCheckResultDTOS().stream() + .filter(item -> item.getCheckType() == CheckType.BUILDING_USE) + .findFirst() + .orElseThrow(); + + assertEquals(RiskLevel.CAUTION, result.getRiskLevel()); + assertEquals(DataStatus.VERIFIED, result.getDataStatus()); + assertEquals("오피스텔(용도 확인 필요)", result.getEvidence().get("buildingUse")); + } + + @Test + @DisplayName("업무용 표기가 없는 일반 오피스텔은 주거용으로 간주한다") + void plainOfficetelIsSafe() { + BuildingData building = cleanApartment(); + building.setBuildingType("OFFICETEL"); + building.setBuildingUse("오피스텔"); + + CheckResultDTO result = service.evaluate( + cleanRegistry(), building, cleanPrice(), 300_000_000L, + "서울특별시 강남구 테헤란로 152" + ).getCheckResultDTOS().stream() + .filter(item -> item.getCheckType() == CheckType.BUILDING_USE) + .findFirst() + .orElseThrow(); + + assertEquals(RiskLevel.SAFE, result.getRiskLevel()); + assertEquals(DataStatus.VERIFIED, result.getDataStatus()); + assertEquals("오피스텔", result.getEvidence().get("buildingUse")); + } + + @Test + @DisplayName("업무시설과 오피스텔이 함께 적힌 전유부는 용도 확인 필요로 표시한다") + void ambiguousBusinessFacilityOfficetelNeedsUseConfirmation() { + BuildingData building = cleanApartment(); + building.setBuildingType("OFFICETEL"); + building.setBuildingUse("업무시설, 오피스텔"); + + CheckResultDTO result = service.evaluate( + cleanRegistry(), building, cleanPrice(), 300_000_000L, + "서울특별시 강남구 테헤란로 152" + ).getCheckResultDTOS().stream() + .filter(item -> item.getCheckType() == CheckType.BUILDING_USE) + .findFirst() + .orElseThrow(); + + assertEquals(RiskLevel.CAUTION, result.getRiskLevel()); + assertEquals(DataStatus.UNVERIFIED, result.getDataStatus()); + assertEquals( + "오피스텔(용도 확인 필요)", + result.getEvidence().get("buildingUse") + ); + } + @Test @DisplayName("계약 대상 호 자체가 근린생활시설이면 위험으로 판정한다") void nonResidentialUnitIsDanger() { diff --git a/backend/src/test/java/com/secondzip/backend/report/service/external/client/RegistryDataParserTest.java b/backend/src/test/java/com/secondzip/backend/report/service/external/client/RegistryDataParserTest.java index 69a1e83..26746e7 100644 --- a/backend/src/test/java/com/secondzip/backend/report/service/external/client/RegistryDataParserTest.java +++ b/backend/src/test/java/com/secondzip/backend/report/service/external/client/RegistryDataParserTest.java @@ -292,6 +292,86 @@ void validatesUnitIdentityWhenResponseExposesDongAndHo() { assertNull(parser.parse(differentUnit, "가동 B101호")); } + @Test + void parsesOwnerFromStructuredSummaryField() { + Map response = Map.of( + "resRegisterEntriesList", + List.of(Map.of( + "resRegistrationSumList", + List.of(Map.of( + "resOwnerName", "홍길동", + "resOwnerType", "INDIVIDUAL", + "resContents", "갑구 현재 권리관계" + )), + "resRegistrationHisList", List.of() + )) + ); + + RegistryData result = parser.parse(response); + + assertEquals(List.of("홍길동"), result.getOwnerNames()); + assertEquals("홍길동", result.getOwnerName()); + assertEquals("INDIVIDUAL", result.getOwnerType()); + } + + @Test + void parsesNestedStructuredOwnerAndMultipleCoOwners() { + Map response = Map.of( + "resRegisterEntriesList", + List.of(Map.of( + "resRegistrationSumList", + List.of( + Map.of( + "resOwnerInfo", + Map.of("resName", "주식회사 세컨드집") + ), + Map.of("resCoOwnerName", "홍길동"), + Map.of("resCoOwnerName", "김철수") + ), + "resRegistrationHisList", List.of() + )) + ); + + RegistryData result = parser.parse(response); + + assertEquals( + List.of("김철수", "주식회사 세컨드집", "홍길동"), + result.getOwnerNames() + ); + assertEquals("CORPORATION", result.getOwnerType()); + } + + @Test + void doesNotTreatStructuredOwnerMetadataAsAName() { + Map response = Map.of( + "resRegisterEntriesList", + List.of(Map.of( + "resRegistrationSumList", + List.of(Map.of( + "resOwnerType", "INDIVIDUAL", + "resOwnerCode", "01", + "resContents", "채권최고액 금 100,000,000원" + )), + "resRegistrationHisList", List.of() + )) + ); + + RegistryData result = parser.parse(response); + + assertTrue(result.getOwnerNames().isEmpty()); + assertNull(result.getOwnerName()); + assertNull(result.getOwnerType()); + } + + @Test + void parsesAdditionalCurrentOwnerRoleLabels() { + RegistryData owner = parser.parse(data("소유권자 홍길동", "")); + RegistryData nominee = parser.parse(data("김철수 (명의인)", "")); + + assertEquals("홍길동", owner.getOwnerName()); + assertEquals("김철수", nominee.getOwnerName()); + } + private Map data( String summaryText, String historyText