Skip to content

Commit 70e66f3

Browse files
authored
HAI-169 Mark hanke completed periodically (#1013)
Every now and then (daily by default), check for any hanke that need to be completed. It needs to be completed, if the end dates of all its areas are gone and it has no open applications. First, get a list of potential hanke IDs that need to be completed. Try to pre-filter the hanke IDs in the database call as much as possible, so there's no need to go through every hanke in Haitaton. Filtering out the hanke with active applications might be possible, but it would lead to a complicated JPQL call. Secondly, check each hanke in a separate transaction. Check that the hanke passes the requirements for completing i.e. it's public, all of the hankealue are over and that there are no active applications. If it checks all the boxes, it's marked as completed. Also, save the time the hanke was marked completed in a new database column. This will be used to determine when to permanently delete the hanke. Change the name of the ENDED status to COMPLETED, since that's more descriptive of what the status means now. Use locking to make sure two instances aren't trying to complete the same group of hanke at the same time. When getting the lock, wait for it to become available instead of skipping the operation if it's locked like we do with the Allu status updates. The amount of hanke to check per run and the scheduling can be configured with environment variables, but the defaults should be fine.
1 parent 3c63e88 commit 70e66f3

16 files changed

Lines changed: 854 additions & 17 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ services:
112112
CLAMAV_BASE_URL: http://clamav-api:3030
113113
HAITATON_TESTDATA_ENABLED: true
114114
HAITATON_FEATURE_HANKE_EDITING: ${HAITATON_FEATURE_HANKE_EDITING:-true}
115+
HAITATON_FEATURE_HANKE_COMPLETION: ${HAITATON_FEATURE_HANKE_COMPLETION:-true}
115116
HAITATON_API_DISABLED: ${HAITATON_API_DISABLED:-false}
116117
depends_on:
117118
- db
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package fi.hel.haitaton.hanke
2+
3+
import assertk.assertFailure
4+
import assertk.assertThat
5+
import assertk.assertions.containsExactly
6+
import assertk.assertions.containsExactlyInAnyOrder
7+
import assertk.assertions.hasClass
8+
import assertk.assertions.isEmpty
9+
import assertk.assertions.isEqualTo
10+
import assertk.assertions.isNull
11+
import fi.hel.haitaton.hanke.allu.ApplicationStatus
12+
import fi.hel.haitaton.hanke.domain.HankeStatus
13+
import fi.hel.haitaton.hanke.factory.HakemusFactory
14+
import fi.hel.haitaton.hanke.factory.HankeFactory
15+
import fi.hel.haitaton.hanke.factory.HankealueFactory
16+
import fi.hel.haitaton.hanke.test.Asserts.isRecent
17+
import java.time.LocalDateTime
18+
import java.time.ZonedDateTime
19+
import org.junit.jupiter.api.Nested
20+
import org.junit.jupiter.api.Test
21+
import org.springframework.beans.factory.annotation.Autowired
22+
23+
class HankeCompletionServiceITest(
24+
@Autowired private val hankeCompletionService: HankeCompletionService,
25+
@Autowired private val hankeFactory: HankeFactory,
26+
@Autowired private val hakemusFactory: HakemusFactory,
27+
@Autowired private val hankeRepository: HankeRepository,
28+
) : IntegrationTest() {
29+
30+
@Nested
31+
inner class GetPublicIds {
32+
33+
@Test
34+
fun `with no hanke returns empty list`() {
35+
val result = hankeCompletionService.getPublicIds()
36+
37+
assertThat(result).isEmpty()
38+
}
39+
40+
@Test
41+
fun `returns only public hanke`() {
42+
hankeFactory.builder().saveEntity(HankeStatus.DRAFT)
43+
hankeFactory.builder().saveEntity(HankeStatus.COMPLETED)
44+
val publicHanke =
45+
hankeFactory
46+
.builder()
47+
.withHankealue(
48+
HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now().minusDays(1))
49+
)
50+
.saveEntity(HankeStatus.PUBLIC)
51+
52+
val result = hankeCompletionService.getPublicIds()
53+
54+
assertThat(result).containsExactly(publicHanke.id)
55+
}
56+
57+
@Test
58+
fun `only returns hanke where the last alue end date is in the past`() {
59+
val pastHanke =
60+
hankeFactory
61+
.builder()
62+
.withHankealue(
63+
HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now().minusDays(1))
64+
)
65+
.saveEntity(HankeStatus.PUBLIC)
66+
hankeFactory
67+
.builder()
68+
.withHankealue(HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now()))
69+
.saveEntity(HankeStatus.PUBLIC)
70+
hankeFactory
71+
.builder()
72+
.withHankealue(
73+
HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now().plusDays(1))
74+
)
75+
.saveEntity(HankeStatus.PUBLIC)
76+
77+
val result = hankeCompletionService.getPublicIds()
78+
79+
assertThat(result).containsExactly(pastHanke.id)
80+
}
81+
82+
@Test
83+
fun `returns ids for hanke with no areas and with areas without an end date`() {
84+
val hankeWithoutArea =
85+
hankeFactory.builder().withNoAreas().saveEntity(HankeStatus.PUBLIC)
86+
val hankeWithoutEndDate =
87+
hankeFactory
88+
.builder()
89+
.withHankealue(HankealueFactory.create(haittaLoppuPvm = null))
90+
.saveEntity(HankeStatus.PUBLIC)
91+
92+
val result = hankeCompletionService.getPublicIds()
93+
94+
assertThat(result)
95+
.containsExactlyInAnyOrder(hankeWithoutArea.id, hankeWithoutEndDate.id)
96+
}
97+
98+
@Test
99+
fun `returns only the the first n ids, ordered by modifiedAt`() {
100+
val hankkeet = (1..6).map { hankeFactory.builder().saveEntity(HankeStatus.PUBLIC) }
101+
val dates = listOf(30, 2, 25, null, 15, 4)
102+
val baseDate = LocalDateTime.parse("2025-03-01T09:54:05")
103+
dates.zip(hankkeet).forEach { (date, hanke) ->
104+
hanke.modifiedAt = date?.let { baseDate.withDayOfMonth(it) }
105+
hankeRepository.save(hanke)
106+
}
107+
108+
val result = hankeCompletionService.getPublicIds()
109+
110+
// max-per-run is set to 3 in application-test.yml
111+
assertThat(result)
112+
.containsExactlyInAnyOrder(hankkeet[1].id, hankkeet[5].id, hankkeet[4].id)
113+
}
114+
}
115+
116+
@Nested
117+
inner class CompleteHankeIfPossible {
118+
private fun baseHanke() =
119+
hankeFactory
120+
.builder()
121+
.withHankealue(
122+
HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now().minusDays(1))
123+
)
124+
125+
@Test
126+
fun `throws exception when the hanke is not public`() {
127+
val hanke = baseHanke().saveEntity(HankeStatus.COMPLETED)
128+
129+
val failure = assertFailure { hankeCompletionService.completeHankeIfPossible(hanke.id) }
130+
131+
failure.hasClass(HankeNotPublicException::class)
132+
}
133+
134+
@Test
135+
fun `throws exception when the hanke has no areas`() {
136+
val hanke = baseHanke().withNoAreas().saveEntity(HankeStatus.PUBLIC)
137+
138+
val failure = assertFailure { hankeCompletionService.completeHankeIfPossible(hanke.id) }
139+
140+
failure.hasClass(PublicHankeHasNoAreasException::class)
141+
}
142+
143+
@Test
144+
fun `throws exception when the hanke has an empty end date`() {
145+
val hanke =
146+
baseHanke()
147+
.withHankealue(HankealueFactory.create(haittaLoppuPvm = null))
148+
.saveEntity(HankeStatus.PUBLIC)
149+
150+
val failure = assertFailure { hankeCompletionService.completeHankeIfPossible(hanke.id) }
151+
152+
failure.hasClass(HankealueWithoutEndDateException::class)
153+
}
154+
155+
@Test
156+
fun `doesn't change hanke status when hanke has areas in the future`() {
157+
val hanke =
158+
baseHanke()
159+
.withHankealue(
160+
HankealueFactory.create(haittaLoppuPvm = ZonedDateTime.now().plusDays(1))
161+
)
162+
.saveEntity(HankeStatus.PUBLIC)
163+
164+
hankeCompletionService.completeHankeIfPossible(hanke.id)
165+
166+
val result = hankeRepository.getReferenceById(hanke.id)
167+
assertThat(result.status).isEqualTo(HankeStatus.PUBLIC)
168+
assertThat(result.completedAt).isNull()
169+
}
170+
171+
@Test
172+
fun `doesn't change hanke status when hanke has active application`() {
173+
val hanke = baseHanke().saveEntity(HankeStatus.PUBLIC)
174+
hakemusFactory
175+
.builder(hanke)
176+
.withMandatoryFields()
177+
.withStatus(status = ApplicationStatus.HANDLING)
178+
.saveEntity()
179+
180+
hankeCompletionService.completeHankeIfPossible(hanke.id)
181+
182+
val result = hankeRepository.getReferenceById(hanke.id)
183+
assertThat(result.status).isEqualTo(HankeStatus.PUBLIC)
184+
assertThat(result.completedAt).isNull()
185+
}
186+
187+
@Test
188+
fun `changes hanke status when hanke has archived application`() {
189+
val hanke = baseHanke().saveEntity(HankeStatus.PUBLIC)
190+
hakemusFactory
191+
.builder(hanke)
192+
.withMandatoryFields()
193+
.withStatus(status = ApplicationStatus.ARCHIVED)
194+
.saveEntity()
195+
196+
hankeCompletionService.completeHankeIfPossible(hanke.id)
197+
198+
val result = hankeRepository.getReferenceById(hanke.id)
199+
assertThat(result.status).isEqualTo(HankeStatus.COMPLETED)
200+
assertThat(result.completedAt).isRecent()
201+
}
202+
}
203+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package fi.hel.haitaton.hanke
2+
3+
import fi.hel.haitaton.hanke.configuration.Feature
4+
import fi.hel.haitaton.hanke.configuration.FeatureFlags
5+
import fi.hel.haitaton.hanke.configuration.LockService
6+
import java.util.concurrent.TimeUnit
7+
import mu.KotlinLogging
8+
import org.springframework.boot.context.event.ApplicationReadyEvent
9+
import org.springframework.context.annotation.Profile
10+
import org.springframework.context.event.EventListener
11+
import org.springframework.scheduling.annotation.Scheduled
12+
import org.springframework.stereotype.Service
13+
14+
private val logger = KotlinLogging.logger {}
15+
16+
@Service
17+
@Profile("!test")
18+
class HankeCompletionScheduler(
19+
private val completionService: HankeCompletionService,
20+
private val lockService: LockService,
21+
private val featureFlags: FeatureFlags,
22+
) {
23+
@Scheduled(cron = "\${haitaton.hanke.completions.cron}", zone = "Europe/Helsinki")
24+
@EventListener(ApplicationReadyEvent::class)
25+
fun completeHankkeet() {
26+
if (featureFlags.isDisabled(Feature.HANKE_COMPLETION)) {
27+
logger.info { "Hanke completion is disabled, not running daily completion job." }
28+
return
29+
}
30+
31+
logger.info(
32+
"Trying to obtain lock $LOCK_NAME to start checking for hanke that need to be completed."
33+
)
34+
lockService.withLock(LOCK_NAME, 10, TimeUnit.MINUTES) {
35+
val ids = completionService.getPublicIds()
36+
logger.info("Got ${ids.size} hanke to try to complete.")
37+
38+
ids.forEach { id ->
39+
try {
40+
completionService.completeHankeIfPossible(id)
41+
} catch (e: HankeValidityException) {
42+
// Don't fail on hanke validation issues, log the error and continue.
43+
logger.error(e) { e.message }
44+
}
45+
}
46+
}
47+
}
48+
49+
companion object {
50+
internal const val LOCK_NAME = "hankeCompletion"
51+
}
52+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package fi.hel.haitaton.hanke
2+
3+
import fi.hel.haitaton.hanke.allu.ApplicationStatus
4+
import fi.hel.haitaton.hanke.domain.HankeStatus
5+
import fi.hel.haitaton.hanke.hakemus.ApplicationType
6+
import fi.hel.haitaton.hanke.hakemus.HakemusEntity
7+
import java.time.LocalDate
8+
import java.time.OffsetDateTime
9+
import mu.KotlinLogging
10+
import org.springframework.beans.factory.annotation.Value
11+
import org.springframework.stereotype.Service
12+
import org.springframework.transaction.annotation.Transactional
13+
14+
private val logger = KotlinLogging.logger {}
15+
16+
@Service
17+
class HankeCompletionService(
18+
private val hankeRepository: HankeRepository,
19+
@Value("\${haitaton.hanke.completions.max-per-run}") private val completionsPerDay: Int,
20+
) {
21+
22+
@Transactional(readOnly = true)
23+
fun getPublicIds(): List<Int> {
24+
return hankeRepository.findHankeToComplete(completionsPerDay)
25+
}
26+
27+
@Transactional
28+
fun completeHankeIfPossible(id: Int) {
29+
logger.info { "Checking hanke completion for $id" }
30+
val hanke = hankeRepository.getReferenceById(id)
31+
32+
assertHankePublic(hanke)
33+
assertHankeHasAreas(hanke)
34+
35+
if (hankeHasFutureAreas(hanke)) {
36+
logger.info {
37+
"Hanke has areas not in the past, not doing anything. ${hanke.logString()}"
38+
}
39+
return
40+
}
41+
42+
if (hankeHasActiveApplications(hanke)) {
43+
logger.info {
44+
"Hanke has active applications, not doing anything. ${hanke.logString()}"
45+
}
46+
return
47+
}
48+
49+
logger.info { "Hanke has been completed, marking it completed. ${hanke.logString()}" }
50+
hanke.status = HankeStatus.COMPLETED
51+
hanke.completedAt = OffsetDateTime.now()
52+
}
53+
54+
companion object {
55+
private val COMPLETED_EXCAVATION_NOTIFICATION_STATUSES: Set<ApplicationStatus> =
56+
setOf(ApplicationStatus.FINISHED, ApplicationStatus.ARCHIVED)
57+
58+
private val COMPLETED_CABLE_REPORT_STATUSES: Set<ApplicationStatus> =
59+
COMPLETED_EXCAVATION_NOTIFICATION_STATUSES + ApplicationStatus.DECISION
60+
61+
fun assertHankePublic(hanke: HankeEntity) {
62+
if (hanke.status != HankeStatus.PUBLIC) throw HankeNotPublicException(hanke)
63+
}
64+
65+
fun assertHankeHasAreas(hanke: HankeEntity) {
66+
if (hanke.alueet.isEmpty()) throw PublicHankeHasNoAreasException(hanke)
67+
}
68+
69+
fun hankeHasFutureAreas(hanke: HankeEntity): Boolean =
70+
hanke.alueet.any {
71+
val loppuPvm = it.haittaLoppuPvm ?: throw HankealueWithoutEndDateException(hanke)
72+
loppuPvm.isAfter(LocalDate.now())
73+
}
74+
75+
fun hankeHasActiveApplications(hanke: HankeEntity): Boolean {
76+
val activeApplications =
77+
hanke.hakemukset.filterNot { hakemus ->
78+
hakemus.alluStatus == null || hasCompletedStatus(hakemus)
79+
}
80+
81+
activeApplications.forEach {
82+
logger.info {
83+
"Hanke has an active application with status ${it.alluStatus} ${it.logString()} ${hanke.logString()}"
84+
}
85+
}
86+
87+
return activeApplications.isNotEmpty()
88+
}
89+
90+
private fun hasCompletedStatus(hakemus: HakemusEntity): Boolean =
91+
hakemus.alluStatus in
92+
when (hakemus.applicationType) {
93+
ApplicationType.CABLE_REPORT -> COMPLETED_CABLE_REPORT_STATUSES
94+
ApplicationType.EXCAVATION_NOTIFICATION ->
95+
COMPLETED_EXCAVATION_NOTIFICATION_STATUSES
96+
}
97+
}
98+
}
99+
100+
class HankeNotPublicException(hanke: HankeEntity) :
101+
HankeValidityException("Hanke is not public, it's ${hanke.status}", hanke)
102+
103+
class PublicHankeHasNoAreasException(hanke: HankeIdentifier) :
104+
HankeValidityException("Public hanke has no alueet", hanke)
105+
106+
class HankealueWithoutEndDateException(hanke: HankeIdentifier) :
107+
HankeValidityException("Public hanke has an alue without an end date", hanke)
108+
109+
open class HankeValidityException(message: String, hanke: HankeIdentifier) :
110+
RuntimeException("$message ${hanke.logString()}")

0 commit comments

Comments
 (0)