From 05d57c259778f7a8f22fb05756422d4ef4410a25 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 5 Oct 2026 19:14:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EC=84=A4=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=95=84=EB=8F=84=20=EC=9E=85?= =?UTF-8?q?=EC=86=8C=20=ED=99=95=EB=A5=A0=EC=9D=84=20=EB=B3=BC=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 입소 확률은 GET /facilities/{facilityId}/admission-forecast 로만 볼 수 있었다. 시설을 먼저 골라야 숫자가 나온다는 뜻인데, 이 기능이 필요한 사람은 대개 어디를 고를지 모르는 쪽이다. 구 안의 어린이집을 하나씩 눌러 확률을 모아야 하니 실질적으로 그 숫자를 영영 보지 못한다. 질문을 뒤집어 지역·월령으로 받고 확률 순으로 돌려준다. GET /facilities/admission-candidates?region=성동구&childAgeMonths=18 - 계산은 시설 상세와 같은 AdmissionForecastCalculator 를 쓴다. 상세에서 본 확률과 목록에서 본 확률이 다르면 둘 다 믿을 수 없게 된다. 반 이름 규칙도 양쪽이 각자 들고 있으면 갈라지므로 계산기로 끌어올렸다. - 관측이 모자란 시설은 확률을 지어내지 않고 센다(notEnoughDataCount). 후보 40곳 중 3곳만 나왔을 때 그 사실을 숨기면 "이 동네에 어린이집이 3곳뿐인가" 로 읽힌다. - 확률이 같으면 관측이 많은 쪽을 위에 둔다. 같은 숫자라도 근거가 두꺼운 편이 낫다. - 구 단위로 어린이집이 수백 곳이라 시설마다 관측을 따로 읽으면 추천 한 번에 수백 번 왕복한다. findHistories 로 한 번에 읽고 시설별로 나눈다(테스트로 호출 수를 고정). - 이 기간 예측이 과거에 얼마나 맞았는지를 함께 준다. 확률을 나열하면서 그 확률이 믿을 만한지를 말하지 않으면 숫자가 혼자 커진다. 검증 실패는 400 이다. 처음에는 CareServiceException 으로 던졌는데 그 예외의 기본 매핑이 500 이라, 음수 월령 같은 잘못된 요청에 서버 오류와 운영 알림이 함께 나갔다. #132 2단계에서 고친 것과 같은 뿌리의 문제다. 실패하는 테스트로 500 을 확인한 뒤 BusinessException(ErrorCode.INVALID_INPUT)으로 바꿨고, ClientErrorStatusTest 에 "값이 범위를 벗어난 파라미터는 400" 을 남겨 다음 API 에서 되살아나지 않게 했다. - OpenAPI: 231 → 232 경로, 158 → 160 스키마 - 전체 테스트 617개, 실패 0 Closes #139 --- docs/api/openapi.json | 149 ++++++++++++++ .../facility/app/CareFacilityFacade.java | 8 + .../controller/CareFacilityController.java | 15 ++ .../response/AdmissionCandidateResponse.java | 71 +++++++ .../repository/CareFacilityRepository.java | 15 ++ .../FacilityCapacitySnapshotRepository.java | 14 ++ .../service/AdmissionCandidateService.java | 143 +++++++++++++ .../service/AdmissionForecastCalculator.java | 14 ++ .../service/AdmissionForecastService.java | 7 +- .../AdmissionCandidateServiceTest.java | 190 ++++++++++++++++++ .../integration/ClientErrorStatusTest.java | 18 ++ 11 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/carecode/domain/facility/dto/response/AdmissionCandidateResponse.java create mode 100644 src/main/java/com/carecode/domain/facility/service/AdmissionCandidateService.java create mode 100644 src/test/java/com/carecode/domain/facility/service/AdmissionCandidateServiceTest.java diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 997c615..3769589 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -571,6 +571,46 @@ }, "type" : "object" }, + "AdmissionCandidateResponse" : { + "properties" : { + "accuracy" : { + "$ref" : "#/components/schemas/ForecastAccuracyResponse" + }, + "candidates" : { + "items" : { + "$ref" : "#/components/schemas/Candidate" + }, + "type" : "array" + }, + "childAgeMonths" : { + "format" : "int32", + "type" : "integer" + }, + "evaluatedFacilities" : { + "format" : "int32", + "type" : "integer" + }, + "horizonMonths" : { + "format" : "int32", + "type" : "integer" + }, + "notEnoughDataCount" : { + "format" : "int32", + "type" : "integer" + }, + "region" : { + "type" : "string" + }, + "targetClass" : { + "type" : "string" + }, + "targetDate" : { + "format" : "date", + "type" : "string" + } + }, + "type" : "object" + }, "AdmissionForecastResponse" : { "properties" : { "accuracy" : { @@ -841,6 +881,46 @@ }, "type" : "object" }, + "Candidate" : { + "properties" : { + "address" : { + "type" : "string" + }, + "availableSpots" : { + "format" : "int32", + "type" : "integer" + }, + "confidence" : { + "type" : "string" + }, + "facilityId" : { + "format" : "int64", + "type" : "integer" + }, + "facilityName" : { + "type" : "string" + }, + "observationCount" : { + "format" : "int64", + "type" : "integer" + }, + "observationDays" : { + "format" : "int64", + "type" : "integer" + }, + "probability" : { + "format" : "int32", + "type" : "integer" + }, + "reasons" : { + "items" : { + "type" : "string" + }, + "type" : "array" + } + }, + "type" : "object" + }, "CareFacilityAdvancedSearchRequest" : { "description" : "검색 조건", "properties" : { @@ -10022,6 +10102,75 @@ "tags" : [ "육아 시설" ] } }, + "/facilities/admission-candidates" : { + "get" : { + "description" : "지역 안에서 아이가 목표 시점까지 들어갈 수 있는 시설을 확률 순으로 준다. 시설 상세의 예측과 같은 계산을 쓴다. 관측이 모자라 확률을 내지 못한 시설 수도 함께 센다.", + "operationId" : "recommendAdmissionCandidates", + "parameters" : [ { + "description" : "지역(주소에 포함되는 문자열)", + "example" : "성동구", + "in" : "query", + "name" : "region", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "description" : "아이 월령", + "example" : 18, + "in" : "query", + "name" : "childAgeMonths", + "required" : true, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "description" : "예측 기간(개월)", + "example" : 6, + "in" : "query", + "name" : "horizonMonths", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "description" : "최대 결과 수", + "example" : 10, + "in" : "query", + "name" : "limit", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", + "in" : "header", + "name" : "X-API-Version", + "required" : false, + "schema" : { + "default" : "1", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/AdmissionCandidateResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "아이 기준 입소 후보 추천", + "tags" : [ "육아 시설" ] + } + }, "/facilities/advanced-search" : { "post" : { "description" : "다양한 조건을 조합하여 시설 검색", diff --git a/src/main/java/com/carecode/domain/facility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/facility/app/CareFacilityFacade.java index 407d459..73709db 100644 --- a/src/main/java/com/carecode/domain/facility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/facility/app/CareFacilityFacade.java @@ -31,6 +31,7 @@ public class CareFacilityFacade { private final AdmissionForecastService admissionForecastService; private final FacilityPopularityService facilityPopularityService; private final com.carecode.domain.facility.service.ForecastAccuracyService forecastAccuracyService; + private final com.carecode.domain.facility.service.AdmissionCandidateService admissionCandidateService; @Transactional(readOnly = true) public List getAllCareFacilities(int page, int size) { @@ -220,6 +221,13 @@ public AdmissionForecastResponse forecastAdmission(Long facilityId, Integer chil return admissionForecastService.forecast(facilityId, childAgeMonths, horizonMonths); } + /** 지역 안에서 아이가 들어갈 수 있는 곳을 확률 순으로. 시설을 먼저 고르지 않아도 되게 한다. */ + @Transactional(readOnly = true) + public com.carecode.domain.facility.dto.response.AdmissionCandidateResponse recommendAdmissionCandidates( + String region, Integer childAgeMonths, Integer horizonMonths, Integer limit) { + return admissionCandidateService.recommend(region, childAgeMonths, horizonMonths, limit); + } + @Transactional(readOnly = true) public FacilityPopularityResponse analyzePopularity(Long facilityId) { return facilityPopularityService.analyze(facilityId); diff --git a/src/main/java/com/carecode/domain/facility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/facility/controller/CareFacilityController.java index 8e69e12..9f7dd6b 100644 --- a/src/main/java/com/carecode/domain/facility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/facility/controller/CareFacilityController.java @@ -429,6 +429,21 @@ public ResponseEntity forecastAdmission( return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths)); } + // 아이 기준 입소 후보 (검색을 뒤집는다) + @GetMapping("/admission-candidates") + @LogExecutionTime + @Operation(summary = "아이 기준 입소 후보 추천", + description = "지역 안에서 아이가 목표 시점까지 들어갈 수 있는 시설을 확률 순으로 준다. " + + "시설 상세의 예측과 같은 계산을 쓴다. 관측이 모자라 확률을 내지 못한 시설 수도 함께 센다.") + public ResponseEntity recommendAdmissionCandidates( + @Parameter(description = "지역(주소에 포함되는 문자열)", example = "성동구", required = true) @RequestParam String region, + @Parameter(description = "아이 월령", example = "18", required = true) @RequestParam Integer childAgeMonths, + @Parameter(description = "예측 기간(개월)", example = "6") @RequestParam(required = false) Integer horizonMonths, + @Parameter(description = "최대 결과 수", example = "10") @RequestParam(required = false) Integer limit) { + return ResponseEntity.ok( + careFacilityFacade.recommendAdmissionCandidates(region, childAgeMonths, horizonMonths, limit)); + } + // 예측 정확도 (공개) @GetMapping("/forecast-accuracy") @LogExecutionTime diff --git a/src/main/java/com/carecode/domain/facility/dto/response/AdmissionCandidateResponse.java b/src/main/java/com/carecode/domain/facility/dto/response/AdmissionCandidateResponse.java new file mode 100644 index 0000000..313cb54 --- /dev/null +++ b/src/main/java/com/carecode/domain/facility/dto/response/AdmissionCandidateResponse.java @@ -0,0 +1,71 @@ +package com.carecode.domain.facility.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** + * "우리 아이가 들어갈 수 있는 곳" — 지역 안의 시설을 입소 확률 순으로. + * + *

