Skip to content

Commit bc61475

Browse files
MIA-1524 allow sync to update a movement occurrence (#221)
* MIA-1524 allow sync to update a movement occurrence * MIA-1524 allow sync to update a movement occurrence * Prevent duplicate occurrences when approving in nomis
1 parent 48b1c02 commit bc61475

File tree

11 files changed

+184
-23
lines changed

11 files changed

+184
-23
lines changed

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/domain/tap/movement/TemporaryAbsenceMovement.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,19 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor
2222
import org.springframework.data.jpa.repository.Modifying
2323
import org.springframework.data.jpa.repository.Query
2424
import org.springframework.data.repository.findByIdOrNull
25+
import uk.gov.justice.digital.hmpps.externalmovementsapi.context.ExternalMovementContext
26+
import uk.gov.justice.digital.hmpps.externalmovementsapi.context.set
2527
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.IdGenerator.newUuid
2628
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.Identifiable
2729
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.interceptor.DomainEventProducer
2830
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.person.PersonSummary
2931
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.referencedata.ReferenceData
3032
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.tap.occurrence.TemporaryAbsenceOccurrence
3133
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.tap.referencedata.AccompaniedBy
34+
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.tap.referencedata.OccurrenceStatus
3235
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.tap.referencedata.absencereason.AbsenceReason
3336
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.DomainEvent
37+
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementOccurrenceChanged
3438
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TemporaryAbsenceCompleted
3539
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TemporaryAbsenceStarted
3640
import uk.gov.justice.digital.hmpps.externalmovementsapi.exception.NotFoundException
@@ -39,6 +43,7 @@ import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.
3943
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementDirection
4044
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementLocation
4145
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementOccurredAt
46+
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementOccurrence
4247
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementReason
4348
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.MovementAction
4449
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.location.Location
@@ -150,6 +155,8 @@ class TemporaryAbsenceMovement(
150155
Direction.IN -> TemporaryAbsenceCompleted(person.identifier, id, occurrence?.id)
151156
}
152157

158+
override fun excludeFromPublish(): Set<String> = setOf(TapMovementOccurrenceChanged.EVENT_TYPE)
159+
153160
enum class Direction {
154161
IN,
155162
OUT,
@@ -159,6 +166,21 @@ class TemporaryAbsenceMovement(
159166
this.person = person
160167
}
161168

169+
fun switchSchedule(
170+
action: ChangeMovementOccurrence,
171+
rdSupplier: (KClass<out ReferenceData>, String) -> ReferenceData,
172+
occurrenceSupplier: (UUID) -> TemporaryAbsenceOccurrence,
173+
) = apply {
174+
if (this.occurrence?.id != action.occurrenceId) {
175+
val oldOccurrence = this.occurrence
176+
oldOccurrence?.removeMovement(this) { rdSupplier(OccurrenceStatus::class, it) as OccurrenceStatus }
177+
val newOccurrence = action.occurrenceId?.let { occurrenceSupplier(it) }
178+
newOccurrence?.addMovement(this) { rdSupplier(OccurrenceStatus::class, it) as OccurrenceStatus }
179+
ExternalMovementContext.get().copy(reason = "Recorded movement temporary absence occurrence changed").set()
180+
appliedActions += action
181+
}
182+
}
183+
162184
fun applyDirection(action: ChangeMovementDirection) = apply {
163185
if (direction != action.direction) {
164186
direction = action.direction

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/domain/tap/occurrence/TemporaryAbsenceOccurrence.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,11 @@ class TemporaryAbsenceOccurrence(
247247
override fun excludeFromPublish(): Set<String> = setOf(
248248
TemporaryAbsenceStarted.EVENT_TYPE,
249249
TemporaryAbsenceCompleted.EVENT_TYPE,
250-
)
250+
) + if (status.code == PENDING.name) {
251+
domainEvents().map { it.eventType }.toSet()
252+
} else {
253+
emptySet()
254+
}
251255

252256
fun applyAbsenceCategorisation(
253257
action: RecategoriseOccurrence,

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/events/DomainEvent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import java.util.UUID
4343
Type(value = TapMovementRecategorised::class, name = TapMovementRecategorised.EVENT_TYPE),
4444
Type(value = TapMovementRelocated::class, name = TapMovementRelocated.EVENT_TYPE),
4545
Type(value = TapMovementOccurredAtChanged::class, name = TapMovementOccurredAtChanged.EVENT_TYPE),
46+
Type(value = TapMovementOccurrenceChanged::class, name = TapMovementOccurrenceChanged.EVENT_TYPE),
4647

4748
Type(value = PrisonerUpdated::class, name = PrisonerUpdated.EVENT_TYPE),
4849
Type(value = PrisonerMerged::class, name = PrisonerMerged.EVENT_TYPE),

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/events/TemporaryAbsenceMovement.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,23 @@ data class TapMovementOccurredAtChanged(
129129
)
130130
}
131131
}
132+
133+
data class TapMovementOccurrenceChanged(
134+
override val additionalInformation: TapMovementInformation,
135+
override val personReference: PersonReference,
136+
) : DomainEvent<TapMovementInformation> {
137+
override val eventType: String = EVENT_TYPE
138+
override val description: String = "A temporary absence movement's occurrence has been changed."
139+
140+
companion object {
141+
const val EVENT_TYPE: String = "person.temporary-absence-movement.occurrence-changed"
142+
operator fun invoke(
143+
personIdentifier: String,
144+
id: UUID,
145+
dataSource: DataSource = ExternalMovementContext.get().source,
146+
) = TapMovementOccurrenceChanged(
147+
TapMovementInformation(id, dataSource),
148+
PersonReference.withIdentifier(personIdentifier),
149+
)
150+
}
151+
}

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/model/actions/movement/ChangeMovementOccurredAt.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data class ChangeMovementOccurredAt(
1010
val occurredAt: LocalDateTime,
1111
override val reason: String? = null,
1212
) : MovementAction {
13-
infix fun changes(occurredAt: LocalDateTime): Boolean = this.occurredAt.truncatedTo(ChronoUnit.SECONDS) == occurredAt.truncatedTo(ChronoUnit.SECONDS)
13+
infix fun changes(occurredAt: LocalDateTime): Boolean = !this.occurredAt.truncatedTo(ChronoUnit.SECONDS).isEqual(occurredAt.truncatedTo(ChronoUnit.SECONDS))
1414

1515
override fun domainEvent(tam: TemporaryAbsenceMovement): DomainEvent<*> = TapMovementOccurredAtChanged(tam.person.identifier, tam.id)
1616
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement
2+
3+
import uk.gov.justice.digital.hmpps.externalmovementsapi.domain.tap.movement.TemporaryAbsenceMovement
4+
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.DomainEvent
5+
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementOccurrenceChanged
6+
import java.util.UUID
7+
8+
data class ChangeMovementOccurrence(val occurrenceId: UUID?, override val reason: String? = null) : MovementAction {
9+
override fun domainEvent(tam: TemporaryAbsenceMovement): DomainEvent<*> = TapMovementOccurrenceChanged(tam.person.identifier, tam.id)
10+
}

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/sync/internal/SyncTapMovement.kt

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package uk.gov.justice.digital.hmpps.externalmovementsapi.sync.internal
22

3-
import com.microsoft.applicationinsights.TelemetryClient
43
import org.springframework.data.repository.findByIdOrNull
54
import org.springframework.stereotype.Service
65
import org.springframework.transaction.annotation.Transactional
@@ -26,12 +25,12 @@ import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.
2625
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementDirection
2726
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementLocation
2827
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementOccurredAt
28+
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementOccurrence
2929
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.movement.ChangeMovementReason
3030
import uk.gov.justice.digital.hmpps.externalmovementsapi.model.actions.occurrence.ChangeOccurrenceLocation
3131
import uk.gov.justice.digital.hmpps.externalmovementsapi.service.person.PersonSummaryService
3232
import uk.gov.justice.digital.hmpps.externalmovementsapi.sync.write.SyncResponse
3333
import uk.gov.justice.digital.hmpps.externalmovementsapi.sync.write.TapMovement
34-
import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME
3534
import java.util.UUID
3635
import kotlin.reflect.KClass
3736

@@ -43,7 +42,6 @@ class SyncTapMovement(
4342
private val occurrenceStatusRepository: OccurrenceStatusRepository,
4443
private val occurrenceRepository: TemporaryAbsenceOccurrenceRepository,
4544
private val movementRepository: TemporaryAbsenceMovementRepository,
46-
private val telemetryClient: TelemetryClient,
4745
) {
4846
fun sync(personIdentifier: String, request: TapMovement): SyncResponse {
4947
val occurrence = request.occurrenceId?.let { occurrenceRepository.getOccurrence(it) }?.also {
@@ -101,21 +99,7 @@ class SyncTapMovement(
10199
request: TapMovement,
102100
rdProvider: (KClass<out ReferenceData>, String) -> ReferenceData,
103101
) = apply {
104-
check(occurrence?.id == this.occurrence?.id) {
105-
telemetryClient.trackEvent(
106-
"MovementOccurrenceChange",
107-
mapOf(
108-
"personIdentifier" to person.identifier,
109-
"prisonCode" to prisonCode,
110-
"direction" to direction.name,
111-
"occurredAt" to ISO_LOCAL_DATE_TIME.format(occurredAt),
112-
"previousOccurrence" to this.occurrence?.id.toString(),
113-
"nextOccurrence" to occurrence?.id.toString(),
114-
),
115-
mapOf(),
116-
)
117-
"Attempt to change the occurrence of a movement"
118-
}
102+
switchSchedule(ChangeMovementOccurrence(occurrence?.id), rdProvider) { _ -> checkNotNull(occurrence) }
119103
applyDirection(ChangeMovementDirection(request.direction))
120104
applyOccurredAt(ChangeMovementOccurredAt(request.occurredAt))
121105
request.comments?.also { applyComments(ChangeMovementComments(it)) }

src/main/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/sync/internal/SyncTapOccurrence.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package uk.gov.justice.digital.hmpps.externalmovementsapi.sync.internal
22

3+
import com.microsoft.applicationinsights.TelemetryClient
34
import org.springframework.data.repository.findByIdOrNull
45
import org.springframework.stereotype.Service
56
import org.springframework.transaction.annotation.Transactional
@@ -43,6 +44,7 @@ class SyncTapOccurrence(
4344
private val authorisationRepository: TemporaryAbsenceAuthorisationRepository,
4445
private val occurrenceRepository: TemporaryAbsenceOccurrenceRepository,
4546
private val movementRepository: TemporaryAbsenceMovementRepository,
47+
private val telemetryClient: TelemetryClient,
4648
) {
4749
fun sync(authorisationId: UUID, request: TapOccurrence): SyncResponse {
4850
val authorisation = authorisationRepository.getAuthorisation(authorisationId)
@@ -61,6 +63,20 @@ class SyncTapOccurrence(
6163
throw ConflictException("Attempt to add occurrence to a non-approved authorisation")
6264
}
6365
ExternalMovementContext.get().copy(requestAt = request.created.at, username = request.created.by).set()
66+
if (!authorisation.repeat) {
67+
occurrenceRepository.findByAuthorisationId(authorisation.id).singleOrNull()?.also { tao ->
68+
telemetryClient.trackEvent(
69+
"DuplicateOccurrenceRemoved",
70+
mapOf(
71+
"personIdentifier" to authorisation.person.identifier,
72+
"id" to tao.id.toString(),
73+
"legacyId" to tao.legacyId.toString(),
74+
),
75+
mapOf(),
76+
)
77+
occurrenceRepository.delete(tao)
78+
}
79+
}
6480
occurrenceRepository.save(
6581
request.asEntity(authorisation, rdPaths).calculateStatus {
6682
rdPaths.getReferenceData(OccurrenceStatus::class, it) as OccurrenceStatus

src/test/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/integration/config/TempAbsenceMovementOperations.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ interface TempAbsenceMovementOperations {
3030
direction: TemporaryAbsenceMovement.Direction,
3131
personIdentifier: String = personIdentifier(),
3232
occurrence: TemporaryAbsenceOccurrence? = null,
33-
occurredAt: LocalDateTime = LocalDateTime.now().minusDays(7),
33+
occurredAt: LocalDateTime = LocalDateTime.now().minusDays(7).truncatedTo(ChronoUnit.MINUTES),
3434
absenceReason: String = "R15",
3535
accompaniedBy: String = AccompaniedBy.Code.NOT_PROVIDED.name,
3636
accompaniedByComments: String? = "Some comments about the accompanied by",

src/test/kotlin/uk/gov/justice/digital/hmpps/externalmovementsapi/integration/sync/SyncTapMovementIntTest.kt

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import uk.gov.justice.digital.hmpps.externalmovementsapi.events.HmppsDomainEvent
1919
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementAccompanimentChanged
2020
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementCommentsChanged
2121
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementOccurredAtChanged
22+
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementOccurrenceChanged
2223
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TapMovementRelocated
2324
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TemporaryAbsenceCompleted
2425
import uk.gov.justice.digital.hmpps.externalmovementsapi.events.TemporaryAbsenceRelocated
@@ -302,12 +303,86 @@ class SyncTapMovementIntTest(
302303
verifyEvents(saved, setOf(TemporaryAbsenceCompleted(saved.person.identifier, saved.id, null, DataSource.NOMIS)))
303304
}
304305

306+
@Test
307+
fun `200 ok temporary absence movement switches scheduled occurrence`() {
308+
val authorisation = givenTemporaryAbsenceAuthorisation(temporaryAbsenceAuthorisation(legacyId = newId()))
309+
val occ1 = givenTemporaryAbsenceOccurrence(
310+
temporaryAbsenceOccurrence(
311+
authorisation,
312+
legacyId = newId(),
313+
movements = listOf(
314+
temporaryAbsenceMovement(
315+
Direction.OUT,
316+
authorisation.person.identifier,
317+
legacyId = newId().toString(),
318+
),
319+
),
320+
),
321+
)
322+
val movement = occ1.movements().first()
323+
assertThat(movement.occurrence!!.id).isEqualTo(occ1.id)
324+
assertThat(movement.occurrence!!.status.code).isEqualTo(OccurrenceStatus.Code.IN_PROGRESS.name)
325+
326+
val occ2 = givenTemporaryAbsenceOccurrence(
327+
temporaryAbsenceOccurrence(
328+
authorisation,
329+
legacyId = newId(),
330+
location = movement.location,
331+
),
332+
)
333+
334+
val request = tapMovement(
335+
accompaniedByCode = movement.accompaniedBy.code,
336+
accompaniedByComments = movement.accompaniedByComments,
337+
direction = movement.direction,
338+
location = movement.location,
339+
occurredAt = movement.occurredAt,
340+
legacyId = movement.legacyId!!,
341+
comments = movement.comments,
342+
prisonCode = movement.prisonCode,
343+
id = movement.id,
344+
occurrenceId = occ2.id,
345+
)
346+
val res = syncTapMovement(authorisation.person.identifier, request)
347+
.expectStatus().isOk
348+
.expectBody<SyncResponse>()
349+
.returnResult()
350+
.responseBody!!
351+
352+
assertThat(res.id).isEqualTo(movement.id)
353+
val saved = requireNotNull(findTemporaryAbsenceMovement(movement.id))
354+
saved.verifyAgainst(authorisation.person.identifier, request)
355+
assertThat(saved.occurrence!!.id).isEqualTo(occ2.id)
356+
assertThat(saved.occurrence!!.status.code).isEqualTo(OccurrenceStatus.Code.IN_PROGRESS.name)
357+
358+
val oldOccurrence = requireNotNull(findTemporaryAbsenceOccurrence(occ1.id))
359+
assertThat(oldOccurrence.status.code).isEqualTo(OccurrenceStatus.Code.SCHEDULED.name)
360+
361+
verifyAudit(
362+
saved,
363+
RevisionType.MOD,
364+
setOf(
365+
TemporaryAbsenceMovement::class.simpleName!!,
366+
TemporaryAbsenceOccurrence::class.simpleName!!,
367+
HmppsDomainEvent::class.simpleName!!,
368+
),
369+
ExternalMovementContext.get().copy(source = DataSource.NOMIS, reason = "Recorded movement temporary absence occurrence changed"),
370+
)
371+
verifyEvents(
372+
saved,
373+
setOf(
374+
TapMovementOccurrenceChanged(movement.person.identifier, movement.id, DataSource.NOMIS),
375+
TemporaryAbsenceStarted(movement.person.identifier, movement.id, occ2.id, DataSource.NOMIS),
376+
),
377+
)
378+
}
379+
305380
private fun tapMovement(
306381
id: UUID? = null,
307382
occurrenceId: UUID? = null,
308383
direction: Direction,
309384
prisonCode: String = prisonCode(),
310-
occurrenceAt: LocalDateTime = LocalDateTime.now().minusDays(7),
385+
occurredAt: LocalDateTime = LocalDateTime.now().minusDays(7),
311386
reasonCode: String = "R15",
312387
accompaniedByCode: String = "L",
313388
accompaniedByComments: String? = "Information about the escort",
@@ -319,7 +394,7 @@ class SyncTapMovementIntTest(
319394
) = TapMovement(
320395
id,
321396
occurrenceId,
322-
occurrenceAt,
397+
occurredAt,
323398
direction,
324399
prisonCode,
325400
reasonCode,

0 commit comments

Comments
 (0)