Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
a41c732
Throw exception if ORC order control Id is null
stefpiatek May 16, 2025
f61f73e
Apply linting suggestions
stefpiatek May 16, 2025
9e7ac61
Remove unused imports
stefpiatek May 16, 2025
deb2c83
add admission type to interchange
skeating Aug 10, 2025
567f6d9
add tests for admission type
skeating Aug 10, 2025
e0b5d52
Revert "add tests for admission type"
skeating Aug 10, 2025
a1141cd
Adds admission type to PV1 segment parsing
skeating Aug 10, 2025
5f5d6ad
fixed check style
skeating Aug 11, 2025
9bfe53f
Fixes infinate loop in hl7 parser and adds get admission type to the …
thompson318 Aug 12, 2025
351b16b
Added test (failing) for emap star data sink
thompson318 Aug 12, 2025
6814fc3
Updated HL7 test message to match test
thompson318 Aug 13, 2025
e2c45dc
Add admission type to A01 message for test
thompson318 Aug 13, 2025
ee84638
Add admission type to hospital visit schema
thompson318 Aug 13, 2025
4784d9d
added admission type to admitpatient message and adder to VisitContro…
thompson318 Aug 13, 2025
2924b5c
Merge branch 'develop' into stefpiatek/fix_malformed_hl7
skeating Aug 16, 2025
d5f8401
Merge pull request #91 from SAFEHR-data/stefpiatek/fix_malformed_hl7
skeating Aug 16, 2025
6841a46
Merge remote-tracking branch 'origin/develop' into feature/add_admiss…
skeating Aug 28, 2025
3d7d5aa
See what happens if we remove admissiontype from generic message
thompson318 Sep 4, 2025
26d2fee
Merge branch 'develop' into feature/add_admission_type
thompson318 Sep 24, 2025
d748f9c
Revert "See what happens if we remove admissiontype from generic mess…
thompson318 Sep 24, 2025
60cbc3f
Try adding admission type to other message types
thompson318 Oct 1, 2025
43a408a
Update adt message yaml to reflect that they now contain admission type
thompson318 Oct 2, 2025
0bffc18
Adds hl7 for Z99 message and message factory test
thompson318 Oct 6, 2025
d92806b
Style fix
thompson318 Oct 6, 2025
25fc8ed
Style fix
thompson318 Oct 6, 2025
963ad71
Attemping to get sub speciality change working.
thompson318 Oct 7, 2025
11934d3
Style fixes
thompson318 Oct 7, 2025
810f65f
Style fixes
thompson318 Oct 7, 2025
0167e4e
Fixed missing import
thompson318 Oct 7, 2025
12d21c6
Put the subspeciality controller in same file as pending controller, …
thompson318 Oct 9, 2025
e52832f
Implementing controller
thompson318 Oct 9, 2025
2744830
Fix style
thompson318 Oct 14, 2025
7b22778
Implementation - time search not quite working
thompson318 Oct 14, 2025
a021e84
Add matched movement value to table and populate
thompson318 Oct 15, 2025
237dbc0
style fixes
thompson318 Oct 15, 2025
fe1a713
Update HL7, implement tests, and get last value in list
thompson318 Oct 15, 2025
50a60e5
Add hospital service to data
thompson318 Oct 15, 2025
a15c101
Add in event date time
thompson318 Oct 15, 2025
5cab8c1
Update logic so only changed services are put in table, and update te…
thompson318 Oct 16, 2025
e37d4e7
Added test cases and tidied up test file
thompson318 Oct 16, 2025
d69046f
add new variables
skeating Oct 19, 2025
80ff91d
change check style to allow more than seven arguments to a function
skeating Oct 19, 2025
ffd29fe
Add the variables to the metadata class
skeating Oct 19, 2025
ad22070
fix the location controller to reflect the new metadata values
skeating Oct 19, 2025
32f117e
files for testing hoover
skeating Oct 20, 2025
7ccf9c6
Added a test case for pending transfers after the edit
thompson318 Oct 20, 2025
dddead6
Simplified setting of event time, and updated test
thompson318 Oct 20, 2025
016c315
Adds site location to department metadata
skeating Oct 21, 2025
d9ed94f
fix linting
skeating Oct 21, 2025
6bdf74c
missed the capitalization form
skeating Oct 21, 2025
0aefbf6
update all the comparison messages to include site location
skeating Oct 22, 2025
4eff55f
no I didn't check they are all UCLH
skeating Oct 22, 2025
5e7dd29
Add time check assert to test
thompson318 Oct 23, 2025
f25f2bb
Revert "no I didn't check they are all UCLH"
skeating Oct 25, 2025
e159567
Revert "update all the comparison messages to include site location"
skeating Oct 25, 2025
baaaa0a
take out the site location variable
skeating Oct 25, 2025
d60ad27
add site location as metadata to be red by the hoover
skeating Oct 25, 2025
9492f6e
fix check style
skeating Oct 25, 2025
79767f1
add additional metadata and remove the files I don't need
skeating Oct 28, 2025
b373249
Merge branch 'sk/add_location_metadata' into test_branch_zella
skeating Oct 28, 2025
19f6e81
Merge remote-tracking branch 'origin/feature/add_admission_type' into…
skeating Oct 28, 2025
5e9e40c
Merge remote-tracking branch 'origin/feature/sub_specialty_change' in…
skeating Oct 28, 2025
9f38de2
Reordered variables
skeating Oct 29, 2025
57da9ee
FIX THE VALUES TO MATCH THOSE EXPECTED
skeating Oct 30, 2025
7f1d695
Fixup department queries
stefpiatek Oct 30, 2025
da5c6b5
Merge branch 'sk/add_location_metadata' into test_branch_zella
skeating Oct 30, 2025
3dc603e
Merge branch 'main' of https://github.com/UCLH-DHCT/emap into test_br…
stefpiatek Dec 9, 2025
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 @@ -35,6 +35,7 @@
import uk.ac.ucl.rits.inform.interchange.adt.PendingDischarge;
import uk.ac.ucl.rits.inform.interchange.adt.PendingTransfer;
import uk.ac.ucl.rits.inform.interchange.adt.SwapLocations;
import uk.ac.ucl.rits.inform.interchange.adt.UpdateSubSpeciality;
import uk.ac.ucl.rits.inform.interchange.form.FormMetadataMsg;
import uk.ac.ucl.rits.inform.interchange.form.FormMsg;
import uk.ac.ucl.rits.inform.interchange.form.FormQuestionMetadataMsg;
Expand Down Expand Up @@ -361,5 +362,16 @@ public void processMessage(FormQuestionMetadataMsg msg) {
formProcessor.processQuestionMetadataMessage(msg, storedFrom);
}

/**
* Process an Update Sub Speciality message.
* @param updateSubSpeciality the message
* @throws EmapOperationMessageProcessingException if message could not be processed
*/
@Override
@Transactional
public void processMessage(UpdateSubSpeciality updateSubSpeciality) throws EmapOperationMessageProcessingException {
Instant storedFrom = Instant.now();
adtProcessor.processUpdateSubSpeciality(updateSubSpeciality, storedFrom);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class DepartmentController {
}

/**
* Get or create minomal department entity.
* Get or create minimal department entity.
* @param msg minimal department message
* @return saved department entity
*/
Expand Down Expand Up @@ -257,7 +257,10 @@ private static boolean updateFieldIfNull(String currentData, String newdata, Con
void processDepartmentStates(DepartmentMetadata msg, Department department, Instant storedFrom) throws IncompatibleDatabaseStateException {
Instant validFrom = msg.getSpecialityUpdate() == null ? msg.getDepartmentContactDate() : msg.getSpecialityUpdate();
DepartmentState currentState = new DepartmentState(
department, msg.getDepartmentRecordStatus().toString(), msg.getDepartmentSpeciality(), validFrom, storedFrom);
department, msg.getDepartmentRecordStatus().toString(),
msg.getDepartmentSpeciality(), msg.getDepartmentType(),
msg.getIsWardOrFlowArea(), msg.getIsCoreInpatientArea(),
msg.getSiteLocation(), validFrom, storedFrom);

if (departmentStateRepo.existsByDepartmentIdAndSpecialityAndValidFrom(department, msg.getDepartmentSpeciality(), validFrom)) {
logger.debug("Department State already exists in the database, no need to process further");
Expand All @@ -274,9 +277,11 @@ void processDepartmentStates(DepartmentMetadata msg, Department department, Inst
invalidatePreviousStateIfChanged(msg.getPreviousDepartmentSpeciality(), currentState, possiblePreviousState.get());
} else if (msg.getPreviousDepartmentSpeciality() != null) {
// if the previous department speciality is not in the database
DepartmentState previousState = new DepartmentState(
department, msg.getDepartmentRecordStatus().toString(), msg.getPreviousDepartmentSpeciality(),
msg.getDepartmentContactDate(), storedFrom);
DepartmentState previousState = new DepartmentState(department,
msg.getDepartmentRecordStatus().toString(),
msg.getPreviousDepartmentSpeciality(), msg.getDepartmentType(),
msg.getIsWardOrFlowArea(), msg.getIsCoreInpatientArea(),
msg.getSiteLocation(), msg.getDepartmentContactDate(), storedFrom);
previousState.setStoredUntil(currentState.getStoredFrom());
previousState.setValidUntil(currentState.getValidFrom());
departmentStateRepo.saveAll(List.of(previousState, currentState));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
import uk.ac.ucl.rits.inform.interchange.adt.PendingTransfer;
import uk.ac.ucl.rits.inform.interchange.adt.CancelPendingDischarge;
import uk.ac.ucl.rits.inform.interchange.adt.PendingDischarge;
import uk.ac.ucl.rits.inform.interchange.adt.UpdateSubSpeciality;

import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;


Expand Down Expand Up @@ -174,6 +176,54 @@ public void processMsg(HospitalVisit visit, CancelPendingTransfer msg, Instant v
plannedState.saveEntityOrAuditLogIfRequired(plannedMovementRepo, plannedMovementAuditRepo);
}

/**
* Process an Update subspeciality (Z99) request.
* <p>
* The Hl7 feed will eventually be changed so that we have an identifier per pending transfer, until then we guarantee the order of cancellations.
* If we get messages out of order and have several cancellation messages before we receive any requests,
* then the first request message for the location and encounter will add the eventDatetime to the earliest cancellation.
* Subsequent requests will add the eventDatetime to the earliest cancellation with no eventDatetime, or create a new request if none exist
* after the pending request eventDatetime.
* @param visit associated visit
* @param msg update sub speciality
* @param validFrom time in the hospital when the message was created
* @param storedFrom time that emap core started processing the message
*/
public void processMsg(HospitalVisit visit, UpdateSubSpeciality msg, Instant validFrom, Instant storedFrom) {
Location fullLocation = null;

if (msg.getFullLocationString().isSave()) {
fullLocation = locationController.getOrCreateLocation(msg.getFullLocationString().get());
}
// pseudo from issue
// match pending adt by hospital_visit and location
// if if a match is found, add in new row to the planned_movement table, event_type = EDIT/HOSPTIAL_SERVICE_CHANGE
// look for matching entry here

Instant eventDateTime = msg.getEventOccurredDateTime();

List<PlannedMovement> movements = plannedMovementRepo.findMatchingMovementsFromZ99(visit, fullLocation, eventDateTime);
if (!movements.isEmpty()) {

int mostRecentMoveIndex = movements.size() - 1;
String currentService = movements.get(mostRecentMoveIndex).getHospitalService();
String editedService = msg.getHospitalService().get();

if (!Objects.equals(currentService, editedService)) {
Long matchedMovementId = movements.get(mostRecentMoveIndex).getPlannedMovementId();
RowState<PlannedMovement, PlannedMovementAudit> plannedState = getOrCreate(
allFromRequest, visit, fullLocation, "EDIT/HOSPITAL_SERVICE_CHANGE", eventDateTime, validFrom, storedFrom
);
PlannedMovement movement = plannedState.getEntity();
// not sure why but event date time isn't being set. Add it here.
plannedState.assignIfDifferent(eventDateTime, movement.getEventDatetime(), movement::setEventDatetime);
plannedState.assignInterchangeValue(msg.getHospitalService(), movement.getHospitalService(), movement::setHospitalService);
plannedState.assignIfDifferent(matchedMovementId, movement.getMatchedMovementId(), movement::setMatchedMovementId);
plannedState.saveEntityOrAuditLogIfRequired(plannedMovementRepo, plannedMovementAuditRepo);
}
}
}

/**
* Process pending ADT cancellation.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public HospitalVisit updateOrCreateHospitalVisit(
Instant validFrom = msg.bestGuessAtValidFrom();
RowState<HospitalVisit, HospitalVisitAudit> visitState = getOrCreateHospitalVisit(
msg.getVisitNumber(), mrn, msg.getSourceSystem(), validFrom, storedFrom);

addAdmissionType(msg, visitState);
if (visitShouldBeUpdated(validFrom, msg.getSourceSystem(), visitState)) {
updateGenericData(msg, visitState);

Expand Down Expand Up @@ -219,6 +219,17 @@ private void addAdmissionDateTime(final AdmissionDateTime msg, RowState<Hospital
visitState.assignInterchangeValue(msg.getAdmissionDateTime(), visit.getAdmissionDatetime(), visit::setAdmissionDatetime);
}

/**
* Add admission type.
* @param msg AdmissionDateTime
* @param visitState visit wrapped in state class
*/
private void addAdmissionType(final AdtMessage msg, RowState<HospitalVisit, HospitalVisitAudit> visitState) {
HospitalVisit visit = visitState.getEntity();
visitState.assignInterchangeValue(msg.getAdmissionType(), visit.getAdmissionType(), visit::setAdmissionType);
}


/**
* Delete admission specific information.
* @param msg cancellation message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import uk.ac.ucl.rits.inform.interchange.adt.PendingDischarge;
import uk.ac.ucl.rits.inform.interchange.adt.PendingTransfer;
import uk.ac.ucl.rits.inform.interchange.adt.SwapLocations;
import uk.ac.ucl.rits.inform.interchange.adt.UpdateSubSpeciality;

import java.time.Instant;
import java.util.List;
Expand All @@ -42,11 +43,11 @@ public class AdtProcessor {

/**
* Implicitly wired spring beans.
* @param personController person interactions.
* @param visitController encounter interactions.
* @param patientLocationController location interactions.
* @param pendingAdtController pending ADT interactions.
* @param deletionController cascading deletes for hospital visits.
* @param personController person interactions.
* @param visitController encounter interactions.
* @param patientLocationController location interactions.
* @param pendingAdtController pending ADT interactions.
* @param deletionController cascading deletes for hospital visits.
*/
public AdtProcessor(PersonController personController, VisitController visitController,
PatientLocationController patientLocationController, PendingAdtController pendingAdtController,
Expand Down Expand Up @@ -244,4 +245,21 @@ public void processPendingAdt(CancelPendingDischarge msg, Instant storedFrom) th
pendingAdtController.processMsg(visit, msg, validFrom, storedFrom);
}


/**
* Process an update subspeciality message.
* <p>
* Updates the sub speciality in the hospital visit table
* @param msg change sub speciality adt message
* @param storedFrom time that emap core started processing the message
* @throws RequiredDataMissingException if the visit number is missing
*/
@Transactional
public void processUpdateSubSpeciality(UpdateSubSpeciality msg, Instant storedFrom) throws RequiredDataMissingException {
Instant validFrom = msg.bestGuessAtValidFrom();
HospitalVisit visit = processPersonAndVisit(msg, storedFrom, validFrom);
// patientLocationController.processVisitLocation(visit, msg, storedFrom);
pendingAdtController.processMsg(visit, msg, validFrom, storedFrom);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ List<PlannedMovement> findMatchingMovementsFromRequest(
String eventType, HospitalVisit hospitalVisitId, Location plannedLocation, Instant eventDatetime
);

/**
* Try and find a matching planned movement from a Z99 edit sub speciality message.
* <p>
* Always find planned location and hospital visit Id, then:
* - Messages before the the same event date time
* @param hospitalVisitId hospital visit associated with the movement
* @param plannedLocation planned location for the movement
* @param eventDatetime the datetime that event was created
* @return planned movement entities
*/
@Query("from PlannedMovement "
+ "where hospitalVisitId = :hospitalVisitId "
+ "and (locationId = :plannedLocation or (:plannedLocation is null and locationId is null)) "
+ "and (eventDatetime <= :eventDatetime) "
+ "order by eventDatetime "
)
List<PlannedMovement> findMatchingMovementsFromZ99(
HospitalVisit hospitalVisitId, Location plannedLocation, Instant eventDatetime
);


/**
* Try and find a matching planned movement from a pending adt cancellation message.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public void testCreateNewAdmit() throws Exception {

HospitalVisit visit = visits.get(0);
assertNotNull(visit.getAdmissionDatetime());
assertNotNull(visit.getAdmissionType());
assertNull(visit.getPresentationDatetime());
// no audit log should be added
assertTrue(getAllAuditHospitalVisits().isEmpty());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package uk.ac.ucl.rits.inform.datasinks.emapstar.adt;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import uk.ac.ucl.rits.inform.datasinks.emapstar.MessageProcessingBase;
import uk.ac.ucl.rits.inform.datasinks.emapstar.repos.CoreDemographicRepository;
import uk.ac.ucl.rits.inform.datasinks.emapstar.repos.HospitalVisitRepository;
import uk.ac.ucl.rits.inform.datasinks.emapstar.repos.MrnRepository;
import uk.ac.ucl.rits.inform.datasinks.emapstar.repos.PlannedMovementRepository;
import uk.ac.ucl.rits.inform.informdb.movement.PlannedMovement;
import uk.ac.ucl.rits.inform.interchange.adt.PendingTransfer;
import uk.ac.ucl.rits.inform.interchange.adt.UpdateSubSpeciality;

import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.NoSuchElementException;


import static org.junit.jupiter.api.Assertions.*;

class TestUpdateSubSpeciality extends MessageProcessingBase {
private static final Logger logger = LoggerFactory.getLogger(TestPendingAdt.class);
@Autowired
private MrnRepository mrnRepository;
@Autowired
private CoreDemographicRepository coreDemographicRepository;
@Autowired
private HospitalVisitRepository hospitalVisitRepository;
@Autowired
private PlannedMovementRepository plannedMovementRepository;

// end to end messages
private UpdateSubSpeciality updateSubSpeciality;
private PendingTransfer pendingTransfer;
private PendingTransfer pendingTransferLater;
private PendingTransfer pendingTransferAfter;

private static final String VISIT_NUMBER = "123412341234";
private static final String LOCATION_STRING = "1020100166^SDEC BY02^11 SDEC";


private PlannedMovement getPlannedMovementOrThrow(String visitNumber, String location) {
return plannedMovementRepository
.findByHospitalVisitIdEncounterAndLocationIdLocationString(visitNumber, location).orElseThrow();
}

@BeforeEach
void setup() throws IOException {
updateSubSpeciality = messageFactory.getAdtMessage("Location/Moves/09_Z99.yaml");

pendingTransfer = messageFactory.getAdtMessage("pending/A15.yaml");
pendingTransferLater = messageFactory.getAdtMessage("pending/A15.yaml");
pendingTransferAfter = messageFactory.getAdtMessage("pending/A15.yaml");

Instant laterTime = pendingTransferLater.getEventOccurredDateTime().plus(1, ChronoUnit.MINUTES);
pendingTransferLater.setEventOccurredDateTime(laterTime);

Instant afterTime = pendingTransferAfter.getEventOccurredDateTime().plus(1, ChronoUnit.HOURS);
pendingTransferAfter.setEventOccurredDateTime(afterTime);
}

/**
* Given that no entities exist in the database
* When a Z99 Message is created
* Mrn, core demographics and hospital visit entities should be created.
* A planned movement should not be created as there are no matching planned moves in the
* planned movement table
* @throws Exception shouldn't happen
*/
@Test
void testUpdateCreatesOtherEntities() throws Exception {
dbOps.processMessage(updateSubSpeciality);

assertEquals(1, mrnRepository.count());
assertEquals(1, coreDemographicRepository.count());
assertEquals(1, hospitalVisitRepository.count());

assertThrows(NoSuchElementException.class, () -> getPlannedMovementOrThrow(VISIT_NUMBER, LOCATION_STRING));
}

/**
* If more than one pending transfer exists find the most recent one and if it
* has a different hospital service insert the edit into the planned movement table.
*/
@Test
void testEditMessageInsertedIfHospitalServicesAreDifferent() throws Exception {

dbOps.processMessage(pendingTransfer);
dbOps.processMessage(pendingTransferLater);
dbOps.processMessage(pendingTransferAfter);
dbOps.processMessage(updateSubSpeciality);

// and entry should have been added to the planned movement table with the correct matched planned movement id
List<PlannedMovement> movements = plannedMovementRepository.findAllByHospitalVisitIdEncounter(VISIT_NUMBER);
assertEquals(4, movements.size());
assertEquals("EDIT/HOSPITAL_SERVICE_CHANGE", movements.get(3).getEventType());
assertEquals(7, movements.get(3).getMatchedMovementId());
assertEquals(Instant.parse("2022-04-22T00:00:00Z"), movements.get(3).getEventDatetime());
}

/**
* Find the most recent one, but don't add to table if it has the same hospital service as the edit message
*/
@Test
void testEditMessageNotInsertedIfHospitalServicesAreTheSame() throws Exception {
dbOps.processMessage(pendingTransfer);
updateSubSpeciality.setHospitalService(pendingTransfer.getHospitalService());
dbOps.processMessage(updateSubSpeciality);

// and entry should have been added to the planned movement table with the correct matched planned movement id
List<PlannedMovement> movements = plannedMovementRepository.findAllByHospitalVisitIdEncounter(VISIT_NUMBER);
assertEquals(1, movements.size());
assertEquals("TRANSFER", movements.get(0).getEventType());
}

/**
* If pending transfers only exist after the edit event, don't add edit message.
*/
@Test
void testEditMessageNotInsertedIfTransfersAreAfter() throws Exception {
dbOps.processMessage(pendingTransferAfter);
dbOps.processMessage(updateSubSpeciality);

assertEquals(1, mrnRepository.count());
assertEquals(1, coreDemographicRepository.count());
assertEquals(1, hospitalVisitRepository.count());

// one entry should have been added to the planned movement table with the correct matched planned movement id
List<PlannedMovement> movements = plannedMovementRepository.findAllByHospitalVisitIdEncounter(VISIT_NUMBER);
assertEquals(1, movements.size());
assertEquals("TRANSFER", movements.get(0).getEventType());
//assertThrows(NoSuchElementException.class, () -> getPlannedMovementOrThrow(VISIT_NUMBER, LOCATION_STRING));
}
}
6 changes: 4 additions & 2 deletions emap-checker.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@
<!-- Checks for Size Violations. -->
<!-- See http://checkstyle.sf.net/config_sizes.html -->
<module name="MethodLength"/>
<module name="ParameterNumber"/>

<module name="ParameterNumber">
<property name="max" value="10"/>
<property name="tokens" value="METHOD_DEF"/>
</module>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="EmptyForIteratorPad"/>
Expand Down
Loading
Loading