시설 하나를 골라 들어가야 확률을 볼 수 있다면, 어디를 고를지 모르는 사람은 그 숫자를 영영 못 본다. + * 검색을 뒤집어 답부터 주려고 만든 응답이다. + * + *

보여주지 않은 것도 함께 센다. 후보 40곳 중 3곳만 나왔을 때 그 사실을 숨기면 "이 동네에 어린이집이 + * 3곳뿐인가" 로 읽힌다. 실제로는 관측이 덜 쌓여 확률을 낼 수 없었을 뿐이다. + */ +@Getter +@Builder +public class AdmissionCandidateResponse { + + private final String region; + private final int childAgeMonths; + + /** 아이 월령 기준 배정 반. */ + private final String targetClass; + + private final int horizonMonths; + + /** 예측 기준 시점. */ + private final LocalDate targetDate; + + /** 지역에서 후보로 본 시설 수. */ + private final int evaluatedFacilities; + + /** 관측이 모자라 확률을 내지 못한 시설 수. */ + private final int notEnoughDataCount; + + /** + * 이 기간 예측이 과거에 얼마나 맞았는지. 표본이 부족하면 null. + * 확률을 나열하면서 그 확률이 믿을 만한지를 말하지 않으면 숫자가 혼자 커진다. + */ + private final ForecastAccuracyResponse accuracy; + + private final List candidates; + + @Getter + @Builder + public static class Candidate { + + private final Long facilityId; + private final String facilityName; + private final String address; + + /** 목표 시점까지 자리가 날 확률(0~100). */ + private final Integer probability; + + /** LOW / MEDIUM / HIGH */ + private final String confidence; + + /** 이 확률이 몇 번의 관측에 근거하는지. 같은 확률이면 근거가 많은 쪽이 낫다. */ + private final long observationCount; + private final long observationDays; + + /** 공공데이터에 적힌 현재 잔여석. 예측과 달리 지금 시점의 값이다. */ + private final Integer availableSpots; + + /** 왜 이 확률이 나왔는지. */ + private final List reasons; + } +} diff --git a/src/main/java/com/carecode/domain/facility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/facility/repository/CareFacilityRepository.java index e27ebb0..f175b89 100644 --- a/src/main/java/com/carecode/domain/facility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/facility/repository/CareFacilityRepository.java @@ -27,6 +27,21 @@ public interface CareFacilityRepository extends JpaRepository findByAddressContaining(String region); + /** + * 입소 후보. 지역 안에서 아이 나이를 받아 주는, 운영 중인 시설. + * + *

AGE_RANGE_MIN/MAX 는 연 나이다(V1__baseline.sql 의 '최소 나이'). 아직 어떤 동기화도 이 값을 + * 채우지 않아 대부분 NULL 인데, NULL 은 "연령 제한 없음" 으로 본다. 나중에 값이 들어오면 조건이 + * 저절로 살아난다. + */ + @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + + "AND cf.address LIKE CONCAT('%', :region, '%') " + + "AND (cf.ageRangeMin IS NULL OR cf.ageRangeMin <= :ageYears) " + + "AND (cf.ageRangeMax IS NULL OR cf.ageRangeMax >= :ageYears)") + List findAdmissionCandidates(@Param("region") String region, + @Param("ageYears") int ageYears, + Pageable pageable); + // 연령 범위에 해당하는 시설 조회 @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true AND " + "((cf.ageRangeMin IS NULL OR cf.ageRangeMin <= :childAge) AND " + diff --git a/src/main/java/com/carecode/domain/facility/repository/FacilityCapacitySnapshotRepository.java b/src/main/java/com/carecode/domain/facility/repository/FacilityCapacitySnapshotRepository.java index 24b93fa..3ed0b3b 100644 --- a/src/main/java/com/carecode/domain/facility/repository/FacilityCapacitySnapshotRepository.java +++ b/src/main/java/com/carecode/domain/facility/repository/FacilityCapacitySnapshotRepository.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -22,6 +23,19 @@ public interface FacilityCapacitySnapshotRepository extends JpaRepository findHistory(@Param("facilityId") Long facilityId, @Param("from") LocalDate from); + /** + * 여러 시설의 관측을 한 번에 읽는다. + * + *

지역 안의 시설마다 {@link #findHistory} 를 부르면 후보 수만큼 질의가 나간다. 한 구에 어린이집이 + * 수백 곳이라 그대로 두면 추천 한 번에 수백 번 왕복한다. 시설 순서를 함께 정렬해 호출부가 한 번만 + * 훑어도 시설별로 나눌 수 있게 한다. + */ + @Query("SELECT s FROM FacilityCapacitySnapshot s " + + "WHERE s.facilityId IN :facilityIds AND s.observedDate >= :from " + + "ORDER BY s.facilityId ASC, s.observedDate ASC") + List findHistories(@Param("facilityIds") Collection facilityIds, + @Param("from") LocalDate from); + /** 관측 기간이 얼마나 쌓였는지. 예측 가능 여부 판단에 쓴다. */ @Query("SELECT MIN(s.observedDate) FROM FacilityCapacitySnapshot s WHERE s.facilityId = :facilityId") Optional findEarliestObservedDate(@Param("facilityId") Long facilityId); diff --git a/src/main/java/com/carecode/domain/facility/service/AdmissionCandidateService.java b/src/main/java/com/carecode/domain/facility/service/AdmissionCandidateService.java new file mode 100644 index 0000000..e919674 --- /dev/null +++ b/src/main/java/com/carecode/domain/facility/service/AdmissionCandidateService.java @@ -0,0 +1,143 @@ +package com.carecode.domain.facility.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.core.exception.BusinessException; +import com.carecode.domain.facility.dto.response.AdmissionCandidateResponse; +import com.carecode.domain.facility.entity.CareFacility; +import com.carecode.domain.facility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.facility.repository.CareFacilityRepository; +import com.carecode.domain.facility.repository.FacilityCapacitySnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 지역 안의 시설을 아이 기준 입소 확률 순으로 돌려준다. + * + *

{@link AdmissionForecastService} 는 "이 시설에 들어갈 수 있나" 에 답한다. 그런데 그 질문을 하려면 + * 먼저 시설을 골라야 하고, 어디를 고를지 모르는 사람은 확률을 영영 보지 못한다. 여기서는 질문을 뒤집어 + * "어디에 들어갈 수 있나" 에 답한다. + * + *

계산은 같은 {@link AdmissionForecastCalculator} 를 쓴다. 시설 상세에서 본 확률과 목록에서 본 확률이 + * 다르면 둘 다 믿을 수 없게 되기 때문이다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdmissionCandidateService { + + /** 예측 입력으로 삼는 관측 구간. {@link AdmissionForecastService} 와 같아야 같은 답이 나온다. */ + private static final int LOOKBACK_MONTHS = 18; + private static final int DEFAULT_HORIZON_MONTHS = 6; + + /** + * 한 번에 확률을 계산할 시설 수 상한. + * + *

구 단위로 어린이집이 수백 곳이다. 전부 계산해도 사용자가 보는 건 위에서 열 곳 남짓이라 + * 여기서 끊는다. 끊었다는 사실은 evaluatedFacilities 로 드러난다. + */ + private static final int MAX_EVALUATED = 200; + + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; + + private final CareFacilityRepository facilityRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + private final AdmissionForecastCalculator calculator; + private final ForecastAccuracyService accuracyService; + private final EventLogger eventLogger; + + public AdmissionCandidateResponse recommend(String region, Integer childAgeMonths, + Integer horizonMonths, Integer limit) { + // BusinessException(= ErrorCode.INVALID_INPUT) 이라야 400 이 나간다. CareServiceException 은 + // 기본 매핑이 500 이어서, 잘못 보낸 요청에 서버 오류와 운영 알림이 함께 나갔다. + if (region == null || region.isBlank()) { + throw new BusinessException("지역을 입력해주세요."); + } + if (childAgeMonths == null || childAgeMonths < 0) { + throw new BusinessException("아이 월령을 입력해주세요."); + } + + LocalDate today = LocalDate.now(); + int horizon = horizonMonths != null && horizonMonths > 0 ? horizonMonths : DEFAULT_HORIZON_MONTHS; + int size = limit != null && limit > 0 ? Math.min(limit, MAX_LIMIT) : DEFAULT_LIMIT; + + List facilities = facilityRepository.findAdmissionCandidates( + region.trim(), childAgeMonths / 12, PageRequest.of(0, MAX_EVALUATED)); + + AdmissionCandidateResponse.AdmissionCandidateResponseBuilder response = + AdmissionCandidateResponse.builder() + .region(region.trim()) + .childAgeMonths(childAgeMonths) + .targetClass(AdmissionForecastCalculator.resolveClassName(childAgeMonths)) + .horizonMonths(horizon) + .targetDate(today.plusMonths(horizon)) + .evaluatedFacilities(facilities.size()) + .accuracy(accuracyService.describeFor(horizon, null).orElse(null)); + + if (facilities.isEmpty()) { + return response.notEnoughDataCount(0).candidates(List.of()).build(); + } + + eventLogger.log(EventType.ADMISSION_FORECAST_VIEWED, null, region.trim()); + + Map> histories = loadHistories(facilities, today); + + List candidates = new ArrayList<>(); + int notEnoughData = 0; + + for (CareFacility facility : facilities) { + List history = + histories.getOrDefault(facility.getId(), List.of()); + AdmissionForecastCalculator.Forecast forecast = calculator.forecast(history, today, horizon); + + // 관측이 모자란 곳은 숫자를 지어내지 않고 센다. 지어낸 확률이 가장 해롭다. + if (!forecast.isAvailable() || forecast.getProbability() == null) { + notEnoughData++; + continue; + } + + candidates.add(AdmissionCandidateResponse.Candidate.builder() + .facilityId(facility.getId()) + .facilityName(facility.getName()) + .address(facility.getAddress()) + .probability(forecast.getProbability()) + .confidence(forecast.getConfidence()) + .observationCount(forecast.getObservationCount()) + .observationDays(forecast.getObservationDays()) + .availableSpots(facility.getAvailableSpots()) + .reasons(forecast.getReasons()) + .build()); + } + + // 확률이 같으면 관측이 많은 쪽이 먼저다. 같은 숫자라도 근거가 두꺼운 편을 위에 둔다. + candidates.sort(Comparator + .comparing(AdmissionCandidateResponse.Candidate::getProbability).reversed() + .thenComparing(Comparator.comparingLong( + AdmissionCandidateResponse.Candidate::getObservationCount).reversed())); + + return response + .notEnoughDataCount(notEnoughData) + .candidates(candidates.size() > size ? candidates.subList(0, size) : candidates) + .build(); + } + + /** 후보 전체의 관측을 한 번에 읽어 시설별로 나눈다. 시설마다 묻으면 후보 수만큼 왕복한다. */ + private Map> loadHistories(List facilities, + LocalDate today) { + List ids = facilities.stream().map(CareFacility::getId).toList(); + return snapshotRepository.findHistories(ids, today.minusMonths(LOOKBACK_MONTHS)).stream() + .collect(Collectors.groupingBy(FacilityCapacitySnapshot::getFacilityId)); + } +} diff --git a/src/main/java/com/carecode/domain/facility/service/AdmissionForecastCalculator.java b/src/main/java/com/carecode/domain/facility/service/AdmissionForecastCalculator.java index 66385f1..404865b 100644 --- a/src/main/java/com/carecode/domain/facility/service/AdmissionForecastCalculator.java +++ b/src/main/java/com/carecode/domain/facility/service/AdmissionForecastCalculator.java @@ -28,6 +28,20 @@ public class AdmissionForecastCalculator { /** 신학기. 승급·졸업으로 자리가 가장 많이 열리는 시점이다. */ private static final Month NEW_TERM_MONTH = Month.MARCH; + /** + * 어린이집 반 편성 이름. 만 나이 기준이다. + * + *

시설 한 곳을 보는 화면과 지역 전체를 훑는 화면이 각자 들고 있으면 언젠가 갈라진다. + * 날짜도 DB 도 보지 않는 순수 계산이라 여기에 둔다. + */ + public static String resolveClassName(Integer ageMonths) { + if (ageMonths == null) { + return null; + } + int years = ageMonths / 12; + return years >= 5 ? "5세반 이상" : years + "세반"; + } + /** * 기준일 이전 관측으로 목표 시점까지 자리가 날 확률을 추정한다. * diff --git a/src/main/java/com/carecode/domain/facility/service/AdmissionForecastService.java b/src/main/java/com/carecode/domain/facility/service/AdmissionForecastService.java index f32135e..d09e6d0 100644 --- a/src/main/java/com/carecode/domain/facility/service/AdmissionForecastService.java +++ b/src/main/java/com/carecode/domain/facility/service/AdmissionForecastService.java @@ -72,12 +72,7 @@ public AdmissionForecastResponse forecast(Long facilityId, Integer childAgeMonth return response.build(); } - /** 어린이집 반 편성은 만 나이 기준이다. */ private String resolveClassName(Integer ageMonths) { - if (ageMonths == null) { - return null; - } - int years = ageMonths / 12; - return years >= 5 ? "5세반 이상" : years + "세반"; + return AdmissionForecastCalculator.resolveClassName(ageMonths); } } diff --git a/src/test/java/com/carecode/domain/facility/service/AdmissionCandidateServiceTest.java b/src/test/java/com/carecode/domain/facility/service/AdmissionCandidateServiceTest.java new file mode 100644 index 0000000..7196dde --- /dev/null +++ b/src/test/java/com/carecode/domain/facility/service/AdmissionCandidateServiceTest.java @@ -0,0 +1,190 @@ +package com.carecode.domain.facility.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.exception.BusinessException; +import com.carecode.domain.facility.dto.response.AdmissionCandidateResponse; +import com.carecode.domain.facility.entity.CareFacility; +import com.carecode.domain.facility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.facility.repository.CareFacilityRepository; +import com.carecode.domain.facility.repository.FacilityCapacitySnapshotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 아이 기준 입소 후보. + * + *

시설을 먼저 고르지 않아도 답을 주는 것이 이 기능의 전부다. 그래서 검증할 것도 둘뿐이다 — + * **순서가 맞는가**, 그리고 **모르는 것을 지어내지 않는가**. + */ +@DisplayName("아이 기준 입소 후보 추천") +class AdmissionCandidateServiceTest { + + private CareFacilityRepository facilityRepository; + private FacilityCapacitySnapshotRepository snapshotRepository; + private AdmissionCandidateService service; + + @BeforeEach + void setUp() { + facilityRepository = mock(CareFacilityRepository.class); + snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); + + ForecastAccuracyService accuracyService = mock(ForecastAccuracyService.class); + when(accuracyService.describeFor(anyInt(), any())).thenReturn(Optional.empty()); + + service = new AdmissionCandidateService(facilityRepository, snapshotRepository, + new AdmissionForecastCalculator(), accuracyService, mock(EventLogger.class)); + } + + @Test + @DisplayName("자리가 자주 났던 곳을 위에 둔다") + void ranksByProbability() { + CareFacility rarely = facility(1L, "꽉찬어린이집"); + CareFacility often = facility(2L, "여유어린이집"); + givenCandidates(rarely, often); + + List history = new ArrayList<>(); + // 꽉찬어린이집: 12주 내내 정원이 가득 찼다. + history.addAll(weekly(1L, 12, 100, 100)); + // 여유어린이집: 같은 기간 내내 자리가 있었다. + history.addAll(weekly(2L, 12, 100, 80)); + givenHistories(history); + + AdmissionCandidateResponse result = service.recommend("성동구", 18, 6, 10); + + assertThat(result.getCandidates()).hasSize(2); + assertThat(result.getCandidates().get(0).getFacilityName()).isEqualTo("여유어린이집"); + assertThat(result.getCandidates().get(0).getProbability()) + .isGreaterThan(result.getCandidates().get(1).getProbability()); + } + + @Test + @DisplayName("관측이 모자란 곳은 확률을 지어내지 않고 센다") + void countsFacilitiesWithoutEnoughData() { + givenCandidates(facility(1L, "관측많은곳"), facility(2L, "새로생긴곳")); + List history = new ArrayList<>(weekly(1L, 12, 100, 80)); + // 새로생긴곳은 관측이 2회뿐이라 추세라고 부를 수 없다(MIN_OBSERVATIONS=4). + history.addAll(weekly(2L, 2, 100, 90)); + givenHistories(history); + + AdmissionCandidateResponse result = service.recommend("성동구", 18, 6, 10); + + assertThat(result.getCandidates()).hasSize(1); + assertThat(result.getCandidates().get(0).getFacilityName()).isEqualTo("관측많은곳"); + // 후보 2곳 중 1곳만 나왔다는 사실을 숨기면 "이 동네에 한 곳뿐인가" 로 읽힌다. + assertThat(result.getEvaluatedFacilities()).isEqualTo(2); + assertThat(result.getNotEnoughDataCount()).isEqualTo(1); + } + + @Test + @DisplayName("요청한 개수까지만 돌려준다") + void respectsLimit() { + List many = new ArrayList<>(); + List history = new ArrayList<>(); + for (long id = 1; id <= 5; id++) { + many.add(facility(id, "어린이집" + id)); + history.addAll(weekly(id, 12, 100, 80)); + } + when(facilityRepository.findAdmissionCandidates(anyString(), anyInt(), any())).thenReturn(many); + givenHistories(history); + + AdmissionCandidateResponse result = service.recommend("성동구", 18, 6, 2); + + assertThat(result.getCandidates()).hasSize(2); + assertThat(result.getEvaluatedFacilities()).isEqualTo(5); + } + + @Test + @DisplayName("시설마다 따로 묻지 않고 관측을 한 번에 읽는다") + void loadsHistoriesInOneQuery() { + givenCandidates(facility(1L, "가"), facility(2L, "나"), facility(3L, "다")); + givenHistories(List.of()); + + service.recommend("성동구", 18, 6, 10); + + // 후보가 늘 때마다 질의가 늘면 한 구를 훑는 것만으로 수백 번 왕복한다. + org.mockito.Mockito.verify(snapshotRepository, org.mockito.Mockito.times(1)) + .findHistories(any(), any()); + org.mockito.Mockito.verify(snapshotRepository, org.mockito.Mockito.never()) + .findHistory(any(), any()); + } + + @Test + @DisplayName("아이 월령 기준 반 이름을 시설 상세와 같은 규칙으로 붙인다") + void usesSameClassNamingAsDetail() { + givenCandidates(facility(1L, "가")); + givenHistories(List.of()); + + AdmissionCandidateResponse result = service.recommend("성동구", 30, 6, 10); + + assertThat(result.getTargetClass()) + .isEqualTo(AdmissionForecastCalculator.resolveClassName(30)) + .isEqualTo("2세반"); + } + + @Test + @DisplayName("지역이나 월령이 없으면 계산하지 않는다") + void requiresRegionAndAge() { + assertThatThrownBy(() -> service.recommend(" ", 18, 6, 10)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("지역"); + + assertThatThrownBy(() -> service.recommend("성동구", null, 6, 10)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("월령"); + } + + @Test + @DisplayName("지역에 후보가 없으면 빈 목록을 준다") + void emptyRegion() { + when(facilityRepository.findAdmissionCandidates(anyString(), anyInt(), any())) + .thenReturn(List.of()); + + AdmissionCandidateResponse result = service.recommend("없는구", 18, 6, 10); + + assertThat(result.getCandidates()).isEmpty(); + assertThat(result.getEvaluatedFacilities()).isZero(); + } + + private void givenCandidates(CareFacility... facilities) { + when(facilityRepository.findAdmissionCandidates(anyString(), anyInt(), any())) + .thenReturn(List.of(facilities)); + } + + private void givenHistories(List snapshots) { + when(snapshotRepository.findHistories(any(), any(LocalDate.class))) + .thenReturn(snapshots); + } + + private CareFacility facility(Long id, String name) { + return CareFacility.builder().id(id).name(name).address("서울 성동구").build(); + } + + private List weekly(long facilityId, int count, int capacity, int enrolled) { + List list = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(count); + for (int i = 0; i < count; i++) { + list.add(FacilityCapacitySnapshot.builder() + .facilityId(facilityId) + .observedDate(start.plusWeeks(i)) + .capacity(capacity) + .currentEnrollment(enrolled) + .availableSpots(Math.max(0, capacity - enrolled)) + .build()); + } + return list; + } +} diff --git a/src/test/java/com/carecode/integration/ClientErrorStatusTest.java b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java index 59a17e3..5f1e2ee 100644 --- a/src/test/java/com/carecode/integration/ClientErrorStatusTest.java +++ b/src/test/java/com/carecode/integration/ClientErrorStatusTest.java @@ -93,6 +93,24 @@ void unknownResourceIsNotFound() throws Exception { verify(alerter, never()).alert(anyString(), anyString(), anyString()); } + /** + * 값이 잘못된 파라미터도 400 이다. + * + *

빠진 파라미터는 Spring 이 알아서 400 을 낸다. 하지만 **값이 범위를 벗어난** 경우는 + * 서비스까지 들어와서 던지는데, 그 예외가 {@code CareServiceException} 이면 다시 500 이 된다. + * 위의 404 와 같은 뿌리의 문제라서, 새 API 를 넣을 때마다 다시 생긴다. + */ + @Test + @DisplayName("값이 범위를 벗어난 파라미터는 400 이고 운영 알림을 울리지 않는다") + void invalidParameterValueIsBadRequest() throws Exception { + assertThat(status(get("/facilities/admission-candidates") + .param("region", "성동구") + .param("childAgeMonths", "-1"))) + .as("음수 월령으로 입소 후보 추천").isEqualTo(400); + + verify(alerter, never()).alert(anyString(), anyString(), anyString()); + } + private int status(RequestBuilder request) throws Exception { MvcResult result = mockMvc.perform(request).andReturn(); return result.getResponse().getStatus();