From 8102d49d04b4ecc82dd318b8de90bba4d1910f88 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 21:42:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8B=A4=EC=9D=BC=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=20=EC=9D=B8=EA=B7=BC=20=EC=A7=80=EC=97=AD=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/consensus/ConsensusService.kt | 21 ++++++- .../consensus/ConsensusServiceTest.kt | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 9cc1da8..a511e04 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -38,6 +38,7 @@ class ConsensusService( 6 to listOf(120, 120, 120, 120, 120, 120), 7 to listOf(90, 120, 90, 120, 90, 120, 90), ) + private const val NEARBY_LOCALITY_RADIUS_KM = 45.0 private val SCORE_AXES = ScoreAxis.entries } @@ -297,10 +298,11 @@ class ConsensusService( val sameIds = sameLocality.map { place -> place.id }.toSet() val nearby = places .filter { it.id !in sameLocality.map { place -> place.id }.toSet() } - .filter { distanceKm(anchor, it)?.let { distance -> distance <= 12.0 } == true } + .filter { distanceKm(anchor, it)?.let { distance -> distance <= NEARBY_LOCALITY_RADIUS_KM } == true } .sortedBy { distanceKm(anchor, it) ?: Double.MAX_VALUE } - return (sameLocality + nearby.filter { it.id !in sameIds }).distinctBy { it.id } + val scoped = (sameLocality + nearby.filter { it.id !in sameIds }).distinctBy { it.id } + return if (scoped.size >= targetSlotCount) scoped else places } private fun localityScore(group: List, optionType: ScheduleOptionType, targetSlotCount: Int): Double { @@ -563,7 +565,7 @@ class ConsensusService( usedPlaceKeys = currentOptionPlaceKeys, avoidPlaceIds = avoidPlaceIdsByOrder[orderIndex].orEmpty(), avoidPlaceKeys = avoidPlaceKeysByOrder[orderIndex].orEmpty(), - previousPlace = chosenPlaces.lastOrNull(), + previousPlace = previousPlaceInSameTripDay(targets, index, chosenPlaces), preferHiddenGem = preferHiddenGem, mustBeHiddenGem = index == forcedHiddenGemIndex, tripDate = tripDate, @@ -718,6 +720,19 @@ class ConsensusService( return if (personalIndex >= 0) personalIndex else targets.size / 2 } + private fun previousPlaceInSameTripDay( + targets: List, + currentIndex: Int, + chosenPlaces: List, + ): PlaceCandidate? { + if (currentIndex <= 0) return null + val previousTarget = targets.getOrNull(currentIndex - 1) ?: return null + val currentTarget = targets.getOrNull(currentIndex) ?: return null + val sameTripDay = previousTarget.startTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() == + currentTarget.startTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() + return chosenPlaces.lastOrNull()?.takeIf { sameTripDay } + } + private fun rankPlaces( targetVector: AxisScores, places: List, diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index db0e5f3..7418e7d 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -202,6 +202,34 @@ class ConsensusServiceTest { } } + @Test + fun `multi day schedule can expand to nearby localities when one locality lacks enough unique places`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "충남", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = nearbyMultiLocalityPlaces(), + tripDate = "2026-06-01", + tripEndDate = "2026-06-03", + ) + ) + + options.forEach { option -> + assertEquals(9, option.slots.size) + assertEquals( + option.slots.size, + option.slots.map { it.placeId }.toSet().size, + "option ${option.optionType} repeated a place instead of expanding to nearby localities", + ) + assertTrue( + option.slots.map { primaryLocality(it.placeAddress) }.toSet().size > 1, + "option ${option.optionType} should be allowed to cross nearby localities across trip days", + ) + } + } + @Test fun `schedule generation reports missing candidates instead of rejecting non Chungnam destination`() { val error = assertThrows(DomainException::class.java) { @@ -297,6 +325,36 @@ class ConsensusServiceTest { ) } + private fun nearbyMultiLocalityPlaces(): List { + val localities = listOf( + Triple("충청남도 예산군 예산읍", 36.68, 126.85), + Triple("충청남도 아산시 온천동", 36.78, 127.00), + Triple("충청남도 천안시 동남구", 36.81, 127.15), + ) + val categories = listOf("tourist_attraction", "restaurant", "cultural_facility") + return localities.flatMapIndexed { localityIndex, (address, baseLat, baseLon) -> + (1..3).map { index -> + PlaceCandidate( + id = (localityIndex * 100 + index).toLong(), + name = "nearby-$localityIndex-$index", + address = "$address 테스트로 $index", + latitude = baseLat + index * 0.001, + longitude = baseLon + index * 0.001, + category = categories[(index - 1) % categories.size], + mobilityScore = 60 + index, + photoScore = 60 + index, + budgetScore = 60 + index, + themeScore = 60 + index, + metadataTags = mapOf("hiddenGem" to (index == 2)), + operatingHours = mapOf("status" to "always"), + externalPopularityScore = if (index == 1) 80 else 35, + externalSignalConfidence = 80, + isRegionalBenefit = index != 1, + ) + } + } + } + private fun mixedLocalityPlaces(): List { val localities = listOf( "충청남도 서천군 장항읍",