diff --git a/backend/src/main/java/com/ssafy/salmanhae/batch/PropertySafetyScoreScheduler.java b/backend/src/main/java/com/ssafy/salmanhae/batch/PropertySafetyScoreScheduler.java new file mode 100644 index 0000000..b6f2f49 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/batch/PropertySafetyScoreScheduler.java @@ -0,0 +1,38 @@ +package com.ssafy.salmanhae.batch; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; +import com.ssafy.salmanhae.service.safety.PropertySafetyScoreService; + +@Component +@ConditionalOnProperty( + prefix = "safety.score.scheduler", + name = "enabled", + havingValue = "true" +) +public class PropertySafetyScoreScheduler { + + private static final Logger log = LoggerFactory.getLogger(PropertySafetyScoreScheduler.class); + + private final PropertySafetyScoreService propertySafetyScoreService; + + public PropertySafetyScoreScheduler(PropertySafetyScoreService propertySafetyScoreService) { + this.propertySafetyScoreService = propertySafetyScoreService; + } + + @Scheduled( + cron = "${safety.score.scheduler.cron:0 30 3 1 * *}", + zone = "${safety.score.scheduler.zone:Asia/Seoul}" + ) + public void runMonthlyRecalculation() { + List results = propertySafetyScoreService.recalculateAll(); + log.info("Monthly property safety score recalculation completed: properties={}", results.size()); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java index 7588697..d6ab9ef 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java @@ -5,12 +5,14 @@ import java.math.BigDecimal; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import com.ssafy.salmanhae.model.dto.map.MapViewportItemType; import com.ssafy.salmanhae.model.dto.map.PropertyClusterViewportItem; @@ -23,6 +25,7 @@ import com.ssafy.salmanhae.model.dto.property.PropertyType; import com.ssafy.salmanhae.model.dto.property.RegionPriceStatResponse; import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; @Repository public class JdbcPropertyDao implements PropertyDao { @@ -35,6 +38,7 @@ public class JdbcPropertyDao implements PropertyDao { """; private final NamedParameterJdbcTemplate jdbcTemplate; + private volatile Boolean onConflictSupported; public JdbcPropertyDao(NamedParameterJdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; @@ -311,6 +315,19 @@ public Optional findActiveById(Long propertyId) { return rows.stream().findFirst(); } + @Override + public List findActivePropertiesForSafetyScoring() { + String sql = """ + SELECT %s + FROM properties + WHERE is_active = true + AND latitude IS NOT NULL + AND longitude IS NOT NULL + ORDER BY id ASC + """.formatted(PROPERTY_COLUMNS); + return jdbcTemplate.query(sql, Map.of(), propertyRowMapper()); + } + @Override public List findComparableTransactions(PropertyRow property, String minContractYearMonth) { String sql = """ @@ -368,6 +385,96 @@ public Optional findSafetySummary(Long propertyId return rows.stream().findFirst(); } + @Override + @Transactional + public int upsertSafetyScoreStats(List results) { + if (results == null || results.isEmpty()) { + return 0; + } + return results.stream() + .mapToInt(this::upsertSafetyScoreStat) + .sum(); + } + + private int upsertSafetyScoreStat(PropertySafetyScoreResult result) { + Map params = new HashMap<>(); + params.put("propertyId", result.propertyId()); + params.put("safetyScore", result.safetyScore()); + params.put("cctvCount300m", result.cctvCount300m()); + params.put("bellCount300m", result.bellCount300m()); + params.put("lightCount300m", result.lightCount300m()); + params.put("policeCount500m", result.policeCount500m()); + if (supportsOnConflict()) { + return upsertSafetyScoreStatWithOnConflict(params); + } + return upsertSafetyScoreStatWithUpdateInsert(params); + } + + private int upsertSafetyScoreStatWithOnConflict(Map params) { + return jdbcTemplate.update(""" + INSERT INTO property_score_stat ( + property_id, safety_score, cctv_count_300m, bell_count_300m, + light_count_300m, police_count_500m, created_at, updated_at + ) VALUES ( + :propertyId, :safetyScore, :cctvCount300m, :bellCount300m, + :lightCount300m, :policeCount500m, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT (property_id) DO UPDATE + SET safety_score = EXCLUDED.safety_score, + cctv_count_300m = EXCLUDED.cctv_count_300m, + bell_count_300m = EXCLUDED.bell_count_300m, + light_count_300m = EXCLUDED.light_count_300m, + police_count_500m = EXCLUDED.police_count_500m, + updated_at = CURRENT_TIMESTAMP + """, params); + } + + private int upsertSafetyScoreStatWithUpdateInsert(Map params) { + int updated = jdbcTemplate.update(""" + UPDATE property_score_stat + SET safety_score = :safetyScore, + cctv_count_300m = :cctvCount300m, + bell_count_300m = :bellCount300m, + light_count_300m = :lightCount300m, + police_count_500m = :policeCount500m, + updated_at = CURRENT_TIMESTAMP + WHERE property_id = :propertyId + """, params); + if (updated > 0) { + return updated; + } + return jdbcTemplate.update(""" + INSERT INTO property_score_stat ( + property_id, safety_score, cctv_count_300m, bell_count_300m, + light_count_300m, police_count_500m, created_at, updated_at + ) VALUES ( + :propertyId, :safetyScore, :cctvCount300m, :bellCount300m, + :lightCount300m, :policeCount500m, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """, params); + } + + private boolean supportsOnConflict() { + Boolean cached = onConflictSupported; + if (cached != null) { + return cached; + } + onConflictSupported = detectOnConflictSupport(); + return onConflictSupported; + } + + private boolean detectOnConflictSupport() { + if (jdbcTemplate.getJdbcTemplate().getDataSource() == null) { + return true; + } + try (var connection = jdbcTemplate.getJdbcTemplate().getDataSource().getConnection()) { + String productName = connection.getMetaData().getDatabaseProductName(); + return productName == null || !productName.toLowerCase(Locale.ROOT).contains("h2"); + } catch (SQLException exception) { + return true; + } + } + @Override public List findRegionPriceStats( String legalDongCode, diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java index a913b2c..58ec517 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java @@ -14,6 +14,7 @@ import com.ssafy.salmanhae.model.dto.property.PropertyRow; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; public interface PropertyDao { @@ -35,10 +36,14 @@ List findPropertyClusters( Optional findActiveById(Long propertyId); + List findActivePropertiesForSafetyScoring(); + List findComparableTransactions(PropertyRow property, String minContractYearMonth); Optional findSafetySummary(Long propertyId, Integer radius); + int upsertSafetyScoreStats(List results); + List findRegionPriceStats( String legalDongCode, PropertyType propertyType, diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreInput.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreInput.java new file mode 100644 index 0000000..8e75e72 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreInput.java @@ -0,0 +1,10 @@ +package com.ssafy.salmanhae.model.dto.safety; + +public record PropertySafetyScoreInput( + Long propertyId, + int cctvCount300m, + int bellCount300m, + int lightCount300m, + int policeCount500m +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreResult.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreResult.java new file mode 100644 index 0000000..9510207 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/safety/PropertySafetyScoreResult.java @@ -0,0 +1,11 @@ +package com.ssafy.salmanhae.model.dto.safety; + +public record PropertySafetyScoreResult( + Long propertyId, + int safetyScore, + int cctvCount300m, + int bellCount300m, + int lightCount300m, + int policeCount500m +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreService.java b/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreService.java new file mode 100644 index 0000000..3091dc7 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreService.java @@ -0,0 +1,13 @@ +package com.ssafy.salmanhae.service.safety; + +import java.util.List; + +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreInput; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; + +public interface PropertySafetyScoreService { + + List recalculateAll(); + + PropertySafetyScoreResult calculateScore(PropertySafetyScoreInput input); +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceImpl.java new file mode 100644 index 0000000..0e52e10 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceImpl.java @@ -0,0 +1,218 @@ +package com.ssafy.salmanhae.service.safety; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import com.ssafy.salmanhae.model.dao.property.PropertyDao; +import com.ssafy.salmanhae.model.dao.safety.SafetyFacilityDao; +import com.ssafy.salmanhae.model.dto.property.PropertyRow; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreInput; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; +import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityRow; +import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityType; + +@Service +public class PropertySafetyScoreServiceImpl implements PropertySafetyScoreService { + + private static final Logger log = LoggerFactory.getLogger(PropertySafetyScoreServiceImpl.class); + + private static final int NEAR_RADIUS_M = 300; + private static final int POLICE_RADIUS_M = 500; + private static final int CCTV_FULL_SCORE_COUNT = 10; + private static final int BELL_FULL_SCORE_COUNT = 3; + private static final int LIGHT_FULL_SCORE_COUNT = 20; + private static final int POLICE_FULL_SCORE_COUNT = 1; + private static final int PROPERTY_CHUNK_SIZE = 100; + private static final double CCTV_WEIGHT = 30.0; + private static final double BELL_WEIGHT = 25.0; + private static final double LIGHT_WEIGHT = 25.0; + private static final double POLICE_WEIGHT = 20.0; + private static final double EARTH_RADIUS_M = 6_371_000.0; + private static final double METERS_PER_DEGREE = Math.toRadians(1.0) * EARTH_RADIUS_M; + private static final double BOUNDING_BOX_MARGIN_M = 1.0; + private static final List SCORE_TYPES = List.of( + SafetyFacilityType.CCTV, + SafetyFacilityType.EMERGENCY_BELL, + SafetyFacilityType.SECURITY_LIGHT, + SafetyFacilityType.POLICE + ); + + private final PropertyDao propertyDao; + private final SafetyFacilityDao safetyFacilityDao; + + public PropertySafetyScoreServiceImpl(PropertyDao propertyDao, SafetyFacilityDao safetyFacilityDao) { + this.propertyDao = propertyDao; + this.safetyFacilityDao = safetyFacilityDao; + } + + @Override + public List recalculateAll() { + List properties = propertyDao.findActivePropertiesForSafetyScoring(); + List spatiallyOrderedProperties = properties.stream() + .sorted(Comparator.comparing(PropertyRow::latitude) + .thenComparing(PropertyRow::longitude) + .thenComparing(PropertyRow::id)) + .toList(); + List results = new ArrayList<>(spatiallyOrderedProperties.size()); + for (int start = 0; start < spatiallyOrderedProperties.size(); start += PROPERTY_CHUNK_SIZE) { + List chunk = spatiallyOrderedProperties.subList( + start, + Math.min(start + PROPERTY_CHUNK_SIZE, spatiallyOrderedProperties.size()) + ); + List facilities = findNearbyCandidates(chunk); + results.addAll(chunk.stream() + .map(property -> calculateForProperty(property, facilities)) + .toList()); + } + int upsertedCount = results.isEmpty() ? 0 : propertyDao.upsertSafetyScoreStats(results); + log.info("Property safety score recalculation finished: calculated={}, upserted={}", results.size(), upsertedCount); + return results; + } + + @Override + public PropertySafetyScoreResult calculateScore(PropertySafetyScoreInput input) { + double weightedScore = weightedMetric(input.cctvCount300m(), CCTV_FULL_SCORE_COUNT, CCTV_WEIGHT) + + weightedMetric(input.bellCount300m(), BELL_FULL_SCORE_COUNT, BELL_WEIGHT) + + weightedMetric(input.lightCount300m(), LIGHT_FULL_SCORE_COUNT, LIGHT_WEIGHT) + + weightedMetric(input.policeCount500m(), POLICE_FULL_SCORE_COUNT, POLICE_WEIGHT); + int safetyScore = Math.max(0, Math.min(100, (int) Math.round(weightedScore))); + return new PropertySafetyScoreResult( + input.propertyId(), + safetyScore, + input.cctvCount300m(), + input.bellCount300m(), + input.lightCount300m(), + input.policeCount500m() + ); + } + + private PropertySafetyScoreResult calculateForProperty(PropertyRow property, List facilities) { + CandidateBounds bounds = candidateBounds(property); + int cctvCount300m = 0; + int bellCount300m = 0; + int lightCount300m = 0; + int policeCount500m = 0; + + for (SafetyFacilityRow facility : facilities) { + if (facility == null || facility.type() == null || facility.latitude() == null || facility.longitude() == null) { + continue; + } + if (!bounds.contains(facility)) { + continue; + } + double distanceMeters = distanceMeters( + property.latitude(), + property.longitude(), + facility.latitude(), + facility.longitude() + ); + if (facility.type() == SafetyFacilityType.POLICE && distanceMeters <= POLICE_RADIUS_M) { + policeCount500m++; + } else if (distanceMeters <= NEAR_RADIUS_M) { + if (facility.type() == SafetyFacilityType.CCTV) { + cctvCount300m++; + } else if (facility.type() == SafetyFacilityType.EMERGENCY_BELL) { + bellCount300m++; + } else if (facility.type() == SafetyFacilityType.SECURITY_LIGHT) { + lightCount300m++; + } + } + } + + return calculateScore(new PropertySafetyScoreInput( + property.id(), + cctvCount300m, + bellCount300m, + lightCount300m, + policeCount500m + )); + } + + private List findNearbyCandidates(List properties) { + if (properties.isEmpty()) { + return List.of(); + } + CandidateBounds bounds = properties.stream() + .map(this::candidateBounds) + .reduce(CandidateBounds::merge) + .orElseThrow(); + return safetyFacilityDao.findInBounds(SCORE_TYPES, bounds.west(), bounds.east(), bounds.south(), bounds.north()); + } + + private CandidateBounds candidateBounds(PropertyRow property) { + BigDecimal latitude = property.latitude(); + BigDecimal longitude = property.longitude(); + double candidateRadiusM = POLICE_RADIUS_M + BOUNDING_BOX_MARGIN_M; + double latitudeDelta = candidateRadiusM / METERS_PER_DEGREE; + double longitudeMetersPerDegree = METERS_PER_DEGREE * Math.cos(Math.toRadians(latitude.doubleValue())); + double longitudeDelta = candidateRadiusM / Math.max(1.0, longitudeMetersPerDegree); + return new CandidateBounds( + longitude.subtract(BigDecimal.valueOf(longitudeDelta)), + longitude.add(BigDecimal.valueOf(longitudeDelta)), + latitude.subtract(BigDecimal.valueOf(latitudeDelta)), + latitude.add(BigDecimal.valueOf(latitudeDelta)) + ); + } + + private double weightedMetric(int count, int fullScoreCount, double weight) { + if (count <= 0) { + return 0.0; + } + double normalized = Math.min(1.0, (double) count / fullScoreCount); + return normalized * weight; + } + + private double distanceMeters( + BigDecimal firstLatitude, + BigDecimal firstLongitude, + BigDecimal secondLatitude, + BigDecimal secondLongitude + ) { + double lat1 = Math.toRadians(firstLatitude.doubleValue()); + double lat2 = Math.toRadians(secondLatitude.doubleValue()); + double deltaLat = Math.toRadians(secondLatitude.subtract(firstLatitude).doubleValue()); + double deltaLon = Math.toRadians(secondLongitude.subtract(firstLongitude).doubleValue()); + double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_M * c; + } + + private record CandidateBounds( + BigDecimal west, + BigDecimal east, + BigDecimal south, + BigDecimal north + ) { + + private CandidateBounds merge(CandidateBounds other) { + return new CandidateBounds( + min(west, other.west), + max(east, other.east), + min(south, other.south), + max(north, other.north) + ); + } + + private boolean contains(SafetyFacilityRow facility) { + return facility.longitude().compareTo(west) >= 0 + && facility.longitude().compareTo(east) <= 0 + && facility.latitude().compareTo(south) >= 0 + && facility.latitude().compareTo(north) <= 0; + } + + private BigDecimal min(BigDecimal first, BigDecimal second) { + return first.compareTo(second) <= 0 ? first : second; + } + + private BigDecimal max(BigDecimal first, BigDecimal second) { + return first.compareTo(second) >= 0 ? first : second; + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 64973ee..1f49236 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -45,3 +45,8 @@ ai.agent.read-timeout-ms=${AI_AGENT_READ_TIMEOUT_MS:10000} safety.ingestion.scheduler.enabled=${SAFETY_INGESTION_SCHEDULER_ENABLED:false} safety.ingestion.scheduler.cron=${SAFETY_INGESTION_SCHEDULER_CRON:0 0 3 1 * *} safety.ingestion.scheduler.zone=${SAFETY_INGESTION_SCHEDULER_ZONE:Asia/Seoul} + +# Property safety score calculation batch +safety.score.scheduler.enabled=${SAFETY_SCORE_SCHEDULER_ENABLED:false} +safety.score.scheduler.cron=${SAFETY_SCORE_SCHEDULER_CRON:0 30 3 1 * *} +safety.score.scheduler.zone=${SAFETY_SCORE_SCHEDULER_ZONE:Asia/Seoul} diff --git a/backend/src/test/java/com/ssafy/salmanhae/batch/PropertySafetyScoreSchedulerTest.java b/backend/src/test/java/com/ssafy/salmanhae/batch/PropertySafetyScoreSchedulerTest.java new file mode 100644 index 0000000..5afe00a --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/batch/PropertySafetyScoreSchedulerTest.java @@ -0,0 +1,27 @@ +package com.ssafy.salmanhae.batch; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; +import com.ssafy.salmanhae.service.safety.PropertySafetyScoreService; + +class PropertySafetyScoreSchedulerTest { + + @Test + void runMonthlyRecalculationDelegatesToService() { + PropertySafetyScoreService service = org.mockito.Mockito.mock(PropertySafetyScoreService.class); + when(service.recalculateAll()).thenReturn(List.of( + new PropertySafetyScoreResult(1L, 78, 8, 2, 14, 1) + )); + PropertySafetyScoreScheduler scheduler = new PropertySafetyScoreScheduler(service); + + scheduler.runMonthlyRecalculation(); + + verify(service).recalculateAll(); + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDaoSafetyScoreTest.java b/backend/src/test/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDaoSafetyScoreTest.java new file mode 100644 index 0000000..4178830 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDaoSafetyScoreTest.java @@ -0,0 +1,52 @@ +package com.ssafy.salmanhae.model.dao.property; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class JdbcPropertyDaoSafetyScoreTest { + + @Autowired + private PropertyDao propertyDao; + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + void upsertSafetyScoreStatsUpdatesSafetyFieldsAndPreservesPriceScore() { + int affectedRows = propertyDao.upsertSafetyScoreStats(List.of( + new PropertySafetyScoreResult(1L, 33, 1, 1, 1, 1) + )); + + Map row = jdbcTemplate.queryForMap( + """ + SELECT safety_score, price_score, cctv_count_300m, bell_count_300m, + light_count_300m, police_count_500m + FROM property_score_stat + WHERE property_id = :propertyId + """, + Map.of("propertyId", 1L) + ); + + assertThat(affectedRows).isEqualTo(1); + assertThat(row.get("SAFETY_SCORE")).isEqualTo(33); + assertThat(row.get("PRICE_SCORE")).isEqualTo(64); + assertThat(row.get("CCTV_COUNT_300M")).isEqualTo(1); + assertThat(row.get("BELL_COUNT_300M")).isEqualTo(1); + assertThat(row.get("LIGHT_COUNT_300M")).isEqualTo(1); + assertThat(row.get("POLICE_COUNT_500M")).isEqualTo(1); + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceTest.java b/backend/src/test/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceTest.java new file mode 100644 index 0000000..222feb0 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/service/safety/PropertySafetyScoreServiceTest.java @@ -0,0 +1,121 @@ +package com.ssafy.salmanhae.service.safety; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ssafy.salmanhae.model.dao.property.PropertyDao; +import com.ssafy.salmanhae.model.dao.safety.SafetyFacilityDao; +import com.ssafy.salmanhae.model.dto.property.PropertyRow; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreInput; +import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult; +import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityRow; +import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityType; + +@ExtendWith(MockitoExtension.class) +class PropertySafetyScoreServiceTest { + + @Mock + private PropertyDao propertyDao; + + @Mock + private SafetyFacilityDao safetyFacilityDao; + + @Test + void calculateScoreUsesDocumentedWeightsAndCaps() { + PropertySafetyScoreService service = new PropertySafetyScoreServiceImpl(propertyDao, safetyFacilityDao); + + PropertySafetyScoreResult result = service.calculateScore( + new PropertySafetyScoreInput(1L, 8, 2, 14, 1) + ); + + assertThat(result.safetyScore()).isEqualTo(78); + assertThat(result.cctvCount300m()).isEqualTo(8); + assertThat(result.bellCount300m()).isEqualTo(2); + assertThat(result.lightCount300m()).isEqualTo(14); + assertThat(result.policeCount500m()).isEqualTo(1); + } + + @Test + void recalculateAllCountsFacilitiesByRadiusAndUpsertsResults() { + PropertyRow property = property(1L, "37.4700000", "126.9360000"); + when(propertyDao.findActivePropertiesForSafetyScoring()).thenReturn(List.of(property)); + when(safetyFacilityDao.findInBounds( + argThat(types -> types != null && types.size() == 4), + argThat(west -> west.compareTo(new BigDecimal("126.931")) < 0), + argThat(east -> east.compareTo(new BigDecimal("126.941")) > 0), + argThat(south -> south.compareTo(new BigDecimal("37.466")) < 0), + argThat(north -> north.compareTo(new BigDecimal("37.474")) > 0) + )).thenReturn(List.of( + facility(SafetyFacilityType.CCTV, "37.4701000", "126.9361000"), + facility(SafetyFacilityType.EMERGENCY_BELL, "37.4702000", "126.9361000"), + facility(SafetyFacilityType.SECURITY_LIGHT, "37.4703000", "126.9361000"), + facility(SafetyFacilityType.POLICE, "37.4735000", "126.9360000"), + facility(SafetyFacilityType.CCTV, "37.4800000", "126.9360000") + )); + when(propertyDao.upsertSafetyScoreStats(argThat(results -> results != null && results.size() == 1))) + .thenReturn(1); + PropertySafetyScoreService service = new PropertySafetyScoreServiceImpl(propertyDao, safetyFacilityDao); + + List results = service.recalculateAll(); + + assertThat(results).hasSize(1); + PropertySafetyScoreResult result = results.getFirst(); + assertThat(result.propertyId()).isEqualTo(1L); + assertThat(result.cctvCount300m()).isEqualTo(1); + assertThat(result.bellCount300m()).isEqualTo(1); + assertThat(result.lightCount300m()).isEqualTo(1); + assertThat(result.policeCount500m()).isEqualTo(1); + assertThat(result.safetyScore()).isEqualTo(33); + verify(propertyDao).upsertSafetyScoreStats(results); + } + + private PropertyRow property(Long id, String latitude, String longitude) { + return new PropertyRow( + id, + "Test Property", + "Test Building", + "building-key", + "address", + "road address", + "1162010200", + PropertyType.ONE_ROOM, + TransactionType.MONTHLY_RENT, + 10_000_000L, + 550_000L, + null, + 70_000L, + new BigDecimal("22.50"), + 3, + 5, + new BigDecimal(latitude), + new BigDecimal(longitude), + "description" + ); + } + + private SafetyFacilityRow facility(SafetyFacilityType type, String latitude, String longitude) { + return new SafetyFacilityRow( + null, + type, + type.name(), + "address", + new BigDecimal(latitude), + new BigDecimal(longitude), + "TEST", + type.name() + "-1", + "description" + ); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index cc88fd8..400cf5c 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -16,3 +16,4 @@ ai.agent.connect-timeout-ms=2000 ai.agent.read-timeout-ms=10000 app.cors.allowed-origins=http://localhost:5173,http://127.0.0.1:5173 safety.ingestion.scheduler.enabled=false +safety.score.scheduler.enabled=false diff --git a/docs/09_BATCH_INGESTION.md b/docs/09_BATCH_INGESTION.md index 3f02cf5..c725b25 100644 --- a/docs/09_BATCH_INGESTION.md +++ b/docs/09_BATCH_INGESTION.md @@ -177,3 +177,28 @@ disabled by default so local and test profiles never call public APIs unexpected logged and recorded in `SafetyFacilityIngestionResult`, while the remaining sources continue. Stored rows are accumulated through `SafetyFacilityDao.upsertAll`, so rerunning the batch is idempotent for the unique `(type, source, source_id)` safety facility key. + +## Phase 5 Property Safety Score Calculation Scheduler + +Phase 5 recalculates `property_score_stat.safety_score` from stored `safety_facility` rows. It never +calls public APIs during user requests; user-facing safety summary APIs read only precomputed DB rows. + +| Metric | Radius | Full-score cap | Weight | +| --- | --- | --- | --- | +| CCTV | 300m | 10 facilities | 30% | +| Emergency bell | 300m | 3 facilities | 25% | +| Security light | 300m | 20 facilities | 25% | +| Police/security facility | 500m | 1 facility | 20% | + +Each metric is normalized as `min(count / full-score-cap, 1.0)`, then multiplied by its weight. The +weighted total is rounded to the nearest integer and clamped to `0..100`. If no facility data exists +for a metric, that metric contributes `0`. + +| Config | Default | Description | +| --- | --- | --- | +| `safety.score.scheduler.enabled` / `SAFETY_SCORE_SCHEDULER_ENABLED` | `false` | Enables the monthly safety score scheduler when set to `true`. | +| `safety.score.scheduler.cron` / `SAFETY_SCORE_SCHEDULER_CRON` | `0 30 3 1 * *` | Runs after the safety facility refresh by default. | +| `safety.score.scheduler.zone` / `SAFETY_SCORE_SCHEDULER_ZONE` | `Asia/Seoul` | Scheduler timezone. | + +The upsert updates `safety_score` and safety facility counts while preserving existing `price_score`. +New rows are inserted with `price_score = null` until a price scoring batch fills that value.