diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java index 7abc089fe..fe64f5b42 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/CourtScheduleEnrichmentService.java @@ -1023,6 +1023,151 @@ private List sanityCheckAndEnrichCrown(final List hearin }).toList(); } + /** + * CROWN unallocation path: when any hearing day carries a {@code courtScheduleId} but no + * {@code courtRoomId} the user has removed the room assignment. We release ALL existing + * court-scheduler slots for this hearing and replace every hearing day with a draft + * ({@code isDraft=true}) session at the same court centre. + * + *

Steps: + *

    + *
  1. DELETE /hearingslots/{hearingId} — release existing booked capacity.
  2. + *
  3. GET /hearingslots?status=DRAFT to find an anchor draft session with enough + * consecutive availability (multiday path triggered when duration > 360 and + * jurisdiction=CROWN).
  4. + *
  5. GET /multidaysearchandbook to atomically book N consecutive draft sessions.
  6. + *
  7. Return the hearing with rebuilt hearing days pointing at the draft sessions.
  8. + *
+ * + *

On any failure the original hearing is returned unchanged so the aggregate can still + * process the unallocation (the null-startTime guard in {@code assignHearingDaysV2} covers + * the draft-day case). + */ + public UpdateHearingForListing enrichUnallocationWithDraftSlots(final UpdateHearingForListing hearing, + final JsonEnvelope envelope) { + final UUID hearingId = hearing.getHearingId(); + LOGGER.info("[UNALLOC] CROWN unallocation for hearingId={}, hearingDays={}", + hearingId, hearing.getHearingDays().size()); + + // Step 1: Release existing slots (best-effort — don't fail the whole flow) + try { + hearingSlotsService.delete(hearingId); + LOGGER.info("[UNALLOC] Released court-scheduler slots for hearingId={}", hearingId); + } catch (final Exception e) { + LOGGER.warn("[UNALLOC] Could not release slots for hearingId={} — continuing", hearingId, e); + } + + // Step 2: Total duration = one full court day (360 min) per hearing day + final int dayCount = hearing.getHearingDays().size(); + if (dayCount == 0) { + LOGGER.warn("[UNALLOC] No hearing days for hearingId={}, returning unchanged", hearingId); + return hearing; + } + final int totalDurationMinutes = dayCount * HearingDurationEnrichmentService.MINUTES_IN_DAY; + + // Step 3: Resolve ouCode for the draft-session search + final String ouCode; + try { + ouCode = getOrRetrieveOucode(hearing, envelope); + } catch (final Exception e) { + LOGGER.warn("[UNALLOC] Could not resolve ouCode for courtCentreId={} hearingId={} — returning unchanged", + hearing.getCourtCentreId(), hearingId, e); + return hearing; + } + if (isBlank(ouCode)) { + LOGGER.warn("[UNALLOC] Empty ouCode for hearingId={}, returning unchanged", hearingId); + return hearing; + } + + // Step 4: Find a draft anchor session with consecutive availability + final LocalDate startDate = extractFirstHearingDate(hearing); + if (startDate == null) { + LOGGER.warn("[UNALLOC] Cannot derive startDate for hearingId={}, returning unchanged", hearingId); + return hearing; + } + final String anchorCourtScheduleId = findDraftAnchorSession(ouCode, startDate, totalDurationMinutes); + if (isBlank(anchorCourtScheduleId)) { + LOGGER.warn("[UNALLOC] No draft anchor found for hearingId={} (ouCode={}, start={}, duration={}) — returning unchanged", + hearingId, ouCode, startDate, totalDurationMinutes); + return hearing; + } + + // Step 5: Atomically book N consecutive draft sessions via multiday search-and-book + final List draftSessions = multiDaySearchAndBook( + anchorCourtScheduleId, totalDurationMinutes, hearingId.toString()); + if (isEmpty(draftSessions)) { + LOGGER.warn("[UNALLOC] multiDaySearchAndBook returned no sessions for hearingId={} — returning unchanged", hearingId); + return hearing; + } + + // Step 6: Build one hearing day per draft session + final int durationPerDay = totalDurationMinutes / draftSessions.size(); + final List draftHearingDays = draftSessions.stream().map(session -> + HearingDay.hearingDay() + .withCourtScheduleId(fromString(session.getCourtScheduleId())) + .withCourtCentreId(nonNull(session.getCourtHouseId()) + ? fromString(session.getCourtHouseId()) + : hearing.getCourtCentreId()) + .withHearingDate(session.getSessionDate()) + .withDurationMinutes(durationPerDay) + .withIsDraft(true) + // courtRoomId is intentionally null — draft sessions have no confirmed room + .build() + ).toList(); + + LOGGER.info("[UNALLOC] Assigned {} draft session(s) for hearingId={}", draftSessions.size(), hearingId); + + return UpdateHearingForListing.updateHearingForListing() + .withValuesFrom(hearing) + .withHearingDays(draftHearingDays) + .build(); + } + + /** + * Calls GET /hearingslots?status=DRAFT to find an anchor court-schedule session that has + * {@code totalDurationMinutes / MINUTES_IN_DAY} consecutive draft sessions available. + * The multiday search path on the court-scheduler side activates when + * {@code jurisdiction=CROWN} and {@code duration > MINUTES_IN_DAY}. + * + * @return the {@code courtScheduleId} of the anchor session, or {@code null} if none found. + */ + private String findDraftAnchorSession(final String ouCode, + final LocalDate startDate, + final int totalDurationMinutes) { + final Map params = new HashMap<>(); + params.put("ouCode", ouCode); + params.put("sessionStartDate", startDate.toString()); + params.put("sessionEndDate", startDate.plusMonths(6).toString()); + params.put("status", "DRAFT"); + params.put("jurisdiction", "CROWN"); + params.put("courtSession", "AD"); + params.put("panel", "ADULT"); + params.put(DURATION_MINUTES, String.valueOf(totalDurationMinutes)); + params.put("pageSize", "1"); + params.put("pageNumber", "1"); + + final Response response = hearingSlotsService.search(params); + if (!isSuccess(response)) { + LOGGER.error("[UNALLOC] Draft slot search failed with HTTP {} for ouCode={}", response.getStatus(), ouCode); + return null; + } + + final JsonObject responseJson = objectToJsonObjectConverter.convert(response.getEntity()); + if (responseJson == null || responseJson.isEmpty()) { + return null; + } + + final JsonArray slots = responseJson.getJsonArray(HEARING_SLOTS); + if (slots == null || slots.isEmpty()) { + LOGGER.info("[UNALLOC] No draft anchor sessions for ouCode={}, start={}, duration={}", ouCode, startDate, totalDurationMinutes); + return null; + } + + final String courtScheduleId = slots.getJsonObject(0).getString(COURT_SCHEDULE_ID, null); + LOGGER.info("[UNALLOC] Draft anchor courtScheduleId={} for ouCode={}", courtScheduleId, ouCode); + return courtScheduleId; + } + public UpdateHearingForListing handleCrownMultiDayExtension(final UpdateHearingForListing hearing) { final int totalDuration = calculateAggregatedDuration(hearing); diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java index 3540314f7..528b85087 100644 --- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java +++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestrator.java @@ -107,7 +107,15 @@ public UpdateHearingForListing enrichUpdateHearingForListing(UpdateHearingForLis enrichedHearing = courtScheduleEnrichmentService.enrichWithCourtSchedules(withDuration,envelope); } else if (JurisdictionType.CROWN.equals(jurisdictionType)) { LOGGER.info("Enrich update hearing for CROWN hearingid: {}", hearing.getHearingId()); - if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { + if (isUnallocationRequest(hearing)) { + // CROWN unallocation: one or more hearing days have a courtScheduleId but no courtRoomId, + // meaning the user has removed the room assignment. Release all existing court-scheduler + // slots and replace all hearing days with isDraft=true sessions at the same court centre. + LOGGER.info("Detected CROWN unallocation for hearingId: {}", hearing.getHearingId()); + UpdateHearingForListing withDraftSlots = courtScheduleEnrichmentService.enrichUnallocationWithDraftSlots(hearing, envelope); + UpdateHearingForListing withHearingDays = hearingDaysEnrichmentService.enrichHearing(withDraftSlots, envelope); + enrichedHearing = hearingDurationEnrichmentService.enrichWithDurationForUpdate(withHearingDays, envelope); + } else if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { // CROWN with courtScheduleId submitted (hearingDays or nonDefaultDays): CourtSchedule-first // flow, mirroring enrichListCourtHearing. The submitted ids ARE the chosen sessions, so we // resolve them via enrichCrownCourtScheduleFirst — the pre-d62d3446 behaviour — rather than @@ -149,7 +157,13 @@ public UpdateHearingForListing enrichUpdateHearingForListing(UpdateHearingForLis enrichedHearing = courtScheduleEnrichmentService.enrichWithCourtSchedules(withDuration,envelope); } else if (JurisdictionType.CROWN.equals(jurisdictionType)) { LOGGER.info("Enrich update hearing for CROWN hearingid: {}", hearing.getHearingId()); - if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { + if (isUnallocationRequest(hearing)) { + // CROWN unallocation — see enrichUpdateHearingForListing(hearing, envelope) for rationale. + LOGGER.info("Detected CROWN unallocation for hearingId: {}", hearing.getHearingId()); + UpdateHearingForListing withDraftSlots = courtScheduleEnrichmentService.enrichUnallocationWithDraftSlots(hearing, envelope); + UpdateHearingForListing withHearingDays = hearingDaysEnrichmentService.enrichHearing(withDraftSlots, envelope, courtCentreDetails); + enrichedHearing = hearingDurationEnrichmentService.enrichWithDurationForUpdate(withHearingDays, envelope); + } else if (!isWeekCommencingHearing(hearing) && hasCourtScheduleId(hearing)) { // courtScheduleId submitted → CourtSchedule-first flow (pre-d62d3446 behaviour). // See enrichUpdateHearingForListing(hearing, envelope) for rationale. UpdateHearingForListing withCourtSchedules = courtScheduleEnrichmentService.enrichCrownCourtScheduleFirst(hearing); @@ -425,4 +439,39 @@ static UpdateHearingForListing stripRoomInfoIfAnyDraft(final UpdateHearingForLis private static boolean anyDayIsDraft(final List days) { return days.stream().anyMatch(d -> Boolean.TRUE.equals(d.getIsDraft())); } + + /** + * Returns {@code true} when the CROWN update payload signals an unallocation of a multi-day + * hearing: + *

+ * + *

A new-allocation payload only has {@code courtScheduleId} on the days the user + * explicitly selected, so partial coverage (not all days) naturally falls into the + * CourtSchedule-first path instead. Single-day CROWN hearings follow the same path without + * an additional NPE risk because {@code assignHearingDaysV2} already filters null + * {@code startTime} keys. + */ + private static boolean isUnallocationRequest(final UpdateHearingForListing hearing) { + if (!JurisdictionType.CROWN.equals(hearing.getJurisdictionType())) { + return false; + } + final List days = hearing.getHearingDays(); + if (isEmpty(days) || days.size() < 2) { + return false; + } + // ALL days must have a courtScheduleId (hearing was fully allocated) + final boolean allDaysBooked = days.stream().allMatch(d -> nonNull(d.getCourtScheduleId())); + if (!allDaysBooked) { + return false; + } + // At least one day has no room (unallocation signal) and isn't already draft + return days.stream().anyMatch(d -> + isNull(d.getCourtRoomId()) && !Boolean.TRUE.equals(d.getIsDraft())); + } } diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestratorTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestratorTest.java index 43939066c..27420e4d9 100644 --- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestratorTest.java +++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingEnrichmentOrchestratorTest.java @@ -506,6 +506,78 @@ public void shouldRouteCrownUpdateWithCourtCentreDetailsThroughCourtScheduleFirs assertEquals(afterDuration, result); } + // ─── CROWN unallocation enrichment tests ───────────────────────────── + + @Test + public void shouldRouteCrownUpdateThroughUnallocationPath_whenAllDaysHaveCourtScheduleIdAndAnyDayHasNoCourtRoomId() { + // All hearing days have courtScheduleId (previously fully allocated) and at least one + // has no courtRoomId (room assignment removed) → unallocation path. + UpdateHearingForListing crownUpdate = mock(UpdateHearingForListing.class); + lenient().when(crownUpdate.getJurisdictionType()).thenReturn(JurisdictionType.CROWN); + HearingDay d1 = HearingDay.hearingDay() + .withHearingDate(LocalDate.parse("2026-05-27")) + .withCourtScheduleId(UUID.randomUUID()) + .withCourtRoomId(UUID.randomUUID()) + .withDurationMinutes(360) + .build(); + HearingDay d2 = HearingDay.hearingDay() + .withHearingDate(LocalDate.parse("2026-05-28")) + .withCourtScheduleId(UUID.randomUUID()) + // courtRoomId intentionally null — signals unallocation of this day + .withDurationMinutes(360) + .build(); + lenient().when(crownUpdate.getHearingDays()).thenReturn(Arrays.asList(d1, d2)); + lenient().when(crownUpdate.getNonDefaultDays()).thenReturn(Collections.emptyList()); + + UpdateHearingForListing afterDraftSlots = mock(UpdateHearingForListing.class); + UpdateHearingForListing afterHearingDays = mock(UpdateHearingForListing.class); + UpdateHearingForListing afterDuration = mock(UpdateHearingForListing.class); + + when(courtScheduleEnrichmentService.enrichUnallocationWithDraftSlots(crownUpdate, envelope)).thenReturn(afterDraftSlots); + when(hearingDaysEnrichmentService.enrichHearing(afterDraftSlots, envelope)).thenReturn(afterHearingDays); + when(hearingDurationEnrichmentService.enrichWithDurationForUpdate(afterHearingDays, envelope)).thenReturn(afterDuration); + + UpdateHearingForListing result = orchestrator.enrichUpdateHearingForListing(crownUpdate, envelope); + + verify(courtScheduleEnrichmentService).enrichUnallocationWithDraftSlots(crownUpdate, envelope); + verify(courtScheduleEnrichmentService, never()).enrichCrownCourtScheduleFirst(any(UpdateHearingForListing.class)); + verify(courtScheduleEnrichmentService, never()).handleCrownMultiDayExtension(any(UpdateHearingForListing.class)); + assertEquals(afterDuration, result); + } + + @Test + public void shouldNotRouteThroughUnallocationPath_whenOnlySomeDaysHaveCourtScheduleId() { + // Only some days have courtScheduleId — new allocation, NOT unallocation. + UpdateHearingForListing crownUpdate = mock(UpdateHearingForListing.class); + lenient().when(crownUpdate.getJurisdictionType()).thenReturn(JurisdictionType.CROWN); + HearingDay d1 = HearingDay.hearingDay() + .withHearingDate(LocalDate.parse("2026-05-27")) + .withCourtScheduleId(UUID.randomUUID()) + .withDurationMinutes(360) + .build(); + HearingDay d2 = HearingDay.hearingDay() + .withHearingDate(LocalDate.parse("2026-05-28")) + .withDurationMinutes(360) + // no courtScheduleId on d2 + .build(); + lenient().when(crownUpdate.getHearingDays()).thenReturn(Arrays.asList(d1, d2)); + lenient().when(crownUpdate.getNonDefaultDays()).thenReturn(Collections.emptyList()); + + UpdateHearingForListing afterCourtSchedule = mock(UpdateHearingForListing.class); + UpdateHearingForListing afterHearingDays = mock(UpdateHearingForListing.class); + UpdateHearingForListing afterDuration = mock(UpdateHearingForListing.class); + + when(courtScheduleEnrichmentService.enrichCrownCourtScheduleFirst(crownUpdate)).thenReturn(afterCourtSchedule); + when(hearingDaysEnrichmentService.enrichHearing(afterCourtSchedule, envelope)).thenReturn(afterHearingDays); + when(hearingDurationEnrichmentService.enrichWithDurationForUpdate(afterHearingDays, envelope)).thenReturn(afterDuration); + + UpdateHearingForListing result = orchestrator.enrichUpdateHearingForListing(crownUpdate, envelope); + + verify(courtScheduleEnrichmentService, never()).enrichUnallocationWithDraftSlots(any(), any()); + verify(courtScheduleEnrichmentService).enrichCrownCourtScheduleFirst(crownUpdate); + assertEquals(afterDuration, result); + } + // ─── MAGS update enrichment tests ──────────────────────────────────── @Test diff --git a/listing-domain/listing-domain-aggregate/src/main/java/uk/gov/moj/cpp/listing/domain/aggregate/Hearing.java b/listing-domain/listing-domain-aggregate/src/main/java/uk/gov/moj/cpp/listing/domain/aggregate/Hearing.java index 01a29b108..40a76a8a1 100644 --- a/listing-domain/listing-domain-aggregate/src/main/java/uk/gov/moj/cpp/listing/domain/aggregate/Hearing.java +++ b/listing-domain/listing-domain-aggregate/src/main/java/uk/gov/moj/cpp/listing/domain/aggregate/Hearing.java @@ -1092,6 +1092,7 @@ public Stream assignHearingDays(final LocalDate startDate, final LocalDa if (!this.hearingDays.isEmpty()) { final Map existingHearingDays = this.hearingDays.stream() + .filter(hd -> hd.getStartTime() != null) .collect(toMap(HearingDay::getStartTime, hearingDay -> hearingDay, (hd1, hd2) -> hd2)); final List newHearingDaysWithExistingSequences = mergeHearingDaySequences(hearingDaysChangedForHearing, existingHearingDays); @@ -1122,6 +1123,7 @@ public Stream assignHearingDaysV2(final UUID hearingId, final List existingHearingDays = this.hearingDays.stream() + .filter(hd -> hd.getStartTime() != null) .collect(toMap(HearingDay::getStartTime, hearingDay -> hearingDay, (hd1, hd2) -> hd2)); List newHearingDaysWithExistingInfo = @@ -1133,6 +1135,7 @@ public Stream assignHearingDaysV2(final UUID hearingId, final List hd.getCourtRoomId() != null) // we do not want to preserve any null courtroom .filter(hd -> !newParentCourtRoom.equals(hd.getCourtRoomId())) // The courtRoom on the parent is not the same as the one on this day .filter(hd -> !daysOfNonDefaultDays.contains(hd.getHearingDate())) // if we are right now changing this room + .filter(hd -> hd.getHearingDate() != null) // skip unallocated days with no hearing date .collect(toMap(HearingDay::getHearingDate, hearingDay -> hearingDay, (hd1, hd2) -> hd2)); newHearingDaysWithExistingInfo = mergePreviouslyChangedCourtRooms(newHearingDaysWithExistingInfo, existingHearingDaysWithChangedRooms); @@ -2300,24 +2303,17 @@ private AllocatedHearingUpdatedForListingV2 allocatedHearingUpdatedForListingEve private HearingUnallocatedForListing hearingUnallocatedForListingEvent(final Optional source) { - if (nonNull(prosecutionCaseDefendantOffenceIds)) { - final Optional offenceIds = prosecutionCaseDefendantOffenceIds.stream() - .flatMap(pc -> pc.getDefendants().stream()) - .flatMap(defendantOffenceIds -> defendantOffenceIds.getOffences().stream()) - .filter(o -> nonNull(o.getSeedingHearing())) - .findFirst(); - if (offenceIds.isPresent()) { - return hearingUnallocatedForListing() - .withHearingId(this.hearingId) - .withSeededHearing(true) - .withSource(source.isPresent() ? source.get() : null) - .withCourtCentreId(this.courtCentreId) - .build(); - } - } + final boolean isSeeded = nonNull(prosecutionCaseDefendantOffenceIds) && + prosecutionCaseDefendantOffenceIds.stream() + .flatMap(pc -> pc.getDefendants().stream()) + .flatMap(defendantOffenceIds -> defendantOffenceIds.getOffences().stream()) + .anyMatch(o -> nonNull(o.getSeedingHearing())); return hearingUnallocatedForListing() .withHearingId(this.hearingId) + .withCourtCentreId(this.courtCentreId) + .withSeededHearing(isSeeded ? true : null) + .withSource(source.orElse(null)) .build(); }