Skip to content

Commit a23de2d

Browse files
authored
Merge pull request #140 from CareCode-Repo/feat/admission-candidates
feat: 시설을 고르지 않아도 입소 확률을 볼 수 있게 한다
2 parents c23800e + 05d57c2 commit a23de2d

11 files changed

Lines changed: 638 additions & 6 deletions

File tree

‎docs/api/openapi.json‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,46 @@
571571
},
572572
"type" : "object"
573573
},
574+
"AdmissionCandidateResponse" : {
575+
"properties" : {
576+
"accuracy" : {
577+
"$ref" : "#/components/schemas/ForecastAccuracyResponse"
578+
},
579+
"candidates" : {
580+
"items" : {
581+
"$ref" : "#/components/schemas/Candidate"
582+
},
583+
"type" : "array"
584+
},
585+
"childAgeMonths" : {
586+
"format" : "int32",
587+
"type" : "integer"
588+
},
589+
"evaluatedFacilities" : {
590+
"format" : "int32",
591+
"type" : "integer"
592+
},
593+
"horizonMonths" : {
594+
"format" : "int32",
595+
"type" : "integer"
596+
},
597+
"notEnoughDataCount" : {
598+
"format" : "int32",
599+
"type" : "integer"
600+
},
601+
"region" : {
602+
"type" : "string"
603+
},
604+
"targetClass" : {
605+
"type" : "string"
606+
},
607+
"targetDate" : {
608+
"format" : "date",
609+
"type" : "string"
610+
}
611+
},
612+
"type" : "object"
613+
},
574614
"AdmissionForecastResponse" : {
575615
"properties" : {
576616
"accuracy" : {
@@ -841,6 +881,46 @@
841881
},
842882
"type" : "object"
843883
},
884+
"Candidate" : {
885+
"properties" : {
886+
"address" : {
887+
"type" : "string"
888+
},
889+
"availableSpots" : {
890+
"format" : "int32",
891+
"type" : "integer"
892+
},
893+
"confidence" : {
894+
"type" : "string"
895+
},
896+
"facilityId" : {
897+
"format" : "int64",
898+
"type" : "integer"
899+
},
900+
"facilityName" : {
901+
"type" : "string"
902+
},
903+
"observationCount" : {
904+
"format" : "int64",
905+
"type" : "integer"
906+
},
907+
"observationDays" : {
908+
"format" : "int64",
909+
"type" : "integer"
910+
},
911+
"probability" : {
912+
"format" : "int32",
913+
"type" : "integer"
914+
},
915+
"reasons" : {
916+
"items" : {
917+
"type" : "string"
918+
},
919+
"type" : "array"
920+
}
921+
},
922+
"type" : "object"
923+
},
844924
"CareFacilityAdvancedSearchRequest" : {
845925
"description" : "검색 조건",
846926
"properties" : {
@@ -10022,6 +10102,75 @@
1002210102
"tags" : [ "육아 시설" ]
1002310103
}
1002410104
},
10105+
"/facilities/admission-candidates" : {
10106+
"get" : {
10107+
"description" : "지역 안에서 아이가 목표 시점까지 들어갈 수 있는 시설을 확률 순으로 준다. 시설 상세의 예측과 같은 계산을 쓴다. 관측이 모자라 확률을 내지 못한 시설 수도 함께 센다.",
10108+
"operationId" : "recommendAdmissionCandidates",
10109+
"parameters" : [ {
10110+
"description" : "지역(주소에 포함되는 문자열)",
10111+
"example" : "성동구",
10112+
"in" : "query",
10113+
"name" : "region",
10114+
"required" : true,
10115+
"schema" : {
10116+
"type" : "string"
10117+
}
10118+
}, {
10119+
"description" : "아이 월령",
10120+
"example" : 18,
10121+
"in" : "query",
10122+
"name" : "childAgeMonths",
10123+
"required" : true,
10124+
"schema" : {
10125+
"format" : "int32",
10126+
"type" : "integer"
10127+
}
10128+
}, {
10129+
"description" : "예측 기간(개월)",
10130+
"example" : 6,
10131+
"in" : "query",
10132+
"name" : "horizonMonths",
10133+
"required" : false,
10134+
"schema" : {
10135+
"format" : "int32",
10136+
"type" : "integer"
10137+
}
10138+
}, {
10139+
"description" : "최대 결과 수",
10140+
"example" : 10,
10141+
"in" : "query",
10142+
"name" : "limit",
10143+
"required" : false,
10144+
"schema" : {
10145+
"format" : "int32",
10146+
"type" : "integer"
10147+
}
10148+
}, {
10149+
"description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400",
10150+
"in" : "header",
10151+
"name" : "X-API-Version",
10152+
"required" : false,
10153+
"schema" : {
10154+
"default" : "1",
10155+
"type" : "string"
10156+
}
10157+
} ],
10158+
"responses" : {
10159+
"200" : {
10160+
"content" : {
10161+
"application/json" : {
10162+
"schema" : {
10163+
"$ref" : "#/components/schemas/AdmissionCandidateResponse"
10164+
}
10165+
}
10166+
},
10167+
"description" : "OK"
10168+
}
10169+
},
10170+
"summary" : "아이 기준 입소 후보 추천",
10171+
"tags" : [ "육아 시설" ]
10172+
}
10173+
},
1002510174
"/facilities/advanced-search" : {
1002610175
"post" : {
1002710176
"description" : "다양한 조건을 조합하여 시설 검색",

‎src/main/java/com/carecode/domain/facility/app/CareFacilityFacade.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class CareFacilityFacade {
3131
private final AdmissionForecastService admissionForecastService;
3232
private final FacilityPopularityService facilityPopularityService;
3333
private final com.carecode.domain.facility.service.ForecastAccuracyService forecastAccuracyService;
34+
private final com.carecode.domain.facility.service.AdmissionCandidateService admissionCandidateService;
3435

3536
@Transactional(readOnly = true)
3637
public List<CareFacilityInfo> getAllCareFacilities(int page, int size) {
@@ -220,6 +221,13 @@ public AdmissionForecastResponse forecastAdmission(Long facilityId, Integer chil
220221
return admissionForecastService.forecast(facilityId, childAgeMonths, horizonMonths);
221222
}
222223

224+
/** 지역 안에서 아이가 들어갈 수 있는 곳을 확률 순으로. 시설을 먼저 고르지 않아도 되게 한다. */
225+
@Transactional(readOnly = true)
226+
public com.carecode.domain.facility.dto.response.AdmissionCandidateResponse recommendAdmissionCandidates(
227+
String region, Integer childAgeMonths, Integer horizonMonths, Integer limit) {
228+
return admissionCandidateService.recommend(region, childAgeMonths, horizonMonths, limit);
229+
}
230+
223231
@Transactional(readOnly = true)
224232
public FacilityPopularityResponse analyzePopularity(Long facilityId) {
225233
return facilityPopularityService.analyze(facilityId);

‎src/main/java/com/carecode/domain/facility/controller/CareFacilityController.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,21 @@ public ResponseEntity<AdmissionForecastResponse> forecastAdmission(
429429
return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths));
430430
}
431431

432+
// 아이 기준 입소 후보 (검색을 뒤집는다)
433+
@GetMapping("/admission-candidates")
434+
@LogExecutionTime
435+
@Operation(summary = "아이 기준 입소 후보 추천",
436+
description = "지역 안에서 아이가 목표 시점까지 들어갈 수 있는 시설을 확률 순으로 준다. "
437+
+ "시설 상세의 예측과 같은 계산을 쓴다. 관측이 모자라 확률을 내지 못한 시설 수도 함께 센다.")
438+
public ResponseEntity<com.carecode.domain.facility.dto.response.AdmissionCandidateResponse> recommendAdmissionCandidates(
439+
@Parameter(description = "지역(주소에 포함되는 문자열)", example = "성동구", required = true) @RequestParam String region,
440+
@Parameter(description = "아이 월령", example = "18", required = true) @RequestParam Integer childAgeMonths,
441+
@Parameter(description = "예측 기간(개월)", example = "6") @RequestParam(required = false) Integer horizonMonths,
442+
@Parameter(description = "최대 결과 수", example = "10") @RequestParam(required = false) Integer limit) {
443+
return ResponseEntity.ok(
444+
careFacilityFacade.recommendAdmissionCandidates(region, childAgeMonths, horizonMonths, limit));
445+
}
446+
432447
// 예측 정확도 (공개)
433448
@GetMapping("/forecast-accuracy")
434449
@LogExecutionTime
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.carecode.domain.facility.dto.response;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
import java.time.LocalDate;
7+
import java.util.List;
8+
9+
/**
10+
* "우리 아이가 들어갈 수 있는 곳" — 지역 안의 시설을 입소 확률 순으로.
11+
*
12+
* <p>시설 하나를 골라 들어가야 확률을 볼 수 있다면, 어디를 고를지 모르는 사람은 그 숫자를 영영 못 본다.
13+
* 검색을 뒤집어 답부터 주려고 만든 응답이다.
14+
*
15+
* <p>보여주지 않은 것도 함께 센다. 후보 40곳 중 3곳만 나왔을 때 그 사실을 숨기면 "이 동네에 어린이집이
16+
* 3곳뿐인가" 로 읽힌다. 실제로는 관측이 덜 쌓여 확률을 낼 수 없었을 뿐이다.
17+
*/
18+
@Getter
19+
@Builder
20+
public class AdmissionCandidateResponse {
21+
22+
private final String region;
23+
private final int childAgeMonths;
24+
25+
/** 아이 월령 기준 배정 반. */
26+
private final String targetClass;
27+
28+
private final int horizonMonths;
29+
30+
/** 예측 기준 시점. */
31+
private final LocalDate targetDate;
32+
33+
/** 지역에서 후보로 본 시설 수. */
34+
private final int evaluatedFacilities;
35+
36+
/** 관측이 모자라 확률을 내지 못한 시설 수. */
37+
private final int notEnoughDataCount;
38+
39+
/**
40+
* 이 기간 예측이 과거에 얼마나 맞았는지. 표본이 부족하면 null.
41+
* 확률을 나열하면서 그 확률이 믿을 만한지를 말하지 않으면 숫자가 혼자 커진다.
42+
*/
43+
private final ForecastAccuracyResponse accuracy;
44+
45+
private final List<Candidate> candidates;
46+
47+
@Getter
48+
@Builder
49+
public static class Candidate {
50+
51+
private final Long facilityId;
52+
private final String facilityName;
53+
private final String address;
54+
55+
/** 목표 시점까지 자리가 날 확률(0~100). */
56+
private final Integer probability;
57+
58+
/** LOW / MEDIUM / HIGH */
59+
private final String confidence;
60+
61+
/** 이 확률이 몇 번의 관측에 근거하는지. 같은 확률이면 근거가 많은 쪽이 낫다. */
62+
private final long observationCount;
63+
private final long observationDays;
64+
65+
/** 공공데이터에 적힌 현재 잔여석. 예측과 달리 지금 시점의 값이다. */
66+
private final Integer availableSpots;
67+
68+
/** 왜 이 확률이 나왔는지. */
69+
private final List<String> reasons;
70+
}
71+
}

‎src/main/java/com/carecode/domain/facility/repository/CareFacilityRepository.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ public interface CareFacilityRepository extends JpaRepository<CareFacility, Long
2727
// 지역별 시설 조회
2828
List<CareFacility> findByAddressContaining(String region);
2929

30+
/**
31+
* 입소 후보. 지역 안에서 아이 나이를 받아 주는, 운영 중인 시설.
32+
*
33+
* <p>AGE_RANGE_MIN/MAX 는 연 나이다(V1__baseline.sql 의 '최소 나이'). 아직 어떤 동기화도 이 값을
34+
* 채우지 않아 대부분 NULL 인데, NULL 은 "연령 제한 없음" 으로 본다. 나중에 값이 들어오면 조건이
35+
* 저절로 살아난다.
36+
*/
37+
@Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true "
38+
+ "AND cf.address LIKE CONCAT('%', :region, '%') "
39+
+ "AND (cf.ageRangeMin IS NULL OR cf.ageRangeMin <= :ageYears) "
40+
+ "AND (cf.ageRangeMax IS NULL OR cf.ageRangeMax >= :ageYears)")
41+
List<CareFacility> findAdmissionCandidates(@Param("region") String region,
42+
@Param("ageYears") int ageYears,
43+
Pageable pageable);
44+
3045
// 연령 범위에 해당하는 시설 조회
3146
@Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true AND " +
3247
"((cf.ageRangeMin IS NULL OR cf.ageRangeMin <= :childAge) AND " +

‎src/main/java/com/carecode/domain/facility/repository/FacilityCapacitySnapshotRepository.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.springframework.stereotype.Repository;
88

99
import java.time.LocalDate;
10+
import java.util.Collection;
1011
import java.util.List;
1112
import java.util.Optional;
1213

@@ -22,6 +23,19 @@ public interface FacilityCapacitySnapshotRepository extends JpaRepository<Facili
2223
List<FacilityCapacitySnapshot> findHistory(@Param("facilityId") Long facilityId,
2324
@Param("from") LocalDate from);
2425

26+
/**
27+
* 여러 시설의 관측을 한 번에 읽는다.
28+
*
29+
* <p>지역 안의 시설마다 {@link #findHistory} 를 부르면 후보 수만큼 질의가 나간다. 한 구에 어린이집이
30+
* 수백 곳이라 그대로 두면 추천 한 번에 수백 번 왕복한다. 시설 순서를 함께 정렬해 호출부가 한 번만
31+
* 훑어도 시설별로 나눌 수 있게 한다.
32+
*/
33+
@Query("SELECT s FROM FacilityCapacitySnapshot s "
34+
+ "WHERE s.facilityId IN :facilityIds AND s.observedDate >= :from "
35+
+ "ORDER BY s.facilityId ASC, s.observedDate ASC")
36+
List<FacilityCapacitySnapshot> findHistories(@Param("facilityIds") Collection<Long> facilityIds,
37+
@Param("from") LocalDate from);
38+
2539
/** 관측 기간이 얼마나 쌓였는지. 예측 가능 여부 판단에 쓴다. */
2640
@Query("SELECT MIN(s.observedDate) FROM FacilityCapacitySnapshot s WHERE s.facilityId = :facilityId")
2741
Optional<LocalDate> findEarliestObservedDate(@Param("facilityId") Long facilityId);

0 commit comments

Comments
 (0)