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
149 changes: 149 additions & 0 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : "다양한 조건을 조합하여 시설 검색",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CareFacilityInfo> getAllCareFacilities(int page, int size) {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,21 @@ public ResponseEntity<AdmissionForecastResponse> forecastAdmission(
return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths));
}

// 아이 기준 입소 후보 (검색을 뒤집는다)
@GetMapping("/admission-candidates")
@LogExecutionTime
@Operation(summary = "아이 기준 입소 후보 추천",
description = "지역 안에서 아이가 목표 시점까지 들어갈 수 있는 시설을 확률 순으로 준다. "
+ "시설 상세의 예측과 같은 계산을 쓴다. 관측이 모자라 확률을 내지 못한 시설 수도 함께 센다.")
public ResponseEntity<com.carecode.domain.facility.dto.response.AdmissionCandidateResponse> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* "우리 아이가 들어갈 수 있는 곳" — 지역 안의 시설을 입소 확률 순으로.
*
* <p>시설 하나를 골라 들어가야 확률을 볼 수 있다면, 어디를 고를지 모르는 사람은 그 숫자를 영영 못 본다.
* 검색을 뒤집어 답부터 주려고 만든 응답이다.
*
* <p>보여주지 않은 것도 함께 센다. 후보 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<Candidate> 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<String> reasons;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ public interface CareFacilityRepository extends JpaRepository<CareFacility, Long
// 지역별 시설 조회
List<CareFacility> findByAddressContaining(String region);

/**
* 입소 후보. 지역 안에서 아이 나이를 받아 주는, 운영 중인 시설.
*
* <p>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<CareFacility> 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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

/**
* 여러 시설의 관측을 한 번에 읽는다.
*
* <p>지역 안의 시설마다 {@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<FacilityCapacitySnapshot> findHistories(@Param("facilityIds") Collection<Long> facilityIds,
@Param("from") LocalDate from);

/** 관측 기간이 얼마나 쌓였는지. 예측 가능 여부 판단에 쓴다. */
@Query("SELECT MIN(s.observedDate) FROM FacilityCapacitySnapshot s WHERE s.facilityId = :facilityId")
Optional<LocalDate> findEarliestObservedDate(@Param("facilityId") Long facilityId);
Expand Down
Loading
Loading