Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,151 @@ private List<HearingDay> sanityCheckAndEnrichCrown(final List<HearingDay> 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.
*
* <p>Steps:
* <ol>
* <li>DELETE /hearingslots/{hearingId} — release existing booked capacity.</li>
* <li>GET /hearingslots?status=DRAFT to find an anchor draft session with enough
* consecutive availability (multiday path triggered when duration &gt; 360 and
* jurisdiction=CROWN).</li>
* <li>GET /multidaysearchandbook to atomically book N consecutive draft sessions.</li>
* <li>Return the hearing with rebuilt hearing days pointing at the draft sessions.</li>
* </ol>
*
* <p>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<CourtSchedule> 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<HearingDay> 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<String, String> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -425,4 +439,39 @@ static UpdateHearingForListing stripRoomInfoIfAnyDraft(final UpdateHearingForLis
private static boolean anyDayIsDraft(final List<HearingDay> 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:
* <ul>
* <li>There are at least two hearing days (a multi-day hearing).</li>
* <li>EVERY hearing day has a {@code courtScheduleId} — the hearing was previously fully
* allocated with a confirmed session on every day.</li>
* <li>At least one of those days has no {@code courtRoomId} and is not already a draft
* session — the room assignment is being cleared (unallocated).</li>
* </ul>
*
* <p>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<HearingDay> 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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading