Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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로 채움.
Expand Down Expand Up @@ -155,4 +189,4 @@ public RiskLevel getFraudResult() {
.toList();
return levels.isEmpty() ? null : RiskLevel.worstOf(levels);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,11 @@ private Map<String, Object> buildingUseEvidence(BuildingData building) {
// 프론트 화면에 표시할 정규화된 건축물 유형
evidence.put(
"buildingUse",
buildingTypeLabel(buildingType, rawBuildingUse)
buildingTypeLabel(
buildingType,
rawBuildingUse,
hasBuildingLevelNonResidentialUse(building)
)
);

// 건축물대장에서 받은 원래 용도
Expand All @@ -291,15 +295,24 @@ private Map<String, Object> buildingUseEvidence(BuildingData building) {

private String buildingTypeLabel(
String buildingType,
String fallback
String fallback,
boolean hasBuildingLevelNonResidentialUse
) {
if (buildingType == null) {
return fallback;
}

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" -> "단독주택";
Expand All @@ -318,8 +331,7 @@ private BuildingUseKind classifyBuildingUse(String use) {
// 오피스텔은 법정 주용도가 보통 '업무시설'이다. 따라서 명시적인
// 주거/비주거 표기를 먼저 분리하지 않으면 실제 주거용 오피스텔도
// 전부 비주거로 오판한다.
if (normalized.contains("비주거")
|| normalized.contains("업무용")) {
if (isExplicitlyNonResidential(normalized)) {
return BuildingUseKind.NON_RESIDENTIAL;
}

Expand All @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"(?<!\\S)(소유자|공유자|수탁자)\\s*[::]?\\s*([^\\n|]+)"
"(?<!\\S)(소유자|공유자|수탁자|소유권자|명의인)\\s*[::]?\\s*([^\\n|]+)"
);
private static final Pattern OWNER_SUFFIX_ROLE = Pattern.compile(
"(?m)^\\s*(.+?)\\s*\\((소유자|공유자|수탁자)\\)\\s*(?:$|[^\\n]*)"
"(?m)^\\s*(.+?)\\s*\\((소유자|공유자|수탁자|소유권자|명의인)\\)\\s*(?:$|[^\\n]*)"
);
private static final Pattern OWNER_TRAILING_FIELDS = Pattern.compile(
"\\s+(?:주민등록번호|법인등록번호|주소|지분|순위번호|등기원인"
Expand Down Expand Up @@ -104,7 +104,10 @@ public RegistryData parse(Map<String, Object> 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);
}
Expand Down Expand Up @@ -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<String> 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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading