diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java index 28c01ef..0bb2e62 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java @@ -28,7 +28,20 @@ public record ComplexFilterResponse( @Builder @Schema(name = "지역 필터", description = "단지가 속한 지역(구) 목록") public record DistrictFilter( - @Schema(description = "고유한 지역(구) 목록", example = "[\"성남시 분당구\", \"서울시 강남구\", \"부산시 해운대구\"]") + @Schema(description = "고유한 지역 목록") + List districts + ) {} + + /** + * 지역 정보 (city별로 그룹화된 districts) + */ + @Builder + @Schema(name = "지역 정보", description = "city별로 그룹화된 district 목록") + public record District( + @Schema(description = "시/도", example = "경기") + String city, + + @Schema(description = "구/시 목록", example = "[\"동두천시\", \"양주시\"]") List districts ) {} diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/ComplexFilterService.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/ComplexFilterService.java index 86736b3..de95deb 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/ComplexFilterService.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/ComplexFilterService.java @@ -1,6 +1,5 @@ package com.pinHouse.server.platform.housing.notice.application.service; -import com.pinHouse.server.platform.Location; import com.pinHouse.server.platform.housing.complex.domain.entity.ComplexDocument; import com.pinHouse.server.platform.housing.complex.domain.entity.Deposit; import com.pinHouse.server.platform.housing.complex.domain.entity.UnitType; @@ -8,8 +7,6 @@ import com.pinHouse.server.platform.housing.facility.domain.entity.FacilityType; import com.pinHouse.server.platform.housing.notice.application.dto.ComplexFilterResponse; import com.pinHouse.server.platform.housing.notice.application.dto.NoticeDetailFilterRequest; -import com.pinHouse.server.platform.pinPoint.domain.entity.PinPoint; -import com.pinHouse.server.platform.pinPoint.domain.repository.PinPointMongoRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -26,8 +23,6 @@ @RequiredArgsConstructor public class ComplexFilterService { - private final PinPointMongoRepository pinPointRepository; - /** * 필터 조건에 따라 단지를 filtered와 nonFiltered로 분리 */ @@ -186,81 +181,198 @@ private boolean passesUnitTypeFilters(UnitType unit, NoticeDetailFilterRequest r } /** - * PinPoint ID로부터 사용자 위치 정보 조회 + * 단지 목록으로부터 필터 정보 계산 + */ + public ComplexFilterResponse buildFilterResponse(List complexes) { + ComplexFilterResponse.DistrictFilter districtFilter = calculateDistrictFilter(complexes); + ComplexFilterResponse.CostFilter costFilter = calculateCostFilter(complexes); + ComplexFilterResponse.AreaFilter areaFilter = calculateAreaFilter(complexes); + + return ComplexFilterResponse.builder() + .districtFilter(districtFilter) + .costFilter(costFilter) + .areaFilter(areaFilter) + .build(); + } + + /** + * 지역 필터 계산 + */ + public ComplexFilterResponse.DistrictFilter calculateDistrictFilter(List complexes) { + // 1. 각 complex에서 city와 district 추출 + List tempDistricts = complexes.stream() + .map(this::parseAddress) + .filter(Objects::nonNull) + .toList(); + + // 2. city별로 그룹화하여 district 목록 생성 + Map> cityToDistricts = tempDistricts.stream() + .collect(Collectors.groupingBy( + TempDistrictInfo::city, + Collectors.mapping( + TempDistrictInfo::district, + Collectors.collectingAndThen( + Collectors.toList(), + list -> list.stream().distinct().sorted().toList() + ) + ) + )); + + // 3. 최종 District 리스트 생성 + List groupedDistricts = cityToDistricts.entrySet().stream() + .map(entry -> ComplexFilterResponse.District.builder() + .city(entry.getKey()) + .districts(entry.getValue()) + .build()) + .sorted(Comparator.comparing(ComplexFilterResponse.District::city)) + .toList(); + + return ComplexFilterResponse.DistrictFilter.builder() + .districts(groupedDistricts) + .build(); + } + + /** + * 임시 District 정보 (그룹화 전) */ - private Location getUserLocation(String pinPointId) { - if (pinPointId == null || pinPointId.isBlank()) { + private record TempDistrictInfo(String city, String district) {} + + /** + * ComplexDocument의 주소 정보를 파싱하여 TempDistrictInfo로 변환 + * + * 변환 규칙: + * 1. 광역시/특별시: "부산시 해운대구" → city: "부산", district: "해운대구" + * 2. 일반시 (구 있음): city: "경기도", county: "청주시 서원구" → city: "경기", district: "청주시 서원구" + * 3. 일반시 (구 없음): city: "경기도", county: "동두천시" → city: "경기", district: "동두천시" + */ + private TempDistrictInfo parseAddress(ComplexDocument complex) { + String county = complex.getCounty(); + String city = complex.getCity(); + + if (county == null || county.isBlank()) { return null; } - try { - PinPoint pinPoint = pinPointRepository.findById(pinPointId) - .orElse(null); + // 광역시 및 특별시 목록 + final Set METRO_CITIES = Set.of( + "서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종" + ); - if (pinPoint != null) { - return pinPoint.getLocation(); + // county를 공백으로 분리 + String[] countyParts = county.trim().split("\\s+"); + + String finalCity; + String finalDistrict; + + // 광역시/특별시 여부 확인 + boolean isMetroCity = false; + String metroCityName = null; + + if (countyParts.length >= 1) { + String cityName = countyParts[0]; + // "광주광역시", "부산광역시", "서울특별시" 등에서 도시 이름 추출 + String cityBase = extractMetroCityName(cityName); + if (cityBase != null && METRO_CITIES.contains(cityBase)) { + isMetroCity = true; + metroCityName = cityBase; } - } catch (Exception e) { - log.error("Failed to fetch PinPoint: {}", pinPointId, e); } - return null; + if (isMetroCity) { + // 광역시/특별시인 경우 + finalCity = metroCityName; + + if (countyParts.length >= 2) { + // "부산시 해운대구" 또는 "광주광역시 서구" → city: "부산", district: "해운대구" + finalDistrict = countyParts[1]; + } else { + // "대구광역시"만 있는 경우 → city: "대구", district: "대구광역시" (원본 유지) + finalDistrict = county; + } + } else { + // 일반시인 경우 + // city 필드가 광역시일 수도 있으므로 확인 + String cityBase = extractMetroCityName(city); + if (cityBase != null && METRO_CITIES.contains(cityBase)) { + // city 필드가 "대구광역시"인 경우 → city: "대구" + finalCity = cityBase; + } else { + // city 필드가 도인 경우 → "경기도" → "경기" + finalCity = shortenProvinceName(city); + } + finalDistrict = county; + } + + return new TempDistrictInfo(finalCity, finalDistrict); } /** - * Haversine formula를 사용한 두 지점 간 거리 계산 (km) + * 광역시/특별시 이름 추출 + * "광주광역시" → "광주" + * "부산광역시" → "부산" + * "서울특별시" → "서울" + * "부산시" → "부산" */ - private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { - final int EARTH_RADIUS_KM = 6371; + private String extractMetroCityName(String cityName) { + if (cityName == null || cityName.isBlank()) { + return null; + } + + // "광역시" 제거 + if (cityName.endsWith("광역시")) { + return cityName.substring(0, cityName.length() - 3); + } - double dLat = Math.toRadians(lat2 - lat1); - double dLon = Math.toRadians(lon2 - lon1); + // "특별시" 제거 + if (cityName.endsWith("특별시")) { + return cityName.substring(0, cityName.length() - 3); + } - double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); + // "특별자치시" 제거 (세종) + if (cityName.endsWith("특별자치시")) { + return cityName.substring(0, cityName.length() - 5); + } - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + // "시" 제거 + if (cityName.endsWith("시")) { + return cityName.substring(0, cityName.length() - 1); + } - return EARTH_RADIUS_KM * c; + return cityName; } /** - * 단지 목록으로부터 필터 정보 계산 + * 도 이름 축약 + * "경기도" → "경기" + * "충청북도" → "충북" + * "경상남도" → "경남" */ - public ComplexFilterResponse buildFilterResponse(List complexes) { - ComplexFilterResponse.DistrictFilter districtFilter = calculateDistrictFilter(complexes); - ComplexFilterResponse.CostFilter costFilter = calculateCostFilter(complexes); - ComplexFilterResponse.AreaFilter areaFilter = calculateAreaFilter(complexes); + private String shortenProvinceName(String province) { + if (province == null || province.isBlank()) { + return ""; + } - return ComplexFilterResponse.builder() - .districtFilter(districtFilter) - .costFilter(costFilter) - .areaFilter(areaFilter) - .build(); - } + // "도" 제거 + if (province.endsWith("도")) { + String base = province.substring(0, province.length() - 1); - /** - * 지역 필터 계산 - */ - private ComplexFilterResponse.DistrictFilter calculateDistrictFilter(List complexes) { - List uniqueDistricts = complexes.stream() - .map(ComplexDocument::getCounty) - .filter(Objects::nonNull) - .filter(county -> !county.isBlank()) - .distinct() - .sorted() - .toList(); + // "충청북", "충청남", "경상북", "경상남", "전라북", "전라남" → 첫글자 + 마지막글자 + if (base.startsWith("충청") || base.startsWith("경상") || base.startsWith("전라")) { + // "충청북" → "충북", "경상남" → "경남" + return base.charAt(0) + String.valueOf(base.charAt(base.length() - 1)); + } - return ComplexFilterResponse.DistrictFilter.builder() - .districts(uniqueDistricts) - .build(); + // 그 외 도는 "도"만 제거 ("경기도" → "경기", "강원도" → "강원") + return base; + } + + return province; } /** * 가격 필터 계산 */ - private ComplexFilterResponse.CostFilter calculateCostFilter(List complexes) { + public ComplexFilterResponse.CostFilter calculateCostFilter(List complexes) { // 모든 unitType의 보증금(deposit.total) 수집 List allPrices = complexes.stream() .flatMap(complex -> complex.getUnitTypes().stream()) @@ -348,7 +460,7 @@ private List calculatePriceDistribution /** * 면적(타입코드) 필터 계산 */ - private ComplexFilterResponse.AreaFilter calculateAreaFilter(List complexes) { + public ComplexFilterResponse.AreaFilter calculateAreaFilter(List complexes) { List uniqueTypeCodes = complexes.stream() .flatMap(complex -> complex.getUnitTypes().stream()) .map(UnitType::getTypeCode) diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java index bd6274c..ab48a6b 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java @@ -179,6 +179,51 @@ public ComplexFilterResponse getComplexFilters(String noticeId) { return complexFilterService.buildFilterResponse(complexes); } + /// 공고의 단지 지역 필터 정보 조회 + @Override + @Transactional(readOnly = true) + public ComplexFilterResponse.DistrictFilter getDistrictFilter(String noticeId) { + + /// 공고 존재 확인 + loadNotice(noticeId); + + /// 단지 목록 조회 + List complexes = complexService.loadComplexes(noticeId); + + /// 지역 필터 계산 + return complexFilterService.calculateDistrictFilter(complexes); + } + + /// 공고의 단지 비용 필터 정보 조회 + @Override + @Transactional(readOnly = true) + public ComplexFilterResponse.CostFilter getCostFilter(String noticeId) { + + /// 공고 존재 확인 + loadNotice(noticeId); + + /// 단지 목록 조회 + List complexes = complexService.loadComplexes(noticeId); + + /// 비용 필터 계산 + return complexFilterService.calculateCostFilter(complexes); + } + + /// 공고의 단지 방타입 필터 정보 조회 + @Override + @Transactional(readOnly = true) + public ComplexFilterResponse.AreaFilter getAreaFilter(String noticeId) { + + /// 공고 존재 확인 + loadNotice(noticeId); + + /// 단지 목록 조회 + List complexes = complexService.loadComplexes(noticeId); + + /// 방타입 필터 계산 + return complexFilterService.calculateAreaFilter(complexes); + } + /// 공고의 필터 조건에 맞는 단지 개수 조회 @Override @Transactional(readOnly = true) diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java index 81dd293..65e5238 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java @@ -33,6 +33,15 @@ public interface NoticeUseCase { /// 공고의 단지 필터링 정보 조회 (지역, 가격, 면적) ComplexFilterResponse getComplexFilters(String noticeId); + /// 공고의 단지 지역 필터 정보 조회 + ComplexFilterResponse.DistrictFilter getDistrictFilter(String noticeId); + + /// 공고의 단지 비용 필터 정보 조회 + ComplexFilterResponse.CostFilter getCostFilter(String noticeId); + + /// 공고의 단지 방타입 필터 정보 조회 + ComplexFilterResponse.AreaFilter getAreaFilter(String noticeId); + /// 공고의 필터 조건에 맞는 단지 개수 조회 int countFilteredComplexes(String noticeId, NoticeDetailFilterRequest request); diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java index 6ac1184..a00c634 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java @@ -87,6 +87,42 @@ public ApiResponse getComplexFilters( return ApiResponse.ok(response); } + /// 공고의 단지 지역 필터 정보 조회 + @GetMapping("/{noticeId}/filter/districts") + public ApiResponse getDistrictFilter( + @PathVariable String noticeId) { + + /// 서비스 계층 + var response = service.getDistrictFilter(noticeId); + + /// 리턴 + return ApiResponse.ok(response); + } + + /// 공고의 단지 비용 필터 정보 조회 + @GetMapping("/{noticeId}/filter/cost") + public ApiResponse getCostFilter( + @PathVariable String noticeId) { + + /// 서비스 계층 + var response = service.getCostFilter(noticeId); + + /// 리턴 + return ApiResponse.ok(response); + } + + /// 공고의 단지 방타입 필터 정보 조회 + @GetMapping("/{noticeId}/filter/area") + public ApiResponse getAreaFilter( + @PathVariable String noticeId) { + + /// 서비스 계층 + var response = service.getAreaFilter(noticeId); + + /// 리턴 + return ApiResponse.ok(response); + } + /// 공고의 필터 조건에 맞는 단지 개수 조회 @PostMapping("/{noticeId}/filter/count") public ApiResponse countFilteredComplexes( diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java index 6b255e5..a887226 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java @@ -59,7 +59,37 @@ ApiResponse getNotice( description = "공고에 포함된 단지들의 지역, 가격, 면적 필터링 정보를 제공합니다. 프론트엔드 필터 UI 구성에 사용됩니다." ) ApiResponse getComplexFilters( - @Parameter(description = "공고 ID", example = "18214") + @Parameter(description = "공고 ID", example = "19347") + @PathVariable String noticeId + ); + + /// 지역 필터 정보 조회 + @Operation( + summary = "공고의 단지 지역 필터 정보 조회 API", + description = "공고에 포함된 단지들의 지역(city별로 그룹화된 districts) 필터링 정보를 제공합니다." + ) + ApiResponse getDistrictFilter( + @Parameter(description = "공고 ID", example = "19347") + @PathVariable String noticeId + ); + + /// 비용 필터 정보 조회 + @Operation( + summary = "공고의 단지 비용 필터 정보 조회 API", + description = "공고에 포함된 단지들의 가격 범위(최소/최대/평균 가격, 가격 분포) 필터링 정보를 제공합니다." + ) + ApiResponse getCostFilter( + @Parameter(description = "공고 ID", example = "19347") + @PathVariable String noticeId + ); + + /// 방타입 필터 정보 조회 + @Operation( + summary = "공고의 단지 방타입 필터 정보 조회 API", + description = "공고에 포함된 단지들의 타입코드(면적 타입) 필터링 정보를 제공합니다." + ) + ApiResponse getAreaFilter( + @Parameter(description = "공고 ID", example = "19347") @PathVariable String noticeId